diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 new file mode 100644 index 0000000..fb85683 --- /dev/null +++ b/.build/GraphKitAuth.tasks.ps1 @@ -0,0 +1,1828 @@ +param([switch] $SkipTaskRegistration) + +$script:GraphKitAuthPayloadFiles = @( + 'GraphKit.Auth.Contracts.dll' + 'GraphKit.Auth.dll' + 'GraphKit.Auth.deps.json' + 'Microsoft.Identity.Client.dll' + 'Microsoft.IdentityModel.Abstractions.dll' +) +$script:GraphKitAuthProviderFiles = @( + 'GraphKit.Auth.dll' + 'GraphKit.Auth.deps.json' + 'Microsoft.Identity.Client.dll' + 'Microsoft.IdentityModel.Abstractions.dll' +) +$script:GraphKitAuthStage = $null +$script:GraphKitAuthStageCaptureType = $null +$script:GraphKitAuthAbiFixtureState = $null +$script:GraphKitAuthAbiGitConfigState = $null +$script:GraphKitAuthExpectedTestCount = 77 + +function Assert-GraphKitAuthTestResult { + [CmdletBinding()] + param([Parameter(Mandatory)][xml] $Result) + + $outcomes = @($Result.TestRun.Results.UnitTestResult | ForEach-Object { [string]$_.outcome }) + $counters = $Result.TestRun.ResultSummary.Counters + $zeroCounterNames = @('failed','error','timeout','aborted','inconclusive','notExecuted', + 'notRunnable','disconnected','warning','inProgress','pending') + $nonZeroCounters = @($zeroCounterNames | Where-Object { [int]$counters.$_ -ne 0 }) + if ($outcomes.Count -ne $script:GraphKitAuthExpectedTestCount -or + @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -ne 0 -or + [int]$counters.total -ne $outcomes.Count -or [int]$counters.executed -ne $outcomes.Count -or + [int]$counters.passed -ne $outcomes.Count -or $nonZeroCounters.Count -ne 0) { + throw "GraphKit.Auth machine-readable test result is incomplete: expected exactly $($script:GraphKitAuthExpectedTestCount); total=$($outcomes.Count), passed=$(@($outcomes | Where-Object { $_ -ceq 'Passed' }).Count)." + } +} + +function Initialize-GraphKitAuthStageCapture { + if ($null -ne $script:GraphKitAuthStageCaptureType) { return } + $root = Split-Path $PSScriptRoot -Parent + $helperPath = Join-Path $root 'scripts/private/GraphKit.AuthStageCapture.cs' + if (-not (Test-Path -LiteralPath $helperPath -PathType Leaf)) { + throw "The private GraphKit.Auth stage-capture helper is missing at '$helperPath'." + } + $helperBytes = [IO.File]::ReadAllBytes($helperPath) + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + try { $template = $strictUtf8.GetString($helperBytes) } + catch { throw "The private GraphKit.Auth capture helper is not strict UTF-8." } + $marker = '__GRAPHKIT_AUTH_STAGE_CAPTURE_NAMESPACE__' + if (($template.Split([string[]]@($marker), [StringSplitOptions]::None).Length - 1) -ne 1) { + throw 'The private GraphKit.Auth capture helper must contain exactly one namespace marker.' + } + $helperHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($helperBytes)).ToLowerInvariant() + $nonce = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(16)).ToLowerInvariant() + $namespace = "GraphKit.R8.StageCapture.H$helperHash.N$nonce" + $expectedTypeName = "$namespace.GraphKitAuthStageCapture" + if (@([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ }).Count) { + throw "The generated GraphKit.Auth capture type '$expectedTypeName' already exists." + } + $compiled = @(Add-Type -TypeDefinition $template.Replace($marker, $namespace) -PassThru -ErrorAction Stop) + $compiledMatches = @($compiled | Where-Object FullName -CEQ $expectedTypeName) + $loaded = @([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ }) + if ($compiledMatches.Count -ne 1 -or $loaded.Count -ne 1 -or -not [object]::ReferenceEquals($compiledMatches[0], $loaded[0])) { + throw 'The proof-bound GraphKit.Auth capture helper collided during compilation.' + } + $script:GraphKitAuthStageCaptureType = $compiledMatches[0] +} + +function Get-GraphKitAuthOutputRoot { + param([Parameter(Mandatory)][string] $OutputRoot) + $resolved = [IO.Path]::GetFullPath($OutputRoot) + if ([IO.Path]::GetFileName($resolved) -ceq 'GraphKit.Auth') { return $resolved } + return Join-Path $resolved 'GraphKit.Auth' +} + +function Get-GraphKitAuthPortableChildEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $ParentPath, + [Parameter(Mandatory)][string] $ChildName, + [Parameter(Mandatory)][string] $Kind + ) + Assert-GraphKitAuthSafeSegment -Value $ChildName -Kind $Kind + $parent = [IO.Path]::GetFullPath($ParentPath) + $expectedNfc = $ChildName.Normalize([Text.NormalizationForm]::FormC) + $portableMatches = @([IO.Directory]::EnumerateFileSystemEntries($parent) | Where-Object { + $actualName = [IO.Path]::GetFileName($_) + [string]::Equals( + $actualName.Normalize([Text.NormalizationForm]::FormC), + $expectedNfc, + [StringComparison]::OrdinalIgnoreCase) + }) + if ($portableMatches.Count -gt 1) { + throw "The GraphKit.Auth $Kind has multiple entries for the portable name '$ChildName'." + } + if ($portableMatches.Count -eq 1) { + $actualName = [IO.Path]::GetFileName($portableMatches[0]) + if ($actualName -cne $ChildName) { + throw "The GraphKit.Auth $Kind '$actualName' is a portable alias for '$ChildName'." + } + return [pscustomobject]@{ + Exists = $true + Path = [string]$portableMatches[0] + Name = $actualName + } + } + return [pscustomobject]@{ Exists = $false; Path = $null; Name = $null } +} + +function Remove-GraphKitAuthVerifiedEmptyDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $ParentPath, + [Parameter(Mandatory)] $ParentEvidence, + [Parameter(Mandatory)][string] $ChildName, + [Parameter(Mandatory)] $ChildEvidence, + [Parameter(Mandatory)][string] $Kind + ) + $parent = [IO.Path]::GetFullPath($ParentPath) + $parentParent = Split-Path $parent -Parent + $parentName = [IO.Path]::GetFileName($parent) + $reopenedParent = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $parentParent, $parentName) + if ([string]$reopenedParent.NativeIdentity -cne [string]$ParentEvidence.NativeIdentity -or + [string]$reopenedParent.PhysicalPath -cne [string]$ParentEvidence.PhysicalPath) { + throw "The GraphKit.Auth $Kind parent changed; ambiguous cleanup was refused." + } + $entry = Get-GraphKitAuthPortableChildEntry -ParentPath $parent ` + -ChildName $ChildName -Kind $Kind + if (-not $entry.Exists) { + throw "The GraphKit.Auth $Kind disappeared; ambiguous cleanup was refused." + } + $current = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName) + if ([string]$current.NativeIdentity -cne [string]$ChildEvidence.NativeIdentity -or + [string]$current.PhysicalPath -cne [string]$ChildEvidence.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath $reopenedParent.PhysicalPath $current.PhysicalPath) -or + @([IO.Directory]::EnumerateFileSystemEntries($entry.Path)).Count -ne 0) { + throw "The GraphKit.Auth $Kind is not the identity-bound empty directory; ambiguous cleanup was refused." + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($entry.Path, $true, $true) + $reopened = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName) + if ([string]$reopened.NativeIdentity -cne [string]$ChildEvidence.NativeIdentity -or + [string]$reopened.PhysicalPath -cne [string]$ChildEvidence.PhysicalPath -or + @([IO.Directory]::EnumerateFileSystemEntries($entry.Path)).Count -ne 0) { + throw "The GraphKit.Auth $Kind changed while cleanup access was applied; ambiguous cleanup was refused." + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop +} + +function Initialize-GraphKitAuthOwnerDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $ParentPath, + [Parameter(Mandatory)] $ParentEvidence, + [Parameter(Mandatory)][string] $ChildName, + [Parameter(Mandatory)][string] $Kind, + [scriptblock] $AfterChildInspection, + [switch] $PreserveCreatedOnFailure + ) + Initialize-GraphKitAuthStageCapture + Assert-GraphKitAuthSafeSegment -Value $ChildName -Kind $Kind + $parent = [IO.Path]::GetFullPath($ParentPath) + $parentParent = Split-Path $parent -Parent + $parentName = [IO.Path]::GetFileName($parent) + $reopenedParent = $script:GraphKitAuthStageCaptureType::InspectDirectory($parentParent, $parentName) + if ([string]$reopenedParent.NativeIdentity -cne [string]$ParentEvidence.NativeIdentity -or + [string]$reopenedParent.PhysicalPath -cne [string]$ParentEvidence.PhysicalPath) { + throw "The GraphKit.Auth $Kind parent changed before child creation." + } + $child = Join-Path $parent $ChildName + $created = $false + $reusedAtomicCollision = $false + $before = $null + try { + $entry = Get-GraphKitAuthPortableChildEntry -ParentPath $parent ` + -ChildName $ChildName -Kind $Kind + if (-not $entry.Exists) { + try { + $before = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $parent, $ChildName) + $created = $true + } + catch { + if ($_.Exception.Message -notmatch 'Atomic owner-only directory destination collision') { + throw + } + $entryAfterCollision = Get-GraphKitAuthPortableChildEntry -ParentPath $parent ` + -ChildName $ChildName -Kind $Kind + if (-not $entryAfterCollision.Exists) { throw } + $before = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName) + if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($before)) { + throw "The GraphKit.Auth $Kind collision did not resolve to an exact current-owner-only writable directory." + } + $reusedAtomicCollision = $true + } + } + $entry = Get-GraphKitAuthPortableChildEntry -ParentPath $parent ` + -ChildName $ChildName -Kind $Kind + if (-not $entry.Exists) { + throw "The GraphKit.Auth $Kind path was not created." + } + if ($null -eq $before) { + $before = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName) + } + if (-not (Test-GraphKitAuthContainedPhysicalPath $reopenedParent.PhysicalPath $before.PhysicalPath)) { + throw "The GraphKit.Auth $Kind path is not one physically contained no-follow directory." + } + if ($created -and + -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($before)) { + throw "The GraphKit.Auth $Kind did not begin with exact current-owner-only writable access." + } + if ($created -and $null -ne $AfterChildInspection) { + & $AfterChildInspection $Kind $child $before + } + if (-not $created -and -not $reusedAtomicCollision) { + if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($before)) { + throw "The GraphKit.Auth $Kind is not exact current-owner-only writable before reuse." + } + } + $after = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName) + if ([string]$after.NativeIdentity -cne [string]$before.NativeIdentity -or + [string]$after.PhysicalPath -cne [string]$before.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath $reopenedParent.PhysicalPath $after.PhysicalPath) -or + -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($after)) { + throw "The GraphKit.Auth $Kind path changed while owner-only access was applied." + } + return $after + } + catch { + $primary = $_ + if ($created -and $null -ne $before -and -not $PreserveCreatedOnFailure) { + try { + Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $parent ` + -ParentEvidence $reopenedParent -ChildName $ChildName ` + -ChildEvidence $before -Kind $Kind + } + catch { + throw "GraphKit.Auth $Kind initialization failed and ambiguous cleanup was refused: $($_.Exception.Message) Original failure: $($primary.Exception.Message)" + } + } + throw $primary + } +} + +function Initialize-GraphKitAuthBuildAuthorityRoot { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $OutputRoot, + [scriptblock] $AfterChildInspection + ) + Initialize-GraphKitAuthStageCapture + $output = [IO.Path]::GetFullPath($OutputRoot) + if (-not (Test-Path -LiteralPath $output -PathType Container)) { + $null = [IO.Directory]::CreateDirectory($output) + } + $outputParent = Split-Path $output -Parent + $outputName = [IO.Path]::GetFileName($output) + $outputEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $outputParent, $outputName) + $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $output ` + -ParentEvidence $outputEvidence -ChildName 'GraphKit.Auth' ` + -Kind 'build auth root' -AfterChildInspection $AfterChildInspection ` + -PreserveCreatedOnFailure + $authRoot = Join-Path $output 'GraphKit.Auth' + $null = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'capture' ` + -Kind 'build capture root' -AfterChildInspection $AfterChildInspection ` + -PreserveCreatedOnFailure + return $authEvidence +} + +function New-GraphKitAuthBuildWorkRoot { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $OutputRoot, + [Parameter(Mandatory)][string] $RunId, + [scriptblock] $AfterCreate + ) + Initialize-GraphKitAuthStageCapture + if ($RunId -cnotmatch '^[0-9a-f]{48}$') { + throw "The GraphKit.Auth build workspace run ID '$RunId' is not exactly 48 lowercase hexadecimal characters." + } + $authRoot = Get-GraphKitAuthOutputRoot $OutputRoot + $captureRoot = Join-Path $authRoot 'capture' + $output = [IO.Path]::GetFullPath($OutputRoot) + $outputEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($output) + $authEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + (Split-Path $authRoot -Parent), [IO.Path]::GetFileName($authRoot)) + $captureEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $authRoot, 'capture') + if (-not (Test-GraphKitAuthContainedPhysicalPath ` + $outputEvidence.PhysicalPath $authEvidence.PhysicalPath) -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $authEvidence.PhysicalPath $captureEvidence.PhysicalPath) -or + -not (Test-GraphKitAuthOwnerOnlyWritableDirectory $captureEvidence)) { + throw 'The GraphKit.Auth build workspace capture root is not exact current-owner-only writable and physically contained.' + } + $name = ".build-$RunId" + $existing = Get-GraphKitAuthPortableChildEntry -ParentPath $captureRoot ` + -ChildName $name -Kind 'build workspace' + if ($existing.Exists) { + throw "The GraphKit.Auth build workspace '$name' already exists." + } + $evidence = $null + try { + $evidence = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $captureRoot, $name) + if ($null -ne $AfterCreate) { & $AfterCreate $evidence } + if (-not (Test-GraphKitAuthContainedPhysicalPath ` + $captureEvidence.PhysicalPath $evidence.PhysicalPath) -or + -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($evidence)) { + throw 'The GraphKit.Auth build workspace was not created with exact owner-only access and containment.' + } + } + catch { + $primary = $_ + if ($null -ne $evidence) { + try { + Assert-GraphKitAuthExactDirectoryClosure -Directory (Join-Path $captureRoot $name) ` + -ExpectedNames @() -Kind 'incomplete build workspace, which must be empty' + $recoveryQuarantine = New-GraphKitAuthTaskQuarantineRoot -OutputRoot $output + $partialWork = [pscustomobject]@{ + Name = $name + Path = Join-Path $captureRoot $name + Evidence = $evidence + CapturePath = $captureRoot + CaptureEvidence = $captureEvidence + AuthPath = $authRoot + AuthEvidence = $authEvidence + OutputPath = $output + OutputEvidence = $outputEvidence + } + $null = Move-GraphKitAuthBuildWorkToQuarantine ` + -BuildWork $partialWork -QuarantineRoot $recoveryQuarantine.Path + } + catch { + throw "GraphKit.Auth build workspace initialization failed and ambiguous cleanup was refused: $($_.Exception.Message) Original failure: $($primary.Exception.Message)" + } + } + throw $primary + } + return [pscustomobject]@{ + PSTypeName = 'GraphKit.Auth.BuildWorkRoot' + Name = $name + Path = Join-Path $captureRoot $name + Evidence = $evidence + CapturePath = $captureRoot + CaptureEvidence = $captureEvidence + AuthPath = $authRoot + AuthEvidence = $authEvidence + OutputPath = $output + OutputEvidence = $outputEvidence + } +} + +function Remove-GraphKitAuthVerifiedInstallCandidate { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $StageRoot, + [Parameter(Mandatory)][string] $InstallName, + [Parameter(Mandatory)] $InstallEvidence, + [Parameter(Mandatory)][string] $FullVersion, + [Parameter(Mandatory)] $TemporaryVersionEvidence, + [Parameter(Mandatory)][string] $CandidateStagePath + ) + $installPath = Join-Path $StageRoot $InstallName + $temporaryVersion = Join-Path $installPath $FullVersion + $currentInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory($StageRoot, $InstallName) + if ([string]$currentInstall.NativeIdentity -cne [string]$InstallEvidence.NativeIdentity -or + [string]$currentInstall.PhysicalPath -cne [string]$InstallEvidence.PhysicalPath) { + throw 'The GraphKit.Auth losing install candidate changed identity; ambiguous cleanup was refused.' + } + $currentVersion = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $installPath, $FullVersion) + if ([string]$currentVersion.NativeIdentity -cne [string]$TemporaryVersionEvidence.NativeIdentity -or + [string]$currentVersion.PhysicalPath -cne [string]$TemporaryVersionEvidence.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath $currentInstall.PhysicalPath $currentVersion.PhysicalPath)) { + throw 'The GraphKit.Auth temporary version wrapper changed identity; ambiguous cleanup was refused.' + } + Assert-GraphKitAuthExactDirectoryClosure -Directory $installPath ` + -ExpectedNames @($FullVersion) -Kind 'losing install root' + $digestName = [IO.Path]::GetFileName($CandidateStagePath) + if ($digestName -cnotmatch '^[0-9a-f]{64}$' -or + [IO.Path]::GetFullPath($CandidateStagePath) -cne [IO.Path]::GetFullPath((Join-Path $temporaryVersion $digestName))) { + throw 'The GraphKit.Auth candidate path is not the exact digest child of its temporary version wrapper.' + } + Assert-GraphKitAuthExactDirectoryClosure -Directory $temporaryVersion ` + -ExpectedNames @($digestName) -Kind 'temporary version wrapper' + $verified = Test-GraphKitAuthSealedStage -StagePath $CandidateStagePath -FullVersion $FullVersion + $candidateEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $temporaryVersion, $digestName) + $payloadEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $verified.StagePath, 'payload') + $manifestEvidence = $script:GraphKitAuthStageCaptureType::InspectFile( + $verified.StagePath, 'manifest.json') + foreach ($file in @($verified.Manifest.files)) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + (Join-Path $verified.StagePath ([string]$file.path)), $false, $true) + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($verified.ManifestPath, $false, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($verified.PayloadPath, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($verified.StagePath, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersion, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($installPath, $true, $true) + + $reopenedInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory($StageRoot, $InstallName) + $reopenedVersion = $script:GraphKitAuthStageCaptureType::InspectDirectory($installPath, $FullVersion) + $reopenedCandidate = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $temporaryVersion, $digestName) + $reopenedPayload = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $verified.StagePath, 'payload') + $reopenedManifest = $script:GraphKitAuthStageCaptureType::InspectFile( + $verified.StagePath, 'manifest.json') + if ([string]$reopenedInstall.NativeIdentity -cne [string]$InstallEvidence.NativeIdentity -or + [string]$reopenedInstall.PhysicalPath -cne [string]$InstallEvidence.PhysicalPath -or + [string]$reopenedVersion.NativeIdentity -cne [string]$TemporaryVersionEvidence.NativeIdentity -or + [string]$reopenedVersion.PhysicalPath -cne [string]$TemporaryVersionEvidence.PhysicalPath -or + [string]$reopenedCandidate.NativeIdentity -cne [string]$candidateEvidence.NativeIdentity -or + [string]$reopenedCandidate.PhysicalPath -cne [string]$candidateEvidence.PhysicalPath -or + [string]$reopenedPayload.NativeIdentity -cne [string]$payloadEvidence.NativeIdentity -or + [string]$reopenedPayload.PhysicalPath -cne [string]$payloadEvidence.PhysicalPath -or + [string]$reopenedManifest.NativeIdentity -cne [string]$manifestEvidence.NativeIdentity -or + [string]$reopenedManifest.PhysicalPath -cne [string]$manifestEvidence.PhysicalPath -or + [string]$reopenedManifest.Sha256 -cne $digestName -or + [long]$reopenedManifest.LinkCount -ne 1 -or + -not (Test-GraphKitAuthContainedPhysicalPath $reopenedInstall.PhysicalPath $reopenedVersion.PhysicalPath) -or + -not (Test-GraphKitAuthContainedPhysicalPath $reopenedVersion.PhysicalPath $reopenedCandidate.PhysicalPath) -or + -not (Test-GraphKitAuthContainedPhysicalPath $reopenedCandidate.PhysicalPath $reopenedPayload.PhysicalPath)) { + throw 'The GraphKit.Auth losing install candidate changed before recursive deletion; ambiguous cleanup was refused.' + } + Assert-GraphKitAuthExactDirectoryClosure -Directory $installPath ` + -ExpectedNames @($FullVersion) -Kind 'losing install root before deletion' + Assert-GraphKitAuthExactDirectoryClosure -Directory $temporaryVersion ` + -ExpectedNames @($digestName) -Kind 'temporary version wrapper before deletion' + Assert-GraphKitAuthExactDirectoryClosure -Directory $verified.StagePath ` + -ExpectedNames @('manifest.json','payload') -Kind 'losing candidate envelope before deletion' + Assert-GraphKitAuthExactDirectoryClosure -Directory $verified.PayloadPath ` + -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'losing candidate payload before deletion' + foreach ($record in @($verified.Manifest.files)) { + $actual = $script:GraphKitAuthStageCaptureType::InspectFile( + $verified.StagePath, [string]$record.path) + if ([string]$actual.NativeIdentity -cne [string]$record.nativeIdentity -or + [string]$actual.Sha256 -cne [string]$record.sha256 -or + [long]$actual.Length -ne [long]$record.length -or + [long]$actual.LinkCount -ne 1 -or + -not (Test-GraphKitAuthContainedPhysicalPath $reopenedPayload.PhysicalPath $actual.PhysicalPath)) { + throw "The GraphKit.Auth losing candidate file '$($record.path)' changed before recursive deletion; ambiguous cleanup was refused." + } + } + Remove-Item -LiteralPath $installPath -Recurse -Force -ErrorAction Stop +} + +function Remove-GraphKitAuthVerifiedCaptureCandidate { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $CaptureRoot, + [Parameter(Mandatory)][string] $CaptureName, + [Parameter(Mandatory)] $CaptureEvidence, + [Parameter(Mandatory)] $PayloadEvidence, + [Parameter(Mandatory)] $CapturedFileEvidence, + $ManifestWrite + ) + $capture = Join-Path $CaptureRoot $CaptureName + $payload = Join-Path $capture 'payload' + $currentCapture = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $CaptureRoot, $CaptureName) + $currentPayload = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $capture, 'payload') + if ([string]$currentCapture.NativeIdentity -cne [string]$CaptureEvidence.NativeIdentity -or + [string]$currentCapture.PhysicalPath -cne [string]$CaptureEvidence.PhysicalPath -or + [string]$currentPayload.NativeIdentity -cne [string]$PayloadEvidence.NativeIdentity -or + [string]$currentPayload.PhysicalPath -cne [string]$PayloadEvidence.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath $currentCapture.PhysicalPath $currentPayload.PhysicalPath)) { + throw 'The GraphKit.Auth incomplete capture changed identity; ambiguous cleanup was refused.' + } + $expectedPayloadNames = @($CapturedFileEvidence.Keys) + Assert-GraphKitAuthExactDirectoryClosure -Directory $payload ` + -ExpectedNames $expectedPayloadNames -Kind 'incomplete capture payload' + foreach ($name in $expectedPayloadNames) { + $expected = $CapturedFileEvidence[$name] + $actual = $script:GraphKitAuthStageCaptureType::InspectFile($payload, $name) + if ([string]$actual.NativeIdentity -cne [string]$expected.NativeIdentity -or + [string]$actual.PhysicalPath -cne [string]$expected.PhysicalPath -or + [string]$actual.Sha256 -cne [string]$expected.Sha256 -or + [long]$actual.Length -ne [long]$expected.Length -or + [long]$actual.LinkCount -ne 1 -or + -not (Test-GraphKitAuthContainedPhysicalPath $currentPayload.PhysicalPath $actual.PhysicalPath)) { + throw "The GraphKit.Auth incomplete capture file '$name' changed; ambiguous cleanup was refused." + } + } + $expectedEnvelopeNames = [Collections.Generic.List[string]]::new() + $expectedEnvelopeNames.Add('payload') + if ($null -ne $ManifestWrite) { $expectedEnvelopeNames.Add('manifest.json') } + Assert-GraphKitAuthExactDirectoryClosure -Directory $capture ` + -ExpectedNames @($expectedEnvelopeNames) -Kind 'incomplete capture envelope' + if ($null -ne $ManifestWrite) { + $expectedManifest = $ManifestWrite.Destination + $actualManifest = $script:GraphKitAuthStageCaptureType::InspectFile($capture, 'manifest.json') + if ([string]$actualManifest.NativeIdentity -cne [string]$expectedManifest.NativeIdentity -or + [string]$actualManifest.PhysicalPath -cne [string]$expectedManifest.PhysicalPath -or + [string]$actualManifest.Sha256 -cne [string]$expectedManifest.Sha256 -or + [long]$actualManifest.Length -ne [long]$expectedManifest.Length -or + [long]$actualManifest.LinkCount -ne 1) { + throw 'The GraphKit.Auth incomplete manifest changed; ambiguous cleanup was refused.' + } + } + foreach ($name in $expectedPayloadNames) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + (Join-Path $payload $name), $false, $true) + } + if ($null -ne $ManifestWrite) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + (Join-Path $capture 'manifest.json'), $false, $true) + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($payload, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($capture, $true, $true) + Remove-Item -LiteralPath $capture -Recurse -Force -ErrorAction Stop +} + +function Remove-GraphKitAuthVerifiedEmptyInstallCandidate { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $StageRoot, + [Parameter(Mandatory)][string] $InstallName, + [Parameter(Mandatory)] $InstallEvidence, + [Parameter(Mandatory)][string] $FullVersion, + [Parameter(Mandatory)] $TemporaryVersionEvidence + ) + $installRoot = Join-Path $StageRoot $InstallName + $temporaryVersionRoot = Join-Path $installRoot $FullVersion + $currentInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $StageRoot, $InstallName) + $currentVersion = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $installRoot, $FullVersion) + if ([string]$currentInstall.NativeIdentity -cne [string]$InstallEvidence.NativeIdentity -or + [string]$currentInstall.PhysicalPath -cne [string]$InstallEvidence.PhysicalPath -or + [string]$currentVersion.NativeIdentity -cne [string]$TemporaryVersionEvidence.NativeIdentity -or + [string]$currentVersion.PhysicalPath -cne [string]$TemporaryVersionEvidence.PhysicalPath -or + @([IO.Directory]::EnumerateFileSystemEntries($temporaryVersionRoot)).Count -ne 0) { + throw 'The GraphKit.Auth incomplete install wrapper is ambiguous.' + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + $temporaryVersionRoot, $true, $true) + Remove-Item -LiteralPath $temporaryVersionRoot -Force -ErrorAction Stop + if (@([IO.Directory]::EnumerateFileSystemEntries($installRoot)).Count -ne 0) { + throw 'The GraphKit.Auth incomplete install root became non-empty during cleanup.' + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($installRoot, $true, $true) + Remove-Item -LiteralPath $installRoot -Force -ErrorAction Stop +} + +function Assert-GraphKitAuthSafeSegment { + param([Parameter(Mandatory)][string] $Value, [Parameter(Mandatory)][string] $Kind) + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -in @('.', '..') -or + [IO.Path]::IsPathRooted($Value) -or $Value.IndexOfAny([char[]]@('/', '\')) -ge 0 -or + -not $Value.IsNormalized([Text.NormalizationForm]::FormC)) { + throw "The GraphKit.Auth $Kind '$Value' is not one safe NFC path segment." + } +} + +function Assert-GraphKitAuthPortableNameSet { + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Names, + [Parameter(Mandatory)][string] $Kind + ) + $portable = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $normalized = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($name in $Names) { + if ([string]::IsNullOrWhiteSpace($name) -or $name.IndexOf('\') -ge 0 -or + -not $name.IsNormalized([Text.NormalizationForm]::FormC)) { + throw "The GraphKit.Auth $Kind contains an unsafe or non-NFC name." + } + if (-not $portable.Add($name) -or + -not $normalized.Add($name.Normalize([Text.NormalizationForm]::FormC))) { + throw "The GraphKit.Auth $Kind contains a portable alias for '$name'." + } + } +} + +function Assert-GraphKitAuthExactDirectoryClosure { + param( + [Parameter(Mandatory)][string] $Directory, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $ExpectedNames, + [Parameter(Mandatory)][string] $Kind + ) + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { + throw "The GraphKit.Auth $Kind directory '$Directory' is missing." + } + $actual = [Collections.Generic.List[string]]::new() + foreach ($entryPath in [IO.Directory]::EnumerateFileSystemEntries($Directory)) { + $actual.Add([IO.Path]::GetFileName($entryPath)) + } + Assert-GraphKitAuthPortableNameSet -Names @($actual) -Kind $Kind + $actualSorted = @($actual | Sort-Object) + $expectedSorted = @($ExpectedNames | Sort-Object) + if (($actualSorted -join '|') -cne ($expectedSorted -join '|')) { + throw "The GraphKit.Auth $Kind closure is not exact. Expected '$($expectedSorted -join '|')'; found '$($actualSorted -join '|')'." + } +} + +function Test-GraphKitAuthContainedPhysicalPath { + param([Parameter(Mandatory)][string] $Root, [Parameter(Mandatory)][string] $Candidate) + $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } + $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + $candidatePath = [IO.Path]::GetFullPath($Candidate) + $prefix = $rootPath + [IO.Path]::DirectorySeparatorChar + return $candidatePath.StartsWith($prefix, $comparison) +} + +function Test-GraphKitAuthSealedPermission { + param($Evidence, [Parameter(Mandatory)][bool] $Directory) + if ($Evidence.OwnerWritable) { return $false } + if ($IsWindows) { + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + return -not [string]::IsNullOrWhiteSpace([string]$Evidence.CurrentOwnerSid) -and + [string]$Evidence.OwnerSid -ceq [string]$Evidence.CurrentOwnerSid -and + [string]$Evidence.CurrentIdentitySid -ceq $currentSid -and + [bool]$Evidence.AccessRulesProtected -and + -not [bool]$Evidence.HasInheritedAccessRules -and + [bool]$Evidence.ExactOwnerOnlyAccess -and + ($Directory -or [bool]$Evidence.FileReadOnly) + } + $expected = if ($Directory) { 0x140 } else { 0x100 } # 0500 / 0400 + return [int]$Evidence.UnixMode -eq $expected -and + [uint32]$Evidence.OwnerUid -eq [uint32]$Evidence.EffectiveUid +} + +function Test-GraphKitAuthOwnerOnlyWritableDirectory { + param([Parameter(Mandatory)] $Evidence) + Initialize-GraphKitAuthStageCapture + return $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($Evidence) +} + +function ConvertTo-GraphKitAuthCanonicalJsonBytes { + param([Parameter(Mandatory)] $Value) + $json = $Value | ConvertTo-Json -Depth 12 -Compress + return [Text.UTF8Encoding]::new($false, $true).GetBytes($json) +} + +function Get-GraphKitAuthSha256 { + param([Parameter(Mandatory)][byte[]] $Bytes) + return [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($Bytes)).ToLowerInvariant() +} + +function Test-GraphKitAuthBytesEqual { + param([Parameter(Mandatory)][byte[]] $Left, [Parameter(Mandatory)][byte[]] $Right) + if ($Left.Length -ne $Right.Length) { return $false } + for ($index = 0; $index -lt $Left.Length; $index++) { + if ($Left[$index] -ne $Right[$index]) { return $false } + } + return $true +} + +function Test-GraphKitAuthSealedStage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $StagePath, + [Parameter(Mandatory)][string] $FullVersion + ) + Initialize-GraphKitAuthStageCapture + Assert-GraphKitAuthSafeSegment -Value $FullVersion -Kind 'full version' + $stage = [IO.Path]::GetFullPath($StagePath) + if (-not (Test-Path -LiteralPath $stage -PathType Container)) { throw "GraphKit.Auth stage '$stage' is missing." } + $digestName = [IO.Path]::GetFileName($stage) + if ($digestName -cnotmatch '^[0-9a-f]{64}$') { throw "GraphKit.Auth stage '$stage' is not digest-named." } + $versionRoot = Split-Path $stage -Parent + if ([IO.Path]::GetFileName($versionRoot) -cne $FullVersion) { + throw "GraphKit.Auth stage version does not match '$FullVersion'." + } + + Assert-GraphKitAuthExactDirectoryClosure -Directory $stage -ExpectedNames @('manifest.json','payload') -Kind 'stage envelope' + $stageRoot = Split-Path $versionRoot -Parent + $versionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($stageRoot, $FullVersion) + $envelope = $script:GraphKitAuthStageCaptureType::InspectDirectory($versionRoot, $digestName) + $manifestEvidence = $script:GraphKitAuthStageCaptureType::InspectFile($stage, 'manifest.json') + if ($manifestEvidence.LinkCount -ne 1) { throw 'The GraphKit.Auth manifest is not link-count one.' } + if ($manifestEvidence.Sha256 -cne $digestName) { throw 'The GraphKit.Auth manifest digest does not match its stage path.' } + if (-not (Test-GraphKitAuthSealedPermission $versionEvidence $true) -or + -not (Test-GraphKitAuthSealedPermission $envelope $true) -or + -not (Test-GraphKitAuthSealedPermission $manifestEvidence $false)) { + throw 'The GraphKit.Auth version, envelope, or manifest is writable or has the wrong permission policy.' + } + + $manifestBytes = $script:GraphKitAuthStageCaptureType::ReadFile($stage, 'manifest.json') + try { $manifest = [Text.UTF8Encoding]::new($false, $true).GetString($manifestBytes) | ConvertFrom-Json -Depth 12 } + catch { throw "The GraphKit.Auth canonical manifest is invalid JSON: $($_.Exception.Message)" } + if ((Get-GraphKitAuthSha256 $manifestBytes) -cne $digestName) { throw 'The GraphKit.Auth manifest changed during validation.' } + $canonical = ConvertTo-GraphKitAuthCanonicalJsonBytes $manifest + if (-not (Test-GraphKitAuthBytesEqual $manifestBytes $canonical)) { + throw 'The GraphKit.Auth manifest is not canonical UTF-8 JSON.' + } + if (($manifest.PSObject.Properties.Name -join '|') -cne 'schemaVersion|fullVersion|permissions|directories|files|manifest' -or + [int]$manifest.schemaVersion -ne 1 -or [string]$manifest.fullVersion -cne $FullVersion) { + throw 'The GraphKit.Auth manifest schema or version is invalid.' + } + if (($manifest.permissions.PSObject.Properties.Name -join '|') -cne 'file|directory' -or + [string]$manifest.permissions.file -cne $(if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0400' }) -or + [string]$manifest.permissions.directory -cne $(if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0500' })) { + throw 'The GraphKit.Auth manifest permission policy is invalid.' + } + if (($manifest.directories.PSObject.Properties.Name -join '|') -cne 'envelope|payload' -or + ($manifest.directories.envelope.PSObject.Properties.Name -join '|') -cne 'nativeIdentity' -or + ($manifest.directories.payload.PSObject.Properties.Name -join '|') -cne 'nativeIdentity') { + throw 'The GraphKit.Auth directory evidence schema is invalid.' + } + if ([string]$manifest.directories.envelope.nativeIdentity -cne [string]$envelope.NativeIdentity) { + throw 'The GraphKit.Auth envelope native identity changed.' + } + + $payloadEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($stage, 'payload') + if ([string]$manifest.directories.payload.nativeIdentity -cne [string]$payloadEvidence.NativeIdentity -or + -not (Test-GraphKitAuthSealedPermission $payloadEvidence $true) -or + -not (Test-GraphKitAuthContainedPhysicalPath $envelope.PhysicalPath $payloadEvidence.PhysicalPath)) { + throw 'The GraphKit.Auth payload directory identity, containment, or permission policy is invalid.' + } + Assert-GraphKitAuthExactDirectoryClosure -Directory (Join-Path $stage 'payload') -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'payload' + if (@($manifest.files).Count -ne $script:GraphKitAuthPayloadFiles.Count) { throw 'The GraphKit.Auth manifest file count is invalid.' } + $expectedManifestPaths = @($script:GraphKitAuthPayloadFiles | ForEach-Object { "payload/$_" }) + $manifestPaths = @($manifest.files | ForEach-Object { [string]$_.path }) + if (($manifestPaths -join '|') -cne ($expectedManifestPaths -join '|')) { throw 'The GraphKit.Auth manifest file order or closure is invalid.' } + foreach ($record in @($manifest.files)) { + if (($record.PSObject.Properties.Name -join '|') -cne 'path|length|sha256|nativeIdentity|linkCount') { + throw "The GraphKit.Auth file evidence schema is invalid for '$($record.path)'." + } + $relative = [string]$record.path + if ($relative.IndexOf('\') -ge 0 -or -not $relative.IsNormalized([Text.NormalizationForm]::FormC)) { + throw "The GraphKit.Auth manifest path '$relative' is unsafe." + } + $fileEvidence = $script:GraphKitAuthStageCaptureType::InspectFile($stage, $relative) + if ($fileEvidence.LinkCount -ne 1 -or [long]$record.linkCount -ne 1 -or + [long]$record.length -ne $fileEvidence.Length -or [string]$record.sha256 -cne [string]$fileEvidence.Sha256 -or + [string]$record.nativeIdentity -cne [string]$fileEvidence.NativeIdentity -or + -not (Test-GraphKitAuthSealedPermission $fileEvidence $false) -or + -not (Test-GraphKitAuthContainedPhysicalPath $payloadEvidence.PhysicalPath $fileEvidence.PhysicalPath)) { + throw "The GraphKit.Auth payload evidence failed for '$relative'." + } + } + if (($manifest.manifest.PSObject.Properties.Name -join '|') -cne 'linkCount' -or [long]$manifest.manifest.linkCount -ne 1) { + throw 'The GraphKit.Auth manifest policy record is invalid.' + } + return [pscustomobject]@{ + StagePath = $stage + PayloadPath = Join-Path $stage 'payload' + ManifestPath = Join-Path $stage 'manifest.json' + ManifestSha256 = $digestName + FullVersion = $FullVersion + Manifest = $manifest + } +} + +function New-GraphKitAuthSealedStage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $OutputRoot, + [Parameter(Mandatory)][string] $FullVersion, + [Parameter(Mandatory)][string] $PayloadSourceRoot, + [scriptblock] $BeforeVersionInstall, + [scriptblock] $AfterOwnedDirectoryCreate, + [scriptblock] $AfterVersionDestinationCheck + ) + Initialize-GraphKitAuthStageCapture + Assert-GraphKitAuthSafeSegment -Value $FullVersion -Kind 'full version' + $source = [IO.Path]::GetFullPath($PayloadSourceRoot) + Assert-GraphKitAuthExactDirectoryClosure -Directory $source -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'capture source' + $authRoot = Get-GraphKitAuthOutputRoot $OutputRoot + $authParent = Split-Path $authRoot -Parent + if (-not (Test-Path -LiteralPath $authParent -PathType Container)) { + $null = [IO.Directory]::CreateDirectory($authParent) + } + $authParentParent = Split-Path $authParent -Parent + $authParentName = [IO.Path]::GetFileName($authParent) + $authParentEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $authParentParent, $authParentName) + $captureRoot = Join-Path $authRoot 'capture' + $stageRoot = Join-Path $authRoot 'stage' + $versionRoot = Join-Path $stageRoot $FullVersion + $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authParent ` + -ParentEvidence $authParentEvidence -ChildName ([IO.Path]::GetFileName($authRoot)) ` + -Kind 'auth root' -AfterChildInspection $AfterOwnedDirectoryCreate ` + -PreserveCreatedOnFailure + $captureRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'capture' -Kind 'capture root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate -PreserveCreatedOnFailure + $stageRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'stage' -Kind 'stage root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate -PreserveCreatedOnFailure + $runId = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)).ToLowerInvariant() + $capture = Join-Path $captureRoot $runId + $payload = Join-Path $capture 'payload' + $installName = ".install-$runId" + $installRoot = Join-Path $stageRoot $installName + $temporaryVersionRoot = Join-Path $installRoot $FullVersion + $captureEvidence = $null + $payloadEvidence = $null + $installEvidence = $null + $temporaryVersionEvidence = $null + $candidateStagePath = $null + $manifestWrite = $null + $capturedFileEvidence = [ordered]@{} + try { + $captureEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $captureRoot ` + -ParentEvidence $captureRootEvidence -ChildName $runId -Kind 'capture envelope' ` + -AfterChildInspection $AfterOwnedDirectoryCreate + $payloadEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $capture ` + -ParentEvidence $captureEvidence -ChildName 'payload' -Kind 'capture payload' ` + -AfterChildInspection $AfterOwnedDirectoryCreate + $records = [Collections.Generic.List[object]]::new() + foreach ($name in $script:GraphKitAuthPayloadFiles) { + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, $name, $payload, $name, $true) + $capturedFileEvidence[$name] = $copy.Destination + if ($copy.Source.LinkCount -ne 1 -or $copy.Destination.LinkCount -ne 1) { + throw "GraphKit.Auth capture source or destination '$name' is not link-count one." + } + $records.Add([ordered]@{ + path = "payload/$name" + length = [long]$copy.Destination.Length + sha256 = [string]$copy.Destination.Sha256 + nativeIdentity = [string]$copy.Destination.NativeIdentity + linkCount = [long]1 + }) + } + Assert-GraphKitAuthExactDirectoryClosure -Directory $payload -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'new capture payload' + $envelopeEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($captureRoot, $runId) + $payloadEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($capture, 'payload') + $manifest = [ordered]@{ + schemaVersion = 1 + fullVersion = $FullVersion + permissions = [ordered]@{ + file = if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0400' } + directory = if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0500' } + } + directories = [ordered]@{ + envelope = [ordered]@{ nativeIdentity = [string]$envelopeEvidence.NativeIdentity } + payload = [ordered]@{ nativeIdentity = [string]$payloadEvidence.NativeIdentity } + } + files = @($records) + manifest = [ordered]@{ linkCount = [long]1 } + } + $manifestBytes = ConvertTo-GraphKitAuthCanonicalJsonBytes $manifest + $manifestWrite = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $capture, 'manifest.json', $manifestBytes, $true) + $manifestPath = Join-Path $capture 'manifest.json' + foreach ($name in $script:GraphKitAuthPayloadFiles) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Join-Path $payload $name), $false, $false) + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($manifestPath, $false, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($payload, $true, $false) + $installEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $stageRoot ` + -ParentEvidence $stageRootEvidence -ChildName $installName -Kind 'temporary install root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate + $temporaryVersionEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $installRoot ` + -ParentEvidence $installEvidence -ChildName $FullVersion -Kind 'temporary version root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate + $digest = Get-GraphKitAuthSha256 $manifestBytes + $candidateStagePath = Join-Path $temporaryVersionRoot $digest + [IO.Directory]::Move($capture, $candidateStagePath) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($candidateStagePath, $true, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersionRoot, $true, $false) + $null = Test-GraphKitAuthSealedStage -StagePath $candidateStagePath -FullVersion $FullVersion + if ($null -ne $BeforeVersionInstall) { + & $BeforeVersionInstall $temporaryVersionRoot + } + $preMoveVersionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $installRoot, $FullVersion) + if ([string]$preMoveVersionEvidence.NativeIdentity -cne [string]$temporaryVersionEvidence.NativeIdentity -or + [string]$preMoveVersionEvidence.PhysicalPath -cne [string]$temporaryVersionEvidence.PhysicalPath) { + throw 'The GraphKit.Auth temporary version wrapper changed before atomic installation.' + } + $versionEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $stageRoot ` + -ChildName $FullVersion -Kind 'stage version' + if ($versionEntry.Exists) { + throw "The GraphKit.Auth stage version '$FullVersion' already exists." + } + if ($null -ne $AfterVersionDestinationCheck) { + & $AfterVersionDestinationCheck $temporaryVersionRoot $versionRoot + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersionRoot, $true, $true) + $movedVersion = $false + try { + $script:GraphKitAuthStageCaptureType::MoveDirectoryCreateNew( + $temporaryVersionRoot, $versionRoot) + $movedVersion = $true + } + finally { + $moveParent = if ($movedVersion) { $stageRoot } else { $installRoot } + $movePath = if ($movedVersion) { $versionRoot } else { $temporaryVersionRoot } + $currentVersionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $moveParent, $FullVersion) + if ([string]$currentVersionEvidence.NativeIdentity -cne [string]$temporaryVersionEvidence.NativeIdentity) { + throw 'The GraphKit.Auth version wrapper changed during atomic installation; resealing was refused.' + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($movePath, $true, $false) + } + $finalVersionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $stageRoot, $FullVersion) + if ([string]$finalVersionEvidence.NativeIdentity -cne [string]$temporaryVersionEvidence.NativeIdentity -or + -not (Test-GraphKitAuthContainedPhysicalPath $stageRootEvidence.PhysicalPath $finalVersionEvidence.PhysicalPath)) { + throw 'The GraphKit.Auth atomic version install did not preserve its native identity and containment.' + } + $currentInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory($stageRoot, $installName) + if ([string]$currentInstall.NativeIdentity -cne [string]$installEvidence.NativeIdentity -or + [string]$currentInstall.PhysicalPath -cne [string]$installEvidence.PhysicalPath -or + @([IO.Directory]::EnumerateFileSystemEntries($installRoot)).Count -ne 0) { + throw 'The GraphKit.Auth successful install left an ambiguous temporary wrapper.' + } + Remove-Item -LiteralPath $installRoot -Force -ErrorAction Stop + $finalPath = Join-Path $versionRoot $digest + $verified = Test-GraphKitAuthSealedStage -StagePath $finalPath -FullVersion $FullVersion + $verified | Add-Member -MemberType NoteProperty -Name ManifestInitialEvidence ` + -Value $manifestWrite.DestinationInitial -Force + return $verified + } + catch { + $primary = $_ + $cleanupFailures = [Collections.Generic.List[string]]::new() + if ($null -ne $captureEvidence) { + try { + $captureEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $captureRoot ` + -ChildName $runId -Kind 'capture envelope cleanup' + if ($captureEntry.Exists) { + if ($null -ne $payloadEvidence) { + Remove-GraphKitAuthVerifiedCaptureCandidate -CaptureRoot $captureRoot ` + -CaptureName $runId -CaptureEvidence $captureEvidence ` + -PayloadEvidence $payloadEvidence -CapturedFileEvidence $capturedFileEvidence ` + -ManifestWrite $manifestWrite + } + else { + Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $captureRoot ` + -ParentEvidence $captureRootEvidence -ChildName $runId ` + -ChildEvidence $captureEvidence -Kind 'empty capture envelope cleanup' + } + } + } + catch { $cleanupFailures.Add($_.Exception.Message) } + } + if ($null -ne $candidateStagePath -and (Test-Path -LiteralPath $candidateStagePath -PathType Container)) { + try { + Remove-GraphKitAuthVerifiedInstallCandidate -StageRoot $stageRoot ` + -InstallName $installName -InstallEvidence $installEvidence ` + -FullVersion $FullVersion -TemporaryVersionEvidence $temporaryVersionEvidence ` + -CandidateStagePath $candidateStagePath + } + catch { $cleanupFailures.Add($_.Exception.Message) } + } + elseif ($null -ne $installEvidence) { + try { + $installEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $stageRoot ` + -ChildName $installName -Kind 'temporary install cleanup' + if ($installEntry.Exists) { + $temporaryVersionEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $installRoot ` + -ChildName $FullVersion -Kind 'temporary version cleanup' + if ($null -ne $temporaryVersionEvidence -and $temporaryVersionEntry.Exists) { + Remove-GraphKitAuthVerifiedEmptyInstallCandidate -StageRoot $stageRoot ` + -InstallName $installName -InstallEvidence $installEvidence ` + -FullVersion $FullVersion -TemporaryVersionEvidence $temporaryVersionEvidence + } + elseif (-not $temporaryVersionEntry.Exists) { + Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $stageRoot ` + -ParentEvidence $stageRootEvidence -ChildName $installName ` + -ChildEvidence $installEvidence -Kind 'empty temporary install cleanup' + } + else { + throw 'The GraphKit.Auth temporary version has no ownership evidence; ambiguous cleanup was refused.' + } + } + } + catch { $cleanupFailures.Add($_.Exception.Message) } + } + if ($cleanupFailures.Count -ne 0) { + throw "GraphKit.Auth stage creation failed and ambiguous cleanup was refused: $($cleanupFailures -join ' | ') Original failure: $($primary.Exception.Message)" + } + if ($primary.Exception.Message -match 'portable alias|stage version .* already exists\.$') { + throw $primary + } + $finalMatches = @([IO.Directory]::EnumerateFileSystemEntries($stageRoot) | Where-Object { + [IO.Path]::GetFileName($_) -ceq $FullVersion + }) + if ($finalMatches.Count -eq 1) { + throw "GraphKit.Auth stage version '$FullVersion' already exists or won the atomic install: $($primary.Exception.Message)" + } + throw $primary + } +} + +function Invoke-GraphKitAuthPrepareClean { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $OutputRoot) + Initialize-GraphKitAuthStageCapture + $authRoot = Get-GraphKitAuthOutputRoot $OutputRoot + $authParent = Split-Path $authRoot -Parent + if (-not (Test-Path -LiteralPath $authParent -PathType Container)) { return @() } + $authEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authParent ` + -ChildName ([IO.Path]::GetFileName($authRoot)) -Kind 'Prepare auth root' + if (-not $authEntry.Exists) { return @() } + $authParentParent = Split-Path $authParent -Parent + $authParentName = [IO.Path]::GetFileName($authParent) + $authParentEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $authParentParent, $authParentName) + $authEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $authParent, [IO.Path]::GetFileName($authRoot)) + if (-not (Test-GraphKitAuthContainedPhysicalPath $authParentEvidence.PhysicalPath $authEvidence.PhysicalPath)) { + throw 'The GraphKit.Auth Prepare auth root is not physically contained.' + } + if (-not (Test-GraphKitAuthOwnerOnlyWritableDirectory $authEvidence)) { + throw 'The GraphKit.Auth Prepare auth root is not exact current-owner-only writable.' + } + $captureEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authRoot ` + -ChildName 'capture' -Kind 'Prepare capture root' + if (-not $captureEntry.Exists) { + Assert-GraphKitAuthExactDirectoryClosure -Directory $authRoot ` + -ExpectedNames @() -Kind 'Prepare partial auth root, which must be empty' + return @() + } + $captureEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($authRoot, 'capture') + if (-not (Test-GraphKitAuthContainedPhysicalPath $authEvidence.PhysicalPath $captureEvidence.PhysicalPath)) { + throw 'The GraphKit.Auth Prepare capture root is not physically contained.' + } + if (-not (Test-GraphKitAuthOwnerOnlyWritableDirectory $captureEvidence)) { + throw 'The GraphKit.Auth Prepare capture root is not exact current-owner-only writable.' + } + Assert-GraphKitAuthExactDirectoryClosure -Directory $captureEntry.Path ` + -ExpectedNames @() -Kind 'Prepare capture root, which must be empty' + $stageEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authRoot ` + -ChildName 'stage' -Kind 'Prepare stage root' + if (-not $stageEntry.Exists) { + Assert-GraphKitAuthExactDirectoryClosure -Directory $authRoot ` + -ExpectedNames @('capture') -Kind 'Prepare partial auth root' + return @() + } + Assert-GraphKitAuthExactDirectoryClosure -Directory $authRoot ` + -ExpectedNames @('capture','stage') -Kind 'Prepare authority root' + $stageRoot = Join-Path $authRoot 'stage' + $stageRootEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($authRoot, 'stage') + if (-not (Test-GraphKitAuthContainedPhysicalPath $authEvidence.PhysicalPath $stageRootEvidence.PhysicalPath)) { + throw 'The GraphKit.Auth Prepare stage root is not physically contained.' + } + if (-not (Test-GraphKitAuthOwnerOnlyWritableDirectory $stageRootEvidence)) { + throw 'The GraphKit.Auth Prepare stage root is not exact current-owner-only writable.' + } + $verified = [Collections.Generic.List[object]]::new() + $versionPaths = @([IO.Directory]::EnumerateFileSystemEntries($stageRoot)) + $versionNames = @($versionPaths | ForEach-Object { [IO.Path]::GetFileName($_) }) + Assert-GraphKitAuthPortableNameSet -Names $versionNames -Kind 'stage version namespace' + foreach ($versionPath in $versionPaths) { + $version = [IO.Path]::GetFileName($versionPath) + $versionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($stageRoot, $version) + if (-not (Test-GraphKitAuthContainedPhysicalPath $stageRootEvidence.PhysicalPath $versionEvidence.PhysicalPath) -or + -not (Test-GraphKitAuthSealedPermission $versionEvidence $true)) { + throw "GraphKit.Auth prior stage version '$version' is linked, escaped, or writable." + } + $digests = @([IO.Directory]::EnumerateFileSystemEntries($versionPath)) + if ($digests.Count -ne 1) { throw "GraphKit.Auth prior stage version '$version' is not an exact envelope container." } + $verified.Add((Test-GraphKitAuthSealedStage -StagePath $digests[0] -FullVersion $version)) + } + foreach ($stage in $verified) { + foreach ($file in @($stage.Manifest.files)) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Join-Path $stage.StagePath ([string]$file.path)), $false, $true) + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stage.ManifestPath, $false, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stage.PayloadPath, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stage.StagePath, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Split-Path $stage.StagePath -Parent), $true, $true) + } + return @($verified) +} + +function New-GraphKitAuthTaskQuarantineRoot { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $OutputRoot) + Initialize-GraphKitAuthStageCapture + $output = [IO.Path]::GetFullPath($OutputRoot) + if (-not (Test-Path -LiteralPath $output -PathType Container)) { + $null = [IO.Directory]::CreateDirectory($output) + } + $outputEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($output) + $quarantineName = 'GraphKit.Auth.quarantine-' + [guid]::NewGuid().ToString('N') + $quarantine = Join-Path $output $quarantineName + $quarantineEvidence = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $output, $quarantineName) + if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess( + $quarantineEvidence) -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $outputEvidence.PhysicalPath $quarantineEvidence.PhysicalPath)) { + throw 'The GraphKit.Auth generated-root quarantine was not created with exact owner-only access.' + } + return [pscustomobject]@{ + PSTypeName = 'GraphKit.Auth.TaskQuarantineRoot' + Name = $quarantineName + Path = $quarantine + Evidence = $quarantineEvidence + OutputPath = $output + OutputEvidence = $outputEvidence + } +} + +function Invoke-GraphKitAuthLiteralQuarantine { + param([Parameter(Mandatory)][string] $RepositoryRoot) + # Directory.Move is the identity-preserving quarantine primitive. Keep its + # destination beneath the repository output tree so source and destination + # remain on the same volume instead of depending on the OS temp volume. + $quarantineRoot = New-GraphKitAuthTaskQuarantineRoot ` + -OutputRoot (Join-Path $RepositoryRoot 'output') + $quarantine = $quarantineRoot.Path + $relativeRoots = @( + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin' + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/obj' + 'src/GraphKit.Auth/GraphKit.Auth/bin' + 'src/GraphKit.Auth/GraphKit.Auth/obj' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/obj' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/TestResults' + ) + foreach ($relative in $relativeRoots) { + $source = Join-Path $RepositoryRoot $relative + if (Test-Path -LiteralPath $source) { + $destination = Join-Path $quarantine ($relative.Replace('/', '__')) + [IO.Directory]::Move([IO.Path]::GetFullPath($source), $destination) + } + } + return $quarantine +} + +function Move-GraphKitAuthBuildWorkToQuarantine { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $BuildWork, + [Parameter(Mandatory)][string] $QuarantineRoot, + [scriptblock] $BeforeMove + ) + Initialize-GraphKitAuthStageCapture + foreach ($property in @( + 'Name','Path','Evidence','CapturePath','CaptureEvidence', + 'AuthPath','AuthEvidence','OutputPath','OutputEvidence' + )) { + if ($null -eq $BuildWork.PSObject.Properties[$property]) { + throw "The GraphKit.Auth build workspace record is missing '$property'." + } + } + $name = [string]$BuildWork.Name + if ($name -cnotmatch '^\.build-[0-9a-f]{48}$') { + throw "The GraphKit.Auth build workspace name '$name' is not exact." + } + $captureRoot = [IO.Path]::GetFullPath([string]$BuildWork.CapturePath) + $source = [IO.Path]::GetFullPath([string]$BuildWork.Path) + if ($source -cne [IO.Path]::GetFullPath((Join-Path $captureRoot $name))) { + throw 'The GraphKit.Auth build workspace path is not the exact captured child.' + } + $authRoot = [IO.Path]::GetFullPath([string]$BuildWork.AuthPath) + $outputRoot = [IO.Path]::GetFullPath([string]$BuildWork.OutputPath) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { [StringComparison]::Ordinal } + if (-not $captureRoot.Equals((Join-Path $authRoot 'capture'), $comparison) -or + -not $authRoot.Equals((Join-Path $outputRoot 'GraphKit.Auth'), $comparison)) { + throw 'The GraphKit.Auth build workspace parent lineage is not exact.' + } + $quarantine = [IO.Path]::GetFullPath($QuarantineRoot) + $quarantineParent = Split-Path $quarantine -Parent + $quarantineName = [IO.Path]::GetFileName($quarantine) + if ($quarantineName -cnotmatch '^GraphKit\.Auth\.quarantine-[0-9a-f]{32}$') { + throw 'The GraphKit.Auth build workspace quarantine is not one task-specific sibling.' + } + if (-not $quarantineParent.Equals($outputRoot, $comparison)) { + throw 'The GraphKit.Auth build workspace quarantine is not beneath the exact captured output root.' + } + $currentOutput = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($outputRoot) + if ([string]$currentOutput.NativeIdentity -cne ` + [string]$BuildWork.OutputEvidence.NativeIdentity -or + [string]$currentOutput.PhysicalPath -cne ` + [string]$BuildWork.OutputEvidence.PhysicalPath) { + throw 'The GraphKit.Auth build workspace output parent changed identity; ambiguous cleanup was refused.' + } + $currentAuth = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $outputRoot, 'GraphKit.Auth') + if ([string]$currentAuth.NativeIdentity -cne ` + [string]$BuildWork.AuthEvidence.NativeIdentity -or + [string]$currentAuth.PhysicalPath -cne ` + [string]$BuildWork.AuthEvidence.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $currentOutput.PhysicalPath $currentAuth.PhysicalPath)) { + throw 'The GraphKit.Auth build workspace authority parent changed identity; ambiguous cleanup was refused.' + } + $quarantineEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $quarantineParent, $quarantineName) + if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess( + $quarantineEvidence) -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $currentOutput.PhysicalPath $quarantineEvidence.PhysicalPath)) { + throw 'The GraphKit.Auth build workspace quarantine is not exact current-owner-only writable.' + } + if ($null -ne $BeforeMove) { & $BeforeMove $source $quarantine } + $currentOutput = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($outputRoot) + if ([string]$currentOutput.NativeIdentity -cne ` + [string]$BuildWork.OutputEvidence.NativeIdentity -or + [string]$currentOutput.PhysicalPath -cne ` + [string]$BuildWork.OutputEvidence.PhysicalPath) { + throw 'The GraphKit.Auth build workspace output parent changed identity before the move; ambiguous cleanup was refused.' + } + $currentAuth = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $outputRoot, 'GraphKit.Auth') + if ([string]$currentAuth.NativeIdentity -cne ` + [string]$BuildWork.AuthEvidence.NativeIdentity -or + [string]$currentAuth.PhysicalPath -cne ` + [string]$BuildWork.AuthEvidence.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $currentOutput.PhysicalPath $currentAuth.PhysicalPath)) { + throw 'The GraphKit.Auth build workspace authority parent changed identity before the move; ambiguous cleanup was refused.' + } + $currentCapture = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($captureRoot) + if ([string]$currentCapture.NativeIdentity -cne ` + [string]$BuildWork.CaptureEvidence.NativeIdentity -or + [string]$currentCapture.PhysicalPath -cne ` + [string]$BuildWork.CaptureEvidence.PhysicalPath) { + throw 'The GraphKit.Auth build workspace capture parent changed identity; ambiguous cleanup was refused.' + } + $current = $script:GraphKitAuthStageCaptureType::InspectDirectory($captureRoot, $name) + if ([string]$current.NativeIdentity -cne [string]$BuildWork.Evidence.NativeIdentity -or + [string]$current.PhysicalPath -cne [string]$BuildWork.Evidence.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $currentCapture.PhysicalPath $current.PhysicalPath) -or + -not (Test-GraphKitAuthOwnerOnlyWritableDirectory $current)) { + throw 'The GraphKit.Auth build workspace changed identity before quarantine; ambiguous cleanup was refused.' + } + $currentQuarantine = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $quarantineParent, $quarantineName) + if ([string]$currentQuarantine.NativeIdentity -cne ` + [string]$quarantineEvidence.NativeIdentity -or + [string]$currentQuarantine.PhysicalPath -cne ` + [string]$quarantineEvidence.PhysicalPath -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $currentOutput.PhysicalPath $currentQuarantine.PhysicalPath)) { + throw 'The GraphKit.Auth build workspace quarantine changed identity before the move; ambiguous cleanup was refused.' + } + $destinationEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $quarantine ` + -ChildName $name -Kind 'build workspace quarantine destination' + if ($destinationEntry.Exists) { + throw "The GraphKit.Auth build workspace quarantine destination '$name' already exists; no move was attempted." + } + $destination = Join-Path $quarantine $name + $script:GraphKitAuthStageCaptureType::MoveDirectoryCreateNew($source, $destination) + $moved = $script:GraphKitAuthStageCaptureType::InspectDirectory($quarantine, $name) + if ([string]$moved.NativeIdentity -cne [string]$BuildWork.Evidence.NativeIdentity -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $currentQuarantine.PhysicalPath $moved.PhysicalPath) -or + -not (Test-GraphKitAuthOwnerOnlyWritableDirectory $moved)) { + throw 'The GraphKit.Auth quarantined build workspace changed identity; ambiguous cleanup was refused.' + } + $sourceEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $captureRoot ` + -ChildName $name -Kind 'quarantined build workspace source' + if ($sourceEntry.Exists) { + throw 'The GraphKit.Auth build workspace source was recreated during quarantine; ambiguous cleanup was refused.' + } + return [pscustomobject]@{ + PSTypeName = 'GraphKit.Auth.QuarantinedBuildWorkRoot' + Name = $name + Path = $destination + Evidence = $moved + } +} + +function Restore-GraphKitAuthGitConfigEnvironment { + [CmdletBinding()] + param([Parameter(Mandatory)] $Environment) + foreach ($entry in @(Get-ChildItem Env:)) { + if ($entry.Name.StartsWith('GIT_CONFIG_', [StringComparison]::Ordinal)) { + Remove-Item -LiteralPath "Env:$($entry.Name)" -ErrorAction Stop + } + } + foreach ($entry in $Environment.GetEnumerator()) { + [Environment]::SetEnvironmentVariable([string]$entry.Key, [string]$entry.Value, 'Process') + } +} + +function Enable-GraphKitAuthAbiTestGitExcludes { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string[]] $Patterns, + [scriptblock] $AfterFirstEnvironmentWrite + ) + if ($null -ne $script:GraphKitAuthAbiGitConfigState) { + throw 'The process already has an active GraphKit.Auth ABI-test Git exclusion scope.' + } + if ($Patterns.Count -ne 5 -or @($Patterns | Select-Object -Unique).Count -ne 5) { + throw 'The GraphKit.Auth ABI-test exclusion inventory must contain exactly five unique entries.' + } + foreach ($pattern in $Patterns) { + if ([string]::IsNullOrWhiteSpace($pattern) -or -not $pattern.StartsWith('/', [StringComparison]::Ordinal) -or + $pattern.EndsWith('/', [StringComparison]::Ordinal) -or $pattern.IndexOfAny([char[]]'*?[') -ge 0 -or + -not $pattern.IsNormalized([Text.NormalizationForm]::FormC)) { + throw "The GraphKit.Auth ABI-test exclusion '$pattern' is not one root-anchored literal file pattern." + } + } + + $environment = [ordered]@{} + foreach ($entry in Get-ChildItem Env:) { + if ($entry.Name.StartsWith('GIT_CONFIG_', [StringComparison]::Ordinal)) { + $environment[$entry.Name] = [string]$entry.Value + } + } + $priorCountText = [Environment]::GetEnvironmentVariable('GIT_CONFIG_COUNT', 'Process') + $priorCount = 0 + if ($null -ne $priorCountText -and + (-not [int]::TryParse($priorCountText, [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, [ref]$priorCount) -or $priorCount -lt 0)) { + throw "The inherited GIT_CONFIG_COUNT '$priorCountText' is invalid." + } + $keyName = "GIT_CONFIG_KEY_$priorCount" + $valueName = "GIT_CONFIG_VALUE_$priorCount" + if ($null -ne [Environment]::GetEnvironmentVariable($keyName, 'Process') -or + $null -ne [Environment]::GetEnvironmentVariable($valueName, 'Process')) { + throw "The inherited process Git configuration collides at index $priorCount." + } + + $excludePath = Join-Path ([IO.Path]::GetTempPath()) ('graphkit-auth-abi-excludes-' + [guid]::NewGuid().ToString('N')) + $bytes = [Text.UTF8Encoding]::new($false, $true).GetBytes(($Patterns -join "`n") + "`n") + $stream = [IO.FileStream]::new($excludePath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } + finally { $stream.Dispose() } + + try { + [Environment]::SetEnvironmentVariable($keyName, 'core.excludesFile', 'Process') + if ($null -ne $AfterFirstEnvironmentWrite) { & $AfterFirstEnvironmentWrite } + [Environment]::SetEnvironmentVariable($valueName, $excludePath, 'Process') + [Environment]::SetEnvironmentVariable('GIT_CONFIG_COUNT', ($priorCount + 1).ToString([Globalization.CultureInfo]::InvariantCulture), 'Process') + $script:GraphKitAuthAbiGitConfigState = [pscustomobject]@{ + Environment = $environment + ExcludePath = $excludePath + } + } + catch { + Restore-GraphKitAuthGitConfigEnvironment -Environment $environment + if (Test-Path -LiteralPath $excludePath -PathType Leaf) { [IO.File]::Delete($excludePath) } + throw + } +} + +function Disable-GraphKitAuthAbiTestGitExcludes { + [CmdletBinding()] + param() + $state = $script:GraphKitAuthAbiGitConfigState + if ($null -eq $state) { return } + try { + Restore-GraphKitAuthGitConfigEnvironment -Environment $state.Environment + } + finally { + if (Test-Path -LiteralPath $state.ExcludePath -PathType Leaf) { [IO.File]::Delete($state.ExcludePath) } + $script:GraphKitAuthAbiGitConfigState = $null + } +} + +function Assert-GraphKitAuthAbiProjectedFileEvidence { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $RelativePath, + [Parameter(Mandatory)] $Expected + ) + Initialize-GraphKitAuthStageCapture + $repositoryRootEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath( + $RepositoryRoot) + $actual = $script:GraphKitAuthStageCaptureType::InspectFile($RepositoryRoot, $RelativePath) + if (-not $actual.IsRegularFile -or $actual.IsReparsePoint -or [long]$actual.LinkCount -ne 1 -or + -not (Test-GraphKitAuthContainedPhysicalPath ` + $repositoryRootEvidence.PhysicalPath $actual.PhysicalPath) -or + [string]$actual.PhysicalPath -cne [string]$Expected.PhysicalPath -or + [string]$actual.NativeIdentity -cne [string]$Expected.NativeIdentity -or + [string]$actual.Sha256 -cne [string]$Expected.Sha256 -or + [long]$actual.Length -ne [long]$Expected.Length -or + [long]$actual.LinkCount -ne [long]$Expected.LinkCount) { + throw "The projected GraphKit.Auth ABI-test file '$RelativePath' was replaced, linked, changed, or escaped." + } + return $actual +} + +function New-GraphKitAuthAbiTestFixture { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $RepositoryRoot, + [Parameter(Mandatory)][string] $OutputRoot + ) + Initialize-GraphKitAuthStageCapture + $sourceManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $RepositoryRoot 'source/GraphKit.psd1') + $baseVersion = [string]$sourceManifest.ModuleVersion + $builtManifestPath = Join-Path $RepositoryRoot "output/module/GraphKit/$baseVersion/GraphKit.psd1" + if (-not (Test-Path -LiteralPath $builtManifestPath -PathType Leaf)) { + throw "The GraphKit.Auth ABI test fixture has no built manifest at '$builtManifestPath'." + } + $builtManifest = Import-PowerShellDataFile -LiteralPath $builtManifestPath + $prerelease = [string]$builtManifest.PrivateData.PSData.Prerelease + $fullVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { $baseVersion } else { "$baseVersion-$prerelease" } + $versionRoot = Join-Path (Get-GraphKitAuthOutputRoot $OutputRoot) "stage/$fullVersion" + if (-not (Test-Path -LiteralPath $versionRoot -PathType Container)) { + throw "The sealed GraphKit.Auth stage for ABI testing is missing at '$versionRoot'." + } + $stageEntries = @([IO.Directory]::EnumerateFileSystemEntries($versionRoot)) + if ($stageEntries.Count -ne 1 -or -not (Test-Path -LiteralPath $stageEntries[0] -PathType Container)) { + throw 'The sealed GraphKit.Auth stage for ABI testing is not one exact digest envelope.' + } + $verified = Test-GraphKitAuthSealedStage -StagePath $stageEntries[0] -FullVersion $fullVersion + $destinations = [ordered]@{ + 'GraphKit.Auth.Contracts.dll' = 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0/GraphKit.Auth.Contracts.dll' + 'GraphKit.Auth.dll' = 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.dll' + 'GraphKit.Auth.deps.json' = 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.deps.json' + 'Microsoft.Identity.Client.dll' = 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.Identity.Client.dll' + 'Microsoft.IdentityModel.Abstractions.dll' = 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.IdentityModel.Abstractions.dll' + } + $binRoots = @( + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin' + 'src/GraphKit.Auth/GraphKit.Auth/bin' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin' + ) | ForEach-Object { Join-Path $RepositoryRoot $_ } + foreach ($binRoot in $binRoots) { + if (Test-Path -LiteralPath $binRoot) { + throw "The GraphKit.Auth ABI test fixture destination '$binRoot' already exists." + } + } + $trainScript = Join-Path $RepositoryRoot 'scripts/Get-GraphKitTrainVersion.ps1' + $baselineState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject + $statusBefore = @(& git -C $RepositoryRoot status --porcelain=v1 --untracked-files=all) + if ($LASTEXITCODE -ne 0) { throw 'Cannot capture Git status before the GraphKit.Auth ABI-test projection.' } + $patterns = @($destinations.Values | ForEach-Object { '/' + $_ }) + Enable-GraphKitAuthAbiTestGitExcludes -RepositoryRoot $RepositoryRoot -Patterns $patterns + $script:GraphKitAuthAbiFixtureState = [pscustomobject]@{ + BaselineState = $baselineState + StatusBefore = @($statusBefore) + CreatedPaths = [Collections.Generic.List[string]]::new() + CreatedDirectories = [Collections.Generic.List[string]]::new() + Completed = $false + ExpectedEvidence = [ordered]@{} + } + try { + foreach ($entry in $destinations.GetEnumerator()) { + $relativeFile = [string]$entry.Value + $destinationFile = Join-Path $RepositoryRoot $relativeFile + $destination = Split-Path $destinationFile -Parent + $missingDirectories = [Collections.Generic.Stack[string]]::new() + $candidateDirectory = [IO.Path]::GetFullPath($destination) + while (-not [IO.Directory]::Exists($candidateDirectory)) { + $missingDirectories.Push($candidateDirectory) + $parentDirectory = [IO.Directory]::GetParent($candidateDirectory) + if ($null -eq $parentDirectory) { + throw "The GraphKit.Auth ABI-test projection '$destination' has no existing ancestor." + } + $candidateDirectory = $parentDirectory.FullName + } + while ($missingDirectories.Count -gt 0) { + $createdDirectory = $missingDirectories.Pop() + $null = [IO.Directory]::CreateDirectory($createdDirectory) + $script:GraphKitAuthAbiFixtureState.CreatedDirectories.Add($createdDirectory) + } + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $verified.PayloadPath, $entry.Key, $destination, $entry.Key + ) + $script:GraphKitAuthAbiFixtureState.CreatedPaths.Add($destinationFile) + $script:GraphKitAuthAbiFixtureState.ExpectedEvidence[$destinationFile] = $copy.Destination + $manifestRecord = @($verified.Manifest.files | Where-Object { + [string]$_.path -ceq "payload/$($entry.Key)" + }) + if ($manifestRecord.Count -ne 1 -or + [string]$copy.Destination.Sha256 -cne [string]$manifestRecord[0].sha256 -or + [long]$copy.Destination.LinkCount -ne 1) { + throw "The GraphKit.Auth ABI test fixture '$($entry.Key)' does not match the sealed payload." + } + & git -C $RepositoryRoot check-ignore --quiet -- $relativeFile + if ($LASTEXITCODE -ne 0) { + throw "The exact GraphKit.Auth ABI-test projection '$relativeFile' is not excluded by its literal process scope." + } + } + + $hiddenState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject + if ([string]$hiddenState.sourceStateSha256 -cne [string]$baselineState.sourceStateSha256 -or + [string]$hiddenState.version -cne [string]$baselineState.version) { + throw 'The exact GraphKit.Auth ABI-test projections changed the process-scoped source fingerprint.' + } + $sentinelRelative = '.graphkit-auth-abi-untracked-' + [guid]::NewGuid().ToString('N') + $sentinelPath = Join-Path $RepositoryRoot $sentinelRelative + try { + $sentinelBytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32) + $sentinel = [IO.FileStream]::new($sentinelPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough) + try { $sentinel.Write($sentinelBytes, 0, $sentinelBytes.Length); $sentinel.Flush($true) } + finally { $sentinel.Dispose() } + & git -C $RepositoryRoot check-ignore --quiet -- $sentinelRelative + if ($LASTEXITCODE -eq 0) { throw 'The unrelated GraphKit.Auth ABI-test sentinel was unexpectedly excluded.' } + if ($LASTEXITCODE -ne 1) { + throw "The GraphKit.Auth ABI-test sentinel exclusion probe failed with git exit code '$LASTEXITCODE'; only exit 1 proves the sentinel is not ignored." + } + $sentinelState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject + if ([string]$sentinelState.sourceStateSha256 -ceq [string]$hiddenState.sourceStateSha256) { + throw 'An unrelated untracked source file did not change the dirty-source fingerprint.' + } + } + finally { + if (Test-Path -LiteralPath $sentinelPath -PathType Leaf) { [IO.File]::Delete($sentinelPath) } + } + $restoredHiddenState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject + if ([string]$restoredHiddenState.sourceStateSha256 -cne [string]$hiddenState.sourceStateSha256) { + throw 'The GraphKit.Auth ABI-test sentinel cleanup did not restore the projected source fingerprint.' + } + $script:GraphKitAuthAbiFixtureState.Completed = $true + } + catch { + try { Remove-GraphKitAuthAbiTestFixture -RepositoryRoot $RepositoryRoot } + catch { Write-Error -ErrorRecord $_ } + throw + } + return @($binRoots) +} + +function Remove-GraphKitAuthAbiTestFixture { + [CmdletBinding()] + param([Parameter(Mandatory)][string] $RepositoryRoot) + $state = $script:GraphKitAuthAbiFixtureState + if ($null -eq $state) { + Disable-GraphKitAuthAbiTestGitExcludes + return + } + $failures = [Collections.Generic.List[string]]::new() + $allowedFiles = @( + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0/GraphKit.Auth.Contracts.dll' + 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.dll' + 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.deps.json' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.Identity.Client.dll' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.IdentityModel.Abstractions.dll' + ) | ForEach-Object { Join-Path $RepositoryRoot $_ } + $allowedFileSet = [Collections.Generic.HashSet[string]]::new( + $(if ($IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal })) + foreach ($file in $allowedFiles) { $null = $allowedFileSet.Add([IO.Path]::GetFullPath($file)) } + $literalParents = @( + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0' + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release' + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin' + 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0' + 'src/GraphKit.Auth/GraphKit.Auth/bin/Release' + 'src/GraphKit.Auth/GraphKit.Auth/bin' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin' + ) | ForEach-Object { Join-Path $RepositoryRoot $_ } + $allowedParentSet = [Collections.Generic.HashSet[string]]::new( + $(if ($IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal })) + foreach ($parent in $literalParents) { + $null = $allowedParentSet.Add([IO.Path]::GetFullPath($parent)) + } + $createdPaths = @($state.CreatedPaths | ForEach-Object { [IO.Path]::GetFullPath([string]$_) }) + try { + foreach ($file in $createdPaths) { + if (-not $allowedFileSet.Contains($file) -or -not $state.ExpectedEvidence.Contains($file)) { + $failures.Add("unregistered or out-of-bound projected path '$file'") + continue + } + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { + $failures.Add("missing recorded projected file '$file'") + continue + } + $relativeFile = [IO.Path]::GetRelativePath($RepositoryRoot, $file).Replace('\', '/') + try { + $null = Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $RepositoryRoot ` + -RelativePath $relativeFile -Expected $state.ExpectedEvidence[$file] + } + catch { + $failures.Add($_.Exception.Message) + continue + } + [IO.File]::Delete($file) + } + $createdParents = if ($null -ne $state.PSObject.Properties['CreatedDirectories']) { + @($state.CreatedDirectories | ForEach-Object { [IO.Path]::GetFullPath([string]$_) }) + } + else { + @($literalParents | Where-Object { + $parentPrefix = [IO.Path]::GetFullPath($_).TrimEnd( + [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar + ) + [IO.Path]::DirectorySeparatorChar + @($createdPaths | Where-Object { + $_.StartsWith($parentPrefix, $(if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal })) + }).Count -ne 0 + }) + } + $createdParents = @($createdParents | Sort-Object Length -Descending -Unique) + foreach ($directory in $createdParents) { + if (-not $allowedParentSet.Contains($directory)) { + $failures.Add("unregistered or out-of-bound projected directory '$directory'") + continue + } + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { continue } + if (@([IO.Directory]::EnumerateFileSystemEntries($directory)).Count -ne 0) { + $failures.Add("non-empty projected parent '$directory'") + continue + } + [IO.Directory]::Delete($directory, $false) + } + } + finally { + Disable-GraphKitAuthAbiTestGitExcludes + $script:GraphKitAuthAbiFixtureState = $null + } + if ($null -ne $state.BaselineState) { + $trainScript = Join-Path $RepositoryRoot 'scripts/Get-GraphKitTrainVersion.ps1' + $restoredState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject + $statusAfter = @(& git -C $RepositoryRoot status --porcelain=v1 --untracked-files=all) + if ($LASTEXITCODE -ne 0) { $failures.Add('cannot capture Git status after ABI-test cleanup') } + if ([string]$restoredState.sourceStateSha256 -cne [string]$state.BaselineState.sourceStateSha256 -or + (@($statusAfter) -join "`n") -cne (@($state.StatusBefore) -join "`n")) { + $failures.Add('source fingerprint or Git status was not restored after ABI-test cleanup') + } + } + if ($failures.Count -ne 0) { + throw "GraphKit.Auth ABI-test cleanup failed: $($failures -join '; ')." + } +} + +if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyContinue)) { + task Prepare_GraphKitAuth_Clean { + Invoke-GraphKitAuthPrepareClean -OutputRoot (Join-Path $BuildRoot 'output') | Out-Null + } + + task Build_GraphKitAuth { + Initialize-GraphKitAuthStageCapture + $solution = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.sln' + $providerProject = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj' + $testProject = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj' + $runId = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)).ToLowerInvariant() + $buildWork = $null + $publishRoot = $null + $providerPublish = $null + $payloadSource = $null + $resultRoot = $null + $quarantine = $null + $buildWorkQuarantined = $false + $primaryFailure = $null + try { + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot (Join-Path $BuildRoot 'output') + $buildWork = New-GraphKitAuthBuildWorkRoot -OutputRoot (Join-Path $BuildRoot 'output') ` + -RunId $runId + $resultRoot = Join-Path $buildWork.Path 'dotnet-test' + $publishRoot = Join-Path $buildWork.Path 'publish' + $providerPublish = Join-Path $publishRoot 'provider' + $payloadSource = Join-Path $publishRoot 'payload' + $dotnetVersionOutput = @(& dotnet --version) + if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth could not query the dotnet SDK version.' } + $dotnetVersion = ([string] (@($dotnetVersionOutput | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) | Select-Object -Last 1)).Trim() + if ($dotnetVersion -cne '10.0.400') { throw 'GraphKit.Auth requires dotnet SDK 10.0.400 exactly.' } + & dotnet restore $solution --locked-mode + if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth locked restore failed.' } + & dotnet build $solution -c Release --no-restore + if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth Release build failed.' } + $null = [IO.Directory]::CreateDirectory($resultRoot) + & dotnet test $testProject -c Release --no-build --no-restore --logger "trx;LogFileName=GraphKit.Auth.trx" --results-directory $resultRoot + if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth Release tests failed.' } + $trxPath = Join-Path $resultRoot 'GraphKit.Auth.trx' + [xml]$trx = Get-Content -LiteralPath $trxPath -Raw + Assert-GraphKitAuthTestResult -Result $trx + $null = [IO.Directory]::CreateDirectory($providerPublish) + & dotnet publish $providerProject -c Release --no-build --no-restore --no-self-contained -o $providerPublish + if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth provider publish failed.' } + Assert-GraphKitAuthExactDirectoryClosure -Directory $providerPublish -ExpectedNames $script:GraphKitAuthProviderFiles -Kind 'raw provider publish' + $builtProvider = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.dll' + $builtContracts = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0/GraphKit.Auth.Contracts.dll' + $builtProviderDeps = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.deps.json' + $testOutput = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0' + $lineagePairs = @( + [pscustomobject]@{ Left=$builtProvider; Right=(Join-Path $testOutput 'GraphKit.Auth.dll'); Kind='tested provider' } + [pscustomobject]@{ Left=$builtProvider; Right=(Join-Path $providerPublish 'GraphKit.Auth.dll'); Kind='published provider' } + [pscustomobject]@{ Left=$builtContracts; Right=(Join-Path $testOutput 'GraphKit.Auth.Contracts.dll'); Kind='tested contracts' } + [pscustomobject]@{ Left=$builtProviderDeps; Right=(Join-Path $providerPublish 'GraphKit.Auth.deps.json'); Kind='published dependency graph' } + [pscustomobject]@{ Left=(Join-Path $testOutput 'Microsoft.Identity.Client.dll'); Right=(Join-Path $providerPublish 'Microsoft.Identity.Client.dll'); Kind='tested MSAL' } + [pscustomobject]@{ Left=(Join-Path $testOutput 'Microsoft.IdentityModel.Abstractions.dll'); Right=(Join-Path $providerPublish 'Microsoft.IdentityModel.Abstractions.dll'); Kind='tested IdentityModel' } + ) + foreach ($pair in $lineagePairs) { + $leftHash = (Get-FileHash -LiteralPath $pair.Left -Algorithm SHA256).Hash + $rightHash = (Get-FileHash -LiteralPath $pair.Right -Algorithm SHA256).Hash + if ($leftHash -cne $rightHash) { throw "The GraphKit.Auth $($pair.Kind) bytes do not share one Release lineage." } + } + $identities = [ordered]@{ + 'GraphKit.Auth.Contracts.dll' = 'GraphKit.Auth.Contracts, Version=1.0.0.0' + 'GraphKit.Auth.dll' = 'GraphKit.Auth, Version=1.0.0.0' + 'Microsoft.Identity.Client.dll' = 'Microsoft.Identity.Client, Version=4.82.1.0' + 'Microsoft.IdentityModel.Abstractions.dll' = 'Microsoft.IdentityModel.Abstractions, Version=8.14.0.0' + } + foreach ($pair in $identities.GetEnumerator()) { + $path = if ($pair.Key -ceq 'GraphKit.Auth.Contracts.dll') { $builtContracts } else { Join-Path $providerPublish $pair.Key } + $identity = [Reflection.AssemblyName]::GetAssemblyName($path) + $actual = "$($identity.Name), Version=$($identity.Version)" + if ($actual -cne $pair.Value) { throw "Unexpected managed identity for '$($pair.Key)': '$actual'." } + } + $contractsInspectionContext = [Runtime.Loader.AssemblyLoadContext]::new( + "GraphKit.Auth.Contracts.BuildInspection.$runId", $true) + try { + $contractsAssembly = $contractsInspectionContext.LoadFromAssemblyPath($builtContracts) + $forbiddenReferences = @($contractsAssembly.GetReferencedAssemblies() | Where-Object { + $_.Name -eq 'GraphKit.Auth' -or $_.Name -like 'Microsoft.Identity*' + }) + if ($forbiddenReferences.Count -ne 0) { + throw "GraphKit.Auth.Contracts has a forbidden runtime reference: $($forbiddenReferences.Name -join ', ')." + } + } + finally { + $contractsAssembly = $null + $contractsInspectionContext.Unload() + $contractsInspectionContext = $null + } + $null = [IO.Directory]::CreateDirectory($payloadSource) + foreach ($name in $script:GraphKitAuthProviderFiles) { + $null = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew($providerPublish, $name, $payloadSource, $name) + } + $contractsRoot = Split-Path $builtContracts -Parent + $null = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew($contractsRoot, 'GraphKit.Auth.Contracts.dll', $payloadSource, 'GraphKit.Auth.Contracts.dll') + $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot + $script:GraphKitAuthQuarantine = $quarantine + Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'." + $version = & (Join-Path $BuildRoot 'scripts/Get-GraphKitTrainVersion.ps1') -RepositoryRoot $BuildRoot -AsObject + $script:GraphKitAuthStage = New-GraphKitAuthSealedStage -OutputRoot (Join-Path $BuildRoot 'output') -FullVersion $version.version -PayloadSourceRoot $payloadSource + } + catch { + $primaryFailure = $_ + throw + } + finally { + $cleanupFailures = [Collections.Generic.List[string]]::new() + if ($null -eq $quarantine) { + try { + $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot + $script:GraphKitAuthQuarantine = $quarantine + Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'." + } + catch { + $cleanupFailures.Add( + "generated-root quarantine failed: $($_.Exception.Message)") + } + } + $buildWorkQuarantine = $quarantine + if ($null -ne $buildWork -and $null -eq $buildWorkQuarantine) { + try { + $buildWorkQuarantineRoot = New-GraphKitAuthTaskQuarantineRoot ` + -OutputRoot (Join-Path $BuildRoot 'output') + $buildWorkQuarantine = $buildWorkQuarantineRoot.Path + $script:GraphKitAuthQuarantine = $buildWorkQuarantine + Write-Host "GraphKit.Auth independent build-workspace quarantine created at '$buildWorkQuarantine'." + } + catch { + $cleanupFailures.Add( + "independent build-workspace quarantine creation failed: $($_.Exception.Message)") + } + } + if ($null -ne $buildWork -and $null -ne $buildWorkQuarantine -and + -not $buildWorkQuarantined) { + try { + $movedBuildWork = Move-GraphKitAuthBuildWorkToQuarantine ` + -BuildWork $buildWork -QuarantineRoot $buildWorkQuarantine + $buildWorkQuarantined = $true + Write-Host "GraphKit.Auth build workspace quarantined at '$($movedBuildWork.Path)'." + } + catch { + $cleanupFailures.Add( + "build-workspace quarantine failed: $($_.Exception.Message)") + } + } + if ($cleanupFailures.Count -ne 0) { + if ($null -eq $primaryFailure) { + throw "GraphKit.Auth build cleanup failed: $($cleanupFailures -join ' | ')." + } + foreach ($cleanupFailure in $cleanupFailures) { + Write-Warning ` + "GraphKit.Auth cleanup also failed; the earlier build failure remains authoritative. $cleanupFailure" ` + -WarningAction Continue + } + } + } + } + + task Copy_GraphKitAuth_Into_BuiltModule { + . Set-SamplerTaskVariable + if ($null -eq $script:GraphKitAuthStage) { throw 'The current build has no sealed GraphKit.Auth stage.' } + $verified = Test-GraphKitAuthSealedStage -StagePath $script:GraphKitAuthStage.StagePath -FullVersion $script:GraphKitAuthStage.FullVersion + $destination = Join-Path $BuiltModuleBase 'Assemblies/GraphKit.Auth' + if (Test-Path -LiteralPath $destination) { throw "GraphKit.Auth built-module destination '$destination' already exists." } + $null = [IO.Directory]::CreateDirectory($destination) + foreach ($record in @($verified.Manifest.files)) { + $name = [IO.Path]::GetFileName([string]$record.path) + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew($verified.PayloadPath, $name, $destination, $name) + if ([string]$copy.Destination.Sha256 -cne [string]$record.sha256 -or $copy.Destination.LinkCount -ne 1) { + throw "GraphKit.Auth built-module copy validation failed for '$name'." + } + } + Assert-GraphKitAuthExactDirectoryClosure -Directory $destination -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'built-module payload' + $manifestPath = Join-Path $BuiltModuleBase 'GraphKit.psd1' + $manifestText = [IO.File]::ReadAllText($manifestPath) + if ($manifestText -notmatch '(?m)^\s*RequiredAssemblies\s*=\s*@\(\)\s*$') { + throw 'The built GraphKit manifest no longer has the expected empty RequiredAssemblies literal.' + } + $updated = [regex]::Replace($manifestText, '(?m)^(\s*RequiredAssemblies\s*=\s*)@\(\)(\s*)$', "`$1@('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')`$2", 1) + [IO.File]::WriteAllText($manifestPath, $updated, [Text.UTF8Encoding]::new($false)) + $moduleManifest = Test-ModuleManifest -Path $manifestPath -ErrorAction Stop + if ((@($moduleManifest.RequiredAssemblies | ForEach-Object { $_.ToString() }) -join '|') -cne 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll') { + throw 'The built GraphKit manifest RequiredAssemblies value is not exact.' + } + } + + task Pester_Tests_With_GraphKitAuth_ABI_Fixture { + $pesterExitCode = $null + try { + $script:GraphKitAuthAbiTestFixture = New-GraphKitAuthAbiTestFixture ` + -RepositoryRoot $BuildRoot -OutputRoot (Join-Path $BuildRoot 'output') + $pwshPath = [Environment]::ProcessPath + & $pwshPath -NoLogo -NoProfile -File (Join-Path $BuildRoot 'build.ps1') ` + -Tasks Pester_Tests_Stop_On_Fail + $pesterExitCode = $LASTEXITCODE + } + finally { + Remove-GraphKitAuthAbiTestFixture -RepositoryRoot $BuildRoot + } + if ($pesterExitCode -ne 0) { + throw "The guarded GraphKit.Auth ABI Pester run failed with exit code '$pesterExitCode'." + } + } +} diff --git a/.build/ReleaseProof.tasks.ps1 b/.build/ReleaseProof.tasks.ps1 new file mode 100644 index 0000000..bc0dab0 --- /dev/null +++ b/.build/ReleaseProof.tasks.ps1 @@ -0,0 +1,20 @@ +<# + Invoke-Build integration for the canonical tested-release proof. + + Capture runs before Pester and deletes stale proof/result material. Finalize runs only + after Pester and coverage gates, rechecks the captured candidate and complete result, + then writes tested-release-proof.json. The scripts hold the behavior so the exact same + boundary is exercised by focused subprocess tests. +#> + +task Capture_Tested_Release_Proof_Candidate { + & (Join-Path $BuildRoot 'scripts/New-GraphKitTestedReleaseProof.ps1') ` + -Stage Capture ` + -RepositoryRoot $BuildRoot +} + +task Record_Tested_Release_Proof { + & (Join-Path $BuildRoot 'scripts/New-GraphKitTestedReleaseProof.ps1') ` + -Stage Finalize ` + -RepositoryRoot $BuildRoot +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a69e968 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +tests/Fixtures/GraphKitAuthParityCases.json text eol=lf diff --git a/.github/powershell-release-sha256.json b/.github/powershell-release-sha256.json new file mode 100644 index 0000000..d2adc6d --- /dev/null +++ b/.github/powershell-release-sha256.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "provenance": { + "7.4.19": { + "releaseUrl": "https://github.com/PowerShell/PowerShell/releases/tag/v7.4.19", + "hashesUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.4.19/hashes.sha256" + }, + "7.6.5": { + "releaseUrl": "https://github.com/PowerShell/PowerShell/releases/tag/v7.6.5", + "hashesUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.5/hashes.sha256" + } + }, + "sha256": { + "7.4.19/PowerShell-7.4.19-win-arm64.zip": "ac3a0249c0cd9f5b55f198f681485099ea73f45838dfd676457571a94d793463", + "7.4.19/PowerShell-7.4.19-win-x64.zip": "cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9", + "7.4.19/powershell-7.4.19-linux-arm64.tar.gz": "2b11aafacf574222abaf691a0b3b2d463e617d17fe337343c2fb93ea871a4691", + "7.4.19/powershell-7.4.19-linux-x64.tar.gz": "1b023e097b0e0546ad9566f7a2126cbe0eb8455fa7b0c5de558e317b8ddc16c8", + "7.4.19/powershell-7.4.19-osx-arm64.tar.gz": "fb9d6656d0c78c6d3f6e8d08ff15e5e0d867f886bf4ebecfde6484d2fa06c042", + "7.4.19/powershell-7.4.19-osx-x64.tar.gz": "bb67378d9b9d469d0c3863aa8a5576a38ad8eaa0fd7aae2c4819e7caf06cb79c", + "7.6.5/PowerShell-7.6.5-win-arm64.zip": "20514a755d16428dc4355c85e0883c859531e71cc3e122670aa1fccdbf96ba7e", + "7.6.5/PowerShell-7.6.5-win-x64.zip": "32eb8f6cdce08f86e987d625a2733e54ac3e289ae7e1621b14c0b5bcec2434ea", + "7.6.5/powershell-7.6.5-linux-arm64.tar.gz": "ed4084f215d8bce2edd23aa7cb1f1e7b0818e41363a635a22065d2701b6141df", + "7.6.5/powershell-7.6.5-linux-x64.tar.gz": "b34ab3b19acac1d3d4d0d3cfdb02acf62f457b0b6a962ff008132033f7566844", + "7.6.5/powershell-7.6.5-osx-arm64.tar.gz": "8196d4b4e7c21b7f6df9d45687bb4e42dc8335f330b580d9eb15f3ef5042a8c3", + "7.6.5/powershell-7.6.5-osx-x64.tar.gz": "3db1d177ab39511c1b6b73b05a1630a5db4e8dce22857ca76f14c5d98f2733fd" + } +} diff --git a/.github/scripts/Install-VerifiedPowerShellArchive.ps1 b/.github/scripts/Install-VerifiedPowerShellArchive.ps1 new file mode 100644 index 0000000..a6a9d83 --- /dev/null +++ b/.github/scripts/Install-VerifiedPowerShellArchive.ps1 @@ -0,0 +1,42 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $AssetName, + [Parameter(Mandatory)][string] $ArchivePath, + [Parameter(Mandatory)][string] $InstallDirectory, + [Parameter(Mandatory)][string] $HashMapPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +$hashMap = Get-Content -LiteralPath $HashMapPath -Raw | ConvertFrom-Json -AsHashtable +if ([int]$hashMap.schemaVersion -ne 1) { + throw 'The reviewed PowerShell release hash map has an unsupported schema version.' +} +$key = "$Version/$AssetName" +$matchingKeys = @($hashMap.sha256.Keys | Where-Object { [string]$_ -ceq $key }) +if ($matchingKeys.Count -ne 1) { + throw "PowerShell release asset '$key' has no reviewed SHA-256 mapping." +} +$expectedHash = [string]$hashMap.sha256[$matchingKeys[0]] +if ($expectedHash -cnotmatch '^[0-9a-f]{64}$') { + throw "The reviewed digest for PowerShell release asset '$key' is not a lowercase 64-character SHA-256." +} +$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() +if (-not [string]::Equals($actualHash, $expectedHash, [StringComparison]::Ordinal)) { + throw "PowerShell release asset '$key' does not match its reviewed SHA-256." +} +Write-Host "Verified SHA-256 for $AssetName." + +$null = New-Item -ItemType Directory -Path $InstallDirectory -Force +if ($AssetName.EndsWith('.zip', [StringComparison]::Ordinal)) { + Expand-Archive -LiteralPath $ArchivePath -DestinationPath $InstallDirectory -Force +} +elseif ($AssetName.EndsWith('.tar.gz', [StringComparison]::Ordinal)) { + & tar -xzf $ArchivePath -C $InstallDirectory + if ($LASTEXITCODE -ne 0) { throw "PowerShell archive extraction failed for '$AssetName'." } +} +else { + throw "PowerShell release asset '$AssetName' is not a supported archive." +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f32b3..e75d741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, 'codex/**'] pull_request: + workflow_dispatch: permissions: contents: read @@ -21,11 +22,34 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Assert exact source revision + shell: pwsh + env: + EXPECTED_SOURCE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + $ErrorActionPreference = 'Stop' + $expected = $env:EXPECTED_SOURCE_SHA + $actual = (& git rev-parse HEAD).Trim() + if (-not [string]::Equals($actual, $expected, [StringComparison]::Ordinal)) { + throw "Checked-out source mismatch: expected $expected, got $actual" + } + Write-Host "Exact source revision asserted: $actual" + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.400' # Each row installs its exact PowerShell version rather than trusting the # runner image, then asserts the running version matches before doing work. - # Pinned release tags + direct GitHub release assets: no third-party action, - # no secrets, fork-safe. + # Pinned release tags + direct GitHub release assets + committed official + # checksums: no third-party action, no secrets, fork-safe. - name: Install PowerShell ${{ matrix.pwsh-version }} shell: pwsh run: | @@ -45,16 +69,13 @@ jobs: $installDir = Join-Path $env:RUNNER_TOOL_CACHE "pwsh-$version" $archive = Join-Path $env:RUNNER_TEMP $asset + $hashMap = Join-Path $env:GITHUB_WORKSPACE '.github/powershell-release-sha256.json' - New-Item -ItemType Directory -Path $installDir -Force | Out-Null Write-Host "Downloading $asset" Invoke-WebRequest -Uri "https://github.com/PowerShell/PowerShell/releases/download/v$version/$asset" -OutFile $archive - - if ($IsWindows) { - Expand-Archive -Path $archive -DestinationPath $installDir -Force - } else { - tar -xzf $archive -C $installDir - } + & (Join-Path $env:GITHUB_WORKSPACE '.github/scripts/Install-VerifiedPowerShellArchive.ps1') ` + -Version $version -AssetName $asset -ArchivePath $archive ` + -InstallDirectory $installDir -HashMapPath $hashMap Add-Content -Path $env:GITHUB_PATH -Value $installDir Write-Host "Installed PowerShell $version to $installDir" @@ -63,11 +84,9 @@ jobs: shell: pwsh run: | $expected = '${{ matrix.pwsh-version }}' - $actual = $PSVersionTable.PSVersion - $expectedMajorMinor = (($expected -split '\.')[0..1] -join '.') - $actualMajorMinor = "$($actual.Major).$($actual.Minor)" - if ($actualMajorMinor -ne $expectedMajorMinor) { - throw "PowerShell version mismatch: expected $expectedMajorMinor.x, got $actual" + $actual = $PSVersionTable.PSVersion.ToString() + if (-not [string]::Equals($actual, $expected, [StringComparison]::Ordinal)) { + throw "PowerShell version mismatch: expected $expected, got $actual" } Write-Host "PowerShell version asserted: $actual" @@ -75,7 +94,7 @@ jobs: shell: pwsh run: pwsh -File ./build.ps1 -ResolveDependency -Tasks noop - - name: Pack candidate + - name: Pack candidate with Build_GraphKitAuth shell: pwsh run: pwsh -File ./build.ps1 -Tasks pack @@ -93,4 +112,21 @@ jobs: if ($resultFiles.Count -gt 1) { throw "Multiple NUnit result files produced: $($resultFiles.Name -join ', ')" } - pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 777 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1482 -AllowedSkips 0 + if ($LASTEXITCODE -ne 0) { + throw 'The standalone whole-result gate failed.' + } + + $proofPath = 'output/testResults/tested-release-proof.json' + if (-not (Test-Path -LiteralPath $proofPath -PathType Leaf)) { + throw "No canonical tested-release proof was produced at $proofPath" + } + $packageFiles = @(Get-ChildItem -Path 'output/GraphKit.*.nupkg' -File -ErrorAction SilentlyContinue) + if ($packageFiles.Count -ne 1) { + throw "Expected exactly one GraphKit package, found $($packageFiles.Count): $($packageFiles.Name -join ', ')" + } + & ./scripts/Test-GraphKitReleaseProof.ps1 ` + -PackagePath $packageFiles[0].FullName ` + -ProofPath $proofPath ` + -TestResultPath $resultFiles[0].FullName ` + -RepositoryRoot $PWD | Out-Null diff --git a/AGENTS.md b/AGENTS.md index 9b885b7..ce3d283 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,17 @@ The container run was worth more than the one auth mode it was built for, becaus Two things about the container are worth knowing before repeating it. The immutable PSGallery `0.2.2` package was built while `Microsoft.PowerShell.SecretManagement` was a hard `RequiredModules` entry, so it had to be installed even for managed identity, which has no stored secret and never opens a vault. The `0.3.0` release removes that hard dependency: managed identity, injected credentials, help, catalog inspection and CI stay usable without SecretManagement, while first vault use explicitly validates and imports version 1.1.2 or newer and requires a registered extension. `Install-GraphKitPinned.ps1` preserves automatic SecretManagement installation for a `0.2.2` pin and makes it opt-in for `0.3.0`. None of this alters or republishes the stable `0.2.2` artifact. +The pinned SecretManagement 1.1.2 `Get-Secret` surface does not expose a `Version` parameter. +Profiles carrying version metadata therefore fail before vault access by design; with the supported +provider, give each rotated secret, password, or certificate generation a distinct immutable name. +Do not claim version-addressable vault retrieval merely because the profile schema can record the +metadata. + Run the suite through `./build.ps1 -Tasks test`, never `Invoke-Pester ./tests` directly: the changelog checks are Sampler-generated and depend on build-injected variables, so a bare Pester run reports two false failures. -**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The post-release development tree requires 777 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and `tests/QA/Assert-GateResult.ps1` enforces the same minimum-count floor used by CI and package verification. +**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The post-release development tree requires 1482 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, skips, or NotRun tests, and `tests/QA/Assert-GateResult.ps1` enforces the same minimum-count floor used by CI and package verification. The synchronization test independently discovers the suite and removes only the repository's explicit platform-only surplus before accepting that shared floor, so agreeing stale copies are not sufficient. -**Phase 5 (cutover) implementation and Ivy24 verification are complete.** All eight steps ran and were verified against the Ivy24 lab tenant: legacy-caller inventory, `Import-GraphLegacyProfile`, a private versioned package channel with publish/pin/install, a live read through the *installed* package, a GraphKit-backed data plane in IHA behind a default-off flag, reads and a reverted mutating write through it, and a full credential-generation rollover ending in the old generation's revocation. Catalog coverage of IHA's declared surface is 27 of 27 at the API version it actually calls. The 2026-08-15 cutover record preserved two operator actions because active customer repointing would have required the legacy fallback to remain. Current operator status on 2026-08-29 is that no legacy or customer-tenant consumer uses these paths, so that historical contingency is not a `0.3.0` release blocker; the repository does not independently inventory external consumers. Purging deleted directory data remains policy-controlled housekeeping rather than package work. Read `docs/cutover/2026-08-15-phase5-cutover.md` before revisiting the historical cutover. +**Phase 5 (cutover) implementation and Ivy24 verification are complete.** All eight steps ran and were verified against the Ivy24 lab tenant: legacy-caller inventory, `Import-GraphLegacyProfile`, a private versioned package channel with publish/pin/install, a live read through the *installed* package, a GraphKit-backed data plane in IHA behind a default-off flag, reads and a reverted mutating write through it, and a full credential-generation rollover ending in the old generation's revocation. Catalog coverage of IHA's declared surface is 27 of 27 at the API version it actually calls. The 2026-08-15 cutover record preserved two operator actions because active customer repointing would have required the legacy fallback to remain. The owner has since confirmed that there are no installed users, legacy consumers, customer-tenant consumers, or repoint targets. R9 legacy import/migration, customer repointing, rollback-window operation, legacy-layer retirement, and deleted-directory purge are therefore **NotApplicable** and must not be executed merely to manufacture closeout evidence. Reusable `New-GraphAppRegistration` provisioning and actual role-grant verification remain applicable product work. Reopen adopter-specific gates only if an adopter is later identified. Read `docs/cutover/2026-08-15-phase5-cutover.md` before revisiting the historical cutover. Three things from that work belong here because they change how you run the build: @@ -52,7 +58,10 @@ These are standalone scripts, deliberately not module functions: converting the Runtime flow: 1. `Register-GraphTenant` persists non-secret profile metadata; credentials stay in `Microsoft.PowerShell.SecretManagement`. -2. `Get-GraphContext` resolves a profile into an immutable runtime context before parallel work begins. +2. `Get-GraphContext` resolves a profile into an immutable runtime context before parallel work + begins. Built-in certificate, client-secret, managed-identity, and fixed-bearer modes use the + runspace-neutral `GraphKit.Auth` boundary; only caller-owned `-TokenProvider` and `-MsalFactory` + compatibility seams remain same-runspace-only. 3. Public commands resolve an operation descriptor from `source/Data/Operations/*.psd1`. 4. `Invoke-GraphOperation` validates URI/query/version/cloud/permission rules, then issues the request through a **GraphKit-owned `HttpClient`** using a token from the context's own MSAL confidential client. 5. The request pipeline handles paging, deadlines, cancellation, scoped admission control, and semantics-aware retry. GraphKit is the **sole retry owner**; there is no underlying handler that can retry behind its back. @@ -64,12 +73,16 @@ Preserve these boundaries: - **Do not use `Connect-MgGraph` / `Invoke-MgGraphRequest` as the transport.** Measured 2026-08-14: `Set-MgRequestContext` executed inside a `ForEach-Object -Parallel` child runspace **mutated the parent's configuration**. SDK state lives in process-global .NET statics shared across runspaces, and `Get-MgContext` takes no parameters because there is exactly one connection. A tenant switch in one runspace therefore retargets every other: a loop paging Tenant A would issue its next page with Tenant B's token while still labelling results Tenant A. Silent cross-tenant contamination is the worst failure this module could have. This also voids the no-replay guarantee (the SDK handler can retry a 503 before GraphKit sees it) and makes the promised split timeouts undeliverable (`Invoke-MgGraphRequest` exposes no timeout or cancellation parameters). - Acquire tokens with **MSAL.NET** per context, which has no global state and still performs certificate assertion signing. Contexts own an `IGraphTokenSource`, not an MSAL client directly: managed identity uses `ManagedIdentityApplicationBuilder`/`AcquireTokenForManagedIdentity` rather than `ConfidentialClientApplicationBuilder`/`AcquireTokenForClient`, and fixed bearer tokens cannot refresh at all. - **v1 consumes MSAL transitively** via `Microsoft.Graph.Authentication`, depended on **solely to deliver `Microsoft.Identity.Client.dll`**; never call `Connect-MgGraph`. Bind late, stay on long-stable surface, and **never ship a competing `Microsoft.Identity.Client.dll`** — four MSAL versions already coexist in a typical environment (Az.Accounts, Graph.Authentication, PSResourceGet) and first load wins in the default ALC. This is an accepted compatibility constraint, not a sound boundary: that DLL is a private implementation detail with no contract for location, load timing, or version. CI must run an import-order matrix in fresh processes. -- The recorded end state is **`GraphKit.Auth`** — a compiled adapter referencing an explicit `Microsoft.Identity.Client` version, loaded into an isolated `AssemblyLoadContext`, exposing only GraphKit-owned request/result types so no MSAL type crosses the boundary. Much later, not v1. Because contexts own `IGraphTokenSource`, that swap is an implementation change, not an interface change. +- The recorded end state is **`GraphKit.Auth`** — a compiled adapter referencing an explicit `Microsoft.Identity.Client` version, loaded into an isolated `AssemblyLoadContext`, exposing only GraphKit-owned request/result types so no MSAL type crosses the boundary. It is the active R8 gate and is not present in the immutable `0.3.0` package. Because contexts own `IGraphTokenSource`, that swap is an implementation change, not an interface change. - Do not build another OAuth client and do not persist access tokens. Client-credentials flows return no refresh token: an expired token is replaced by reacquiring from the source credential, which already lives in SecretManagement. - API version is per-operation metadata, never a global beta mode. - Generic reads may use `Get-GraphObject`; do not introduce a universal generic mutation API. - IntuneHealthAutomation retains its reports, Excel processing, checkpointing, console UI, and caching in its own repository; no adopted runtime currently makes their consolidation a GraphKit release condition. -- A current context is interactive convenience only. Low-level work must accept `-Context` or `-ProfileId` and resolve it before entering runspaces. +- A current context is interactive convenience only. Low-level work must accept `-Context` or + `-ProfileId`. Caller-owned legacy `-TokenProvider` and `-MsalFactory` sources must be created and + used in the same runspace; the public sender rejects a crossed legacy source before it joins a + shared token flight. Re-resolving mutable credentials independently in child runspaces is not + proof of the approved immutable-context contract and must not be described as equivalent. Example planned usage: @@ -111,7 +124,10 @@ Treat `build.ps1`, `build.yaml`, and `RequiredModules.psd1` as authoritative for - Normalize transport outcomes before policy logic. Retry code should consume a stable result record rather than PowerShell exception internals. - Inject `Send`, `UtcNow`, `Delay`, and `Jitter` into retry logic. Tests must use virtual time and deterministic jitter. - Prefer `PSCustomObject` records or a small compiled type over many public PowerShell classes; class definitions persist awkwardly across test runs. -- Resolve immutable contexts before asynchronous/runspace work. Shared throttle state must be thread-safe and scoped by cloud, tenant, client, resource family, and read/write class. +- Resolve immutable contexts before asynchronous/runspace work. Built-in compiled token sources + are runspace-neutral; caller-owned legacy provider/factory seams remain same-runspace-only + fail-fast containment. Shared throttle state must remain thread-safe and scoped by cloud, + tenant, client, resource family, and read/write class. - Error handling must preserve certainty: distinguish known failure from indeterminate commit. Never blanket-replay POST/PATCH after timeouts, resets, or ambiguous 5xx responses. - A successful 2xx response with `Retry-After` remains success: update future pacing, never replay it. - Treat `@odata.nextLink` as opaque, validate its Graph authority before forwarding authorization, and continue through empty pages carrying a next link. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf6bed..6fb89bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The compiled `GraphKit.Auth` authentication boundary. A dependency-free + `GraphKit.Auth.Contracts.dll` owns the GraphKit ABI-v1 DTOs, interfaces, strict loader, + proxies, and host lifetime and loads in the default `AssemblyLoadContext`. `GraphKit.Auth.dll` + and its locked `Microsoft.Identity.Client` 4.82.1 closure load in one named collectible + context per module import, so no MSAL type crosses the boundary. The built-in certificate, + client-secret, managed-identity, and fixed-bearer modes now construct runspace-neutral + compiled sources, while `Get-GraphContext -TokenProvider` and `-MsalFactory` remain the + caller-owned legacy same-runspace compatibility seams. + +### Fixed + +- Release gates now require exactly 77 passing `GraphKit.Auth` tests, independently reconcile the + portable Pester floor against fresh discovery, and verify every dynamically selected PowerShell + CI archive against a committed checksum copied from that version's official PowerShell release + `hashes.sha256` asset before extraction or execution. +- The PSGallery preflight now scans the verifier-owned package snapshot rather than only selected + source-like entries: strict UTF-8 JSON plus printable ASCII/UTF-8 and UTF-16LE strings in every + shipped DLL are checked for private paths, identifiers, GUIDs, and contextual certificate + thumbprints. The authored `src/GraphKit.Auth` C# tree is scanned separately because compilation + can omit source-only literals. Diagnostics retain only fixed categories and SHA-256 evidence + fingerprints, and legitimate 40-hex source or vendor revisions are not treated as thumbprints. +- Restored the `0.3.0` lazy SecretManagement contract after the R8 manifest regressed it to an + always-loaded package dependency. R8 packages again require only Graph Authentication at import; + SecretManagement 1.1.2+ is discovered at first vault-backed context resolution, while help, + catalog inspection, managed identity, and injected-provider contexts remain usable without it. +- The real retry/sender path now acquires exactly one bearer per physical attempt. Tenant proof, + the `Authorization` header, and returned provenance are bound to that same token fingerprint; + a rotating no-expiry provider can no longer have one token proved and another sent. +- A `401` refresh now propagates `WithForceRefresh($true)` into both confidential-client and + managed-identity MSAL builders instead of bypassing only GraphKit's local cache. Unproven + provider tenant claims no longer populate verified provenance. +- Cancellation now flows through mutation tenant proof into its nested retry pipeline, so a + cancelled proof cannot outlive the caller or proceed to the mutation send. +- The canonical acquisition tuple is now wired into the production sender. Concurrent contexts + share one ordinary or forced-refresh acquisition, while a cancelled waiter can leave without + cancelling the shared work or leaking a disposable wait handle. A cancelled leader is replaced + only when its own caller token was signalled, and followers adopt shared results through a + monotonic per-source cache so an older ordinary result cannot overwrite a newer forced refresh. +- Credential generations now bind the exact canonical PFX byte snapshot, password/material + version references, and unambiguous length-prefixed fields. Mutable unversioned vault and + subject selectors are isolated per context, relative PFX paths remain bound after a working- + directory change, and resolver-owned password/PFX buffers are disposed or zeroed on every path. +- GraphKit-owned HTTP clients and credential material now share a compiled module-lifecycle + coordinator. Removal cancels active work, waits on both operation and cancellation-callback + gates, disposes owned resources asynchronously in LIFO order, and remains bounded even when a + callback or `Dispose()` implementation does not cooperate; injected resources remain caller-owned. +- The sender now rejects a legacy PowerShell token source before it crosses into a different + runspace, where nested PowerShell-class acquisition can hang. This is fail-fast containment; + the compiled, runspace-neutral `GraphKit.Auth` adapter remains the R8 completion gate. + ## [0.3.0] - 2026-08-30 GraphKit `0.3.0` was published to PSGallery at `2026-08-30T04:38:20.12Z`. The 207381-byte public diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 index ff39b15..5f94d81 100644 --- a/RequiredModules.psd1 +++ b/RequiredModules.psd1 @@ -18,6 +18,7 @@ ModuleBuilder = '3.1.8' ChangelogManagement = '3.1.0' Sampler = '0.120.1' + 'Microsoft.PowerShell.PSResourceGet' = '1.2.0' diff --git a/build.ps1 b/build.ps1 index 10f227e..454929d 100755 --- a/build.ps1 +++ b/build.ps1 @@ -329,6 +329,21 @@ process . $taskFile.FullName } + task package_graphkit_r8_nupkg { + . Set-SamplerTaskVariable + + if (-not $BuiltModuleManifest) { + throw "No valid manifest found for project $ProjectName." + } + if (-not (Get-Command -Name Compress-PSResource -ErrorAction SilentlyContinue)) { + throw 'Compress-PSResource is required to create GraphKit R8 prerelease packages.' + } + + Get-ChildItem -LiteralPath $OutputDirectory -Filter "$ProjectName.*.nupkg" -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction Stop + Compress-PSResource -Path $BuiltModuleBase -DestinationPath $OutputDirectory -ErrorAction Stop + } + # Synopsis: Empty task, useful to test the bootstrap process. task noop { } @@ -364,8 +379,39 @@ process } } -begin -{ +begin +{ + function Get-GraphKitValidatedTrainVersion + { + [CmdletBinding()] + [OutputType([string])] + param + ( + [Parameter(Mandatory)] + [string] + $VersionScript, + + [Parameter(Mandatory)] + [string] + $RepositoryRoot + ) + + $versionOutput = @( + & $VersionScript -RepositoryRoot $RepositoryRoot -ErrorAction Stop + ) + if ($versionOutput.Count -ne 1 -or + $versionOutput[0] -isnot [string] -or + [string]::IsNullOrWhiteSpace([string] $versionOutput[0])) + { + throw ( + "The GraphKit train-version script must return exactly one non-empty string; " + + "received $($versionOutput.Count) output object(s)." + ) + } + + return ([string] $versionOutput[0]).Trim() + } + # Find build config if not specified. if (-not $BuildConfig) { @@ -524,6 +570,10 @@ begin { Write-Verbose -Message "Bootstrap completed. Handing back to InvokeBuild." + $versionScript = Join-Path $PSScriptRoot 'scripts/Get-GraphKitTrainVersion.ps1' + $env:ModuleVersion = Get-GraphKitValidatedTrainVersion ` + -VersionScript $versionScript -RepositoryRoot $PSScriptRoot + if ($PSBoundParameters.ContainsKey('ResolveDependency')) { Write-Verbose -Message "Dependency already resolved. Removing task." diff --git a/build.yaml b/build.yaml index 85bf20e..d1dc8d7 100644 --- a/build.yaml +++ b/build.yaml @@ -46,12 +46,15 @@ NestedModule: # Defining 'Workflows' (suite of InvokeBuild tasks) to be run using their alias BuildWorkflow: '.': # "." is the default Invoke-Build workflow. It is called when no -Tasks is specified to the build.ps1 - - build + - pack - test build: + - Prepare_GraphKitAuth_Clean - Clean + - Build_GraphKitAuth - Build_Module_ModuleBuilder + - Copy_GraphKitAuth_Into_BuiltModule - Build_NestedModules_ModuleBuilder - Create_changelog_release_output @@ -59,7 +62,7 @@ BuildWorkflow: pack: - build - - package_module_nupkg + - package_graphkit_r8_nupkg @@ -67,11 +70,19 @@ BuildWorkflow: test: # Uncomment to modify the PSModulePath in the test pipeline (also requires the build configuration section SetPSModulePath). #- Set_PSModulePath - - Pester_Tests_Stop_On_Fail + # Invalidate stale proof and capture the exact package/module candidate before Pester. + - Capture_Tested_Release_Proof_Candidate + # The frozen Task 3/4 ABI tests resolve the fixed runtime closure from three historical source-build + # paths. One guarded task projects only the five sealed files under exact process-local Git exclusions, + # runs Pester, and removes the files plus empty parents in finally before proof finalization. + - Pester_Tests_With_GraphKitAuth_ABI_Fixture # Use this task if pipeline uses code coverage and the module is using the # pattern of Public, Private, Enum, Classes. #- Convert_Pester_Coverage - Pester_if_Code_Coverage_Under_Threshold + # Emit publication authority only after the unchanged candidate and complete result + # pair pass the same canonical verifier both publisher scripts consume. + - Record_Tested_Release_Proof # Use this task when you have multiple parallel tests, which produce multiple # code coverage files and needs to get merged into one file. @@ -178,8 +189,3 @@ GitConfig: # FilesToAdd: # - 'CHANGELOG.md' # UpdateChangelogOnPrerelease: false # Set to true to update changelog on pre-releases too - - - - - diff --git a/docs/cutover/2026-08-15-phase5-cutover.md b/docs/cutover/2026-08-15-phase5-cutover.md index 45c4882..6c5758d 100644 --- a/docs/cutover/2026-08-15-phase5-cutover.md +++ b/docs/cutover/2026-08-15-phase5-cutover.md @@ -1,9 +1,11 @@ # Phase 5 Cutover — completion record, 2026-08-15 -> **Superseding operator status, 2026-08-29:** this is a historical execution record, not a -> current `0.3.0` release checklist. No adopted legacy or customer-tenant runtime uses these -> paths, so the retained fallback and operator actions described below are not present-day -> migration blockers. Preserve the original account below as evidence of what was verified. +> **Superseding operator status, confirmed 2026-09-01:** this is a historical execution record, +> not a current release checklist. The owner confirmed that there are no installed users, legacy +> consumers, customer-tenant consumers, or repoint targets. The adopter migration, customer +> repointing, rollback-window, legacy-retirement, and deleted-directory-purge actions described +> below are **NotApplicable** and must not be run merely for program closeout. Preserve the original +> account below as evidence of what was verified. > **Status as of 2026-08-16:** phase 5 is closed. GraphKit 0.1.0 and 0.1.1 are published to the > public PowerShell Gallery and consumed by the TenantPulse rebuild; an external security review @@ -277,7 +279,7 @@ installable from it. That changes the consumer story: IntuneHealthAutomation v2 channel. The channel and bundle remain useful for an offline host and for testing an unreleased build, but they are no longer the distribution path. -## Next moves +## Historical next moves — superseded as a current checklist 1. ~~Run `Test-GraphKitOnWindows.ps1` on the Windows host~~ — **done, 15/15 on PowerShell 7.6.5**. 2. Grant the five outstanding scopes on the lab app and re-run the descriptor verification. diff --git a/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md b/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md index 6f77706..c53310b 100644 --- a/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md +++ b/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md @@ -39,7 +39,7 @@ TenantPulse's authoritative `source/Data/DatasetMap.psd1` contains exactly five The existing check functions already define the compact row contracts: -- RBAC: `roleDefinitionName`, `groupId`, `groupDisplayName`, `isManagementRestricted`, `isAssignableToRole`. +- RBAC and assignment reporting: `roleDefinitionName`, `groupId`, `groupDisplayName`, `groupDescription`, `isManagementRestricted`, `isAssignableToRole`. - BitLocker: `policyId`, `policyName`, `isFullDiskEncryption`. - LAPS: `policyId`, `policyName`, `backsUpToEntra`, `hasSufficientComplexity`, `hasSufficientLength`, `hasPostAuthAction`. - Security baseline: `id`, `name`, `templateFamily`, `hasAssignment`, `isDeprecated`, with native Boolean values. diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md new file mode 100644 index 0000000..8c5bb73 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -0,0 +1,1346 @@ +# GraphKit R8 Authentication Boundary Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a digest-bound GraphKit `0.4.0-r8` prerelease whose four built-in authentication modes use a runspace-neutral compiled adapter with exact MSAL 4.82.1 isolated from the process default load context. + +**Architecture:** A dependency-free `GraphKit.Auth.Contracts.dll` loads in the default `AssemblyLoadContext` and owns the GraphKit ABI, strict loader, proxies, and lifetime. `GraphKit.Auth.dll` and its locked MSAL runtime closure load in one named collectible context per module import; only contract types cross. PowerShell resolves persisted credential material before a context leaves its creation runspace, then transfers owned framework types into the compiled source. + +**Tech Stack:** PowerShell 7.4/7.6, Sampler 0.120.1, ModuleBuilder 3.1.8, Pester 6.1.0, .NET SDK 10.0.400 targeting `net8.0`, Microsoft.Identity.Client 4.82.1, collectible `AssemblyLoadContext`, locked NuGet restore. + +--- + +## Status (2026-09-01) + +Deterministic implementation is complete and green: Tasks 1-7 (prerelease identity, ABI and +package boundary, dependency-free contract assembly, isolated provider, build/package/CI +wiring, built-in cutover, and deterministic parity/runspace proof) plus Task 8 Steps 0-3 +(deterministic prerequisites and the digest-bound protected runner; the exact verified revision is +recorded by the current release proof rather than pinned in this plan). The remaining checkboxes are +approval-gated and out of scope for deterministic +completion: Task 8 Steps 4-6 (protected live parity evidence), Task 9 (transitive MSAL removal, +sequenced after live parity), and Task 10 (exact-SHA CI and publication). + + +## File map + +New compiled source: + +- `global.json` — exact .NET SDK selection. +- `src/GraphKit.Auth/Directory.Build.props` — deterministic, warning-clean, `net8.0` defaults. +- `src/GraphKit.Auth/GraphKit.Auth.sln` — the two production projects and unit-test project. +- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj` — dependency-free shared ABI. +- `src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs` — credentials, descriptors, requests, results, and interfaces. +- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs` — strict shared-contract resolver. +- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs` — provider load, validation, proxy ownership, unload. +- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs` — default-context proxy and disposal state. +- `src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj` — isolated provider with MSAL 4.82.1. +- `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs` — descriptor validation and source construction. +- `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs` — cache, refresh, adoption, cancellation, disposal. +- `src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs` — confidential-client and managed-identity acquisition. +- `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj` — deterministic provider tests. +- `src/GraphKit.Auth/GraphKit.Auth.Tests/*.cs` — factory, cache, force-refresh, cancellation, and ownership tests. +- `src/GraphKit.Auth/**/packages.lock.json` — exact restored dependency graphs. + +New build and PowerShell integration: + +- `.build/GraphKitAuth.tasks.ps1` — locked build and allowlisted package staging. +- `scripts/Get-GraphKitTrainVersion.ps1` — deterministic full prerelease identity. +- `source/Private/TokenSources/New-GraphAuthTokenSource.ps1` — CLR descriptor bridge and ownership transfer. +- `tests/QA/GraphKitAuthPackage.tests.ps1` — binary/runtime-closure/ALC/no-leak package gates. +- `tests/Unit/Auth/GraphKitAuth.Tests.ps1` — public ABI and fixed-bearer behavior. +- `tests/Concurrency/GraphKitAuthRunspace.Tests.ps1` — exact source/context cross-runspace proof. +- `tests/Unit/Auth/GraphKitAuthParity.Tests.ps1` — legacy and compiled deterministic contract parity. + +Existing files to modify: + +- `build.ps1`, `build.yaml`, `.github/workflows/ci.yml` — generated version, compiled build task, SDK setup, and .NET tests. +- `source/GraphKit.psd1` — `0.4.0-r8` and eventual dependency removal; generated assemblies are + referenced only in the built manifest. +- `source/Private/Initialize-GraphModuleLifecycle.ps1` — host-first/source-later LIFO registration. +- `source/Private/TokenSources/GraphTokenSource.ps1` — compiled production selection; retained compatibility classes. +- `source/Private/TokenSources/New-GraphMsalApplication.ps1` — legacy parity/test-only scope. +- `source/Private/Transport/Send-GraphHttpRequest.ps1` — CLR-source cache adoption and scoped legacy guard. +- `source/Public/Get-GraphContext.ps1` — compiled built-ins, same-runspace provider/factory compatibility. +- `source/Private/Assert-GraphMsalEnvironment.ps1` — remove default-ALC guard after cutover. +- `scripts/New-GraphKitTestedReleaseProof.ps1`, `scripts/Test-GraphKitReleaseProof.ps1` — full prerelease/source-revision proof. +- `scripts/Install-GraphKitPinned.ps1`, `scripts/Publish-GraphKitPackage.ps1`, `scripts/Publish-GraphKitToGallery.ps1` — prerelease-aware exact artifact handling. +- `tests/QA/PackageIdentity.tests.ps1`, `tests/QA/PackageDependencies.tests.ps1`, `tests/QA/ImportOrderMatrix.tests.ps1`, `tests/QA/ReleaseProof.tests.ps1`, `tests/QA/ReleaseTruth.tests.ps1` — successor identity and isolated dependency assertions. +- `README.md`, `AGENTS.md`, `CHANGELOG.md`, both governing specs — exact completed/evidence status. + +### Task 1: Freeze and test successor package identity + +**Files:** + +- Create: `scripts/Get-GraphKitTrainVersion.ps1` +- Modify: `build.ps1` +- Modify: `source/GraphKit.psd1` +- Modify: `scripts/New-GraphKitTestedReleaseProof.ps1` +- Modify: `scripts/Test-GraphKitReleaseProof.ps1` +- Test: `tests/QA/PackageIdentity.tests.ps1` +- Test: `tests/QA/ReleaseProof.tests.ps1` + +- [x] **Step 1: Write failing successor-version tests** + +Add assertions that source declares base `0.4.0`, the train is `r8`, the built/package version is +`0.4.0-r8.g<12 hex>` for a clean tree, and the proof records the exact full version and 40-hex +source revision. Add a fixture proving that a prerelease package is found under a base-version +module directory. + +```powershell +$metadata.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}$' +$proof.source.revision | Should -Match '^[0-9a-f]{40}$' +$proof.module.version | Should -Be ([string] $metadata.version) +``` + +- [x] **Step 2: Run the focused tests and verify red** + +Run: + +```powershell +./build.ps1 -Tasks pack +./build.ps1 -Tasks test +``` + +Expected: failures naming stable `0.3.0`, missing source revision, and prerelease package discovery. + +- [x] **Step 3: Implement deterministic version generation** + +`Get-GraphKitTrainVersion.ps1` returns one string and nothing else: + +```powershell +$base = '0.4.0' +$train = 'r8' +$revision = (& git -C $RepositoryRoot rev-parse HEAD).Trim().ToLowerInvariant() +$diff = (& git -C $RepositoryRoot diff --binary HEAD) +$suffix = if ([string]::IsNullOrEmpty($diff)) { + '' +} else { + $bytes = [Text.Encoding]::UTF8.GetBytes($diff) + $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + ".d$($hash.Substring(0, 12))" +} +"$base-$train.g$($revision.Substring(0, 12))$suffix" +``` + +Set `$env:ModuleVersion` in `build.ps1` before Sampler resolves build metadata. Record the complete +semantic version and source state in proof schema v2. Resolve the built directory from base +`ModuleVersion` while resolving the package from full PSData prerelease/version metadata. + +- [x] **Step 4: Run identity/proof tests and verify green** + +Expected: every new identity fixture passes; no package named `GraphKit.0.3.0.nupkg` is produced. + +- [x] **Step 5: Commit** + +```bash +git add build.ps1 source/GraphKit.psd1 scripts/Get-GraphKitTrainVersion.ps1 scripts/New-GraphKitTestedReleaseProof.ps1 scripts/Test-GraphKitReleaseProof.ps1 tests/QA/PackageIdentity.tests.ps1 tests/QA/ReleaseProof.tests.ps1 +git commit -m "build: establish the GraphKit R8 prerelease identity" +``` + +### Task 2: Add red ABI and package-boundary tests + +**Files:** + +- Create: `tests/Unit/Auth/GraphKitAuth.Tests.ps1` +- Create: `tests/QA/GraphKitAuthPackage.tests.ps1` +- Modify: `tests/QA/PackageDependencies.tests.ps1` + +- [x] **Step 1: Write the missing-artifact and ABI tests** + +The tests require these exact package paths: + +```text +Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll +Assemblies/GraphKit.Auth/GraphKit.Auth.dll +Assemblies/GraphKit.Auth/GraphKit.Auth.deps.json +Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll +Assemblies/GraphKit.Auth/Microsoft.IdentityModel.Abstractions.dll +``` + +Load the contracts assembly and assert: + +```powershell +[GraphKit.Auth.GraphAuthHost]::ContractMarker | Should -Be 'GraphKit.Auth.Abi/1' +[GraphKit.Auth.IGraphTokenSource].GetMethod('Acquire').ReturnType.FullName | + Should -Be 'GraphKit.Auth.GraphTokenResult' +``` + +Reflect over every public type/member signature and fail when the declaring assembly or full type +name contains `Microsoft.Identity.Client`. + +- [x] **Step 2: Run the two files and verify red** + +Expected: missing assembly/package path failures only. + +- [x] **Step 3: Commit tests only** + +```bash +git add tests/Unit/Auth/GraphKitAuth.Tests.ps1 tests/QA/GraphKitAuthPackage.tests.ps1 tests/QA/PackageDependencies.tests.ps1 +git commit -m "test: define the GraphKit Auth package boundary" +``` + +### Task 3: Implement the dependency-free contract assembly + +**Files:** + +- Create: `global.json` +- Create: `src/GraphKit.Auth/Directory.Build.props` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs` + +- [x] **Step 1: Pin the SDK and deterministic defaults** + +`global.json`: + +```json +{ + "sdk": { + "version": "10.0.400", + "rollForward": "disable", + "allowPrerelease": false + } +} +``` + +`Directory.Build.props` sets `TargetFramework=net8.0`, `Nullable=enable`, +`ImplicitUsings=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, +`ContinuousIntegrationBuild=true`, `DebugType=None`, and +`RestorePackagesWithLockFile=true`. + +- [x] **Step 2: Implement ABI-v1 DTOs and interfaces** + +Use sealed mutable-result/plain-constructor types, not records and not PowerShell types. Validate +null/empty strings, absolute HTTPS authorities/resources, GUID presence by auth mode, credential +discriminator agreement, private-key presence, and non-empty generation before a provider loads. + +The result must retain a settable `VerifiedTenantId`: + +```csharp +public sealed class GraphTokenResult +{ + public required string AccessToken { get; init; } + public DateTimeOffset ExpiresOnUtc { get; init; } + public DateTimeOffset ReceivedOnUtc { get; init; } + public required string TokenType { get; init; } + public required string[] Scopes { get; init; } + public string? VerifiedTenantId { get; set; } + public required string TokenFingerprint { get; init; } + public required string CredentialGeneration { get; init; } +} +``` + +- [x] **Step 3: Implement strict loader and proxy lifetime** + +`GraphAuthLoadContext.Load` returns the default contracts assembly for the exact matching contract +name and uses `AssemblyDependencyResolver` for every isolated dependency. It rejects a second +contracts copy, an unexpected provider name/version, and a provider path outside the declared +payload root. Host import validates `GraphKit.Auth.Abi/1`; an incompatible contracts assembly +already loaded in the default context fails with an instruction to start a fresh PowerShell +process. + +`GraphTokenSourceProxy` uses `Interlocked` state, forwards contract members, clears the inner source +on dispose, and tells the host exactly once. It never catches and relabels provider exceptions. + +- [x] **Step 4: Build the contracts project** + +Run: + +```bash +dotnet build src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj -c Release +``` + +Expected: zero warnings and errors; no `Microsoft.Identity.Client` in `project.assets.json`. + +- [x] **Step 5: Commit** + +```bash +git add global.json src/GraphKit.Auth +git commit -m "feat: define the GraphKit Auth ABI" +``` + +### Task 4: Implement the isolated provider and deterministic .NET tests + +**Files:** + +- Create: `src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj` +- Create: `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs` +- Create: `src/GraphKit.Auth/GraphKit.Auth.sln` + +- [x] **Step 1: Write failing .NET source-contract tests** + +Use an internal fake acquisition client to prove cache reuse, adaptive refresh, forced-refresh +replacement, cancellation, failed-acquisition fanout, generation rejection, fixed-bearer refusal, +and exactly-once material disposal. A representative test is: + +```csharp +[Fact] +public void ForcedRefreshReplacesAnOlderCachedResult() +{ + using var source = SourceFixture.Refreshable("first", "second"); + Assert.Equal("first", source.Acquire(false, CancellationToken.None).AccessToken); + Assert.Equal("second", source.Acquire(true, CancellationToken.None).AccessToken); + Assert.Equal("second", source.Acquire(false, CancellationToken.None).AccessToken); +} +``` + +- [x] **Step 2: Run .NET tests and verify red** + +Run: + +```bash +dotnet test src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj -c Release +``` + +Expected: missing provider/source types. + +- [x] **Step 3: Implement the minimal complete provider** + +`GraphKit.Auth.csproj` pins: + +```xml + + + false + runtime + +``` + +The factory accepts one immutable `GraphTokenRequest` containing the source-constant identity and +credential fields and creates one confidential-client or managed-identity MSAL application per +source. Per-call force refresh and cancellation remain arguments to `IGraphTokenSource.Acquire`; +there is no duplicate descriptor DTO. The source computes SHA-256 token fingerprints, uses +`AuthenticationResult.ExpiresOn`, records `ReceivedOnUtc` at successful acquisition, validates +generation on every result/adoption, and never parses a JWT. Fixed bearer returns +`DateTimeOffset.MinValue` expiry and throws on force. Every MSAL exception is caught inside the +isolated provider and converted to a GraphKit-owned `GraphAuthException` without preserving an +MSAL `InnerException` or `Data` value. + +- [x] **Step 4: Lock restore and run tests green** + +Run: + +```bash +dotnet restore src/GraphKit.Auth/GraphKit.Auth.sln --use-lock-file +dotnet restore src/GraphKit.Auth/GraphKit.Auth.sln --locked-mode +dotnet test src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj -c Release --no-restore +``` + +Expected: all tests pass, zero warnings, committed lock files name exact MSAL 4.82.1. + +- [x] **Step 5: Commit** + +```bash +git add src/GraphKit.Auth +git commit -m "feat: add the isolated GraphKit Auth provider" +``` + +### Task 5: Integrate compiled build, package, and CI + +**Approved base:** `a8b74d8df692e70bb89d1645796c6cecae31aafc`, independently approved with +no Critical, Important, or Minor findings. Verify this literal HEAD and a clean tracked worktree +before writing tests; stop if either differs. + +**Files:** + +- Create: `.build/GraphKitAuth.tasks.ps1` +- Create: `scripts/private/GraphKit.AuthStageCapture.cs` +- Modify: `build.yaml` +- Modify: `.github/workflows/ci.yml` +- Modify: this complete Task 5 section +- Modify: `scripts/Test-GraphKitReleaseProof.ps1` +- Modify: `source/GraphKit.psd1` only if needed to make its empty source-manifest + `RequiredAssemblies` intent explicit +- Test: `tests/QA/GraphKitAuthPackage.tests.ps1` +- Test: `tests/QA/BuiltModule.tests.ps1` +- Test: `tests/QA/ReleaseProof.tests.ps1` + +Do not modify the PowerShell authentication bridge, remove `Microsoft.Graph.Authentication`, alter +public commands, perform live authentication, touch a vault or tenant, create Azure resources, or +implement Tasks 6-10. Preserve the immutable public `0.3.0` artifact. + +#### Controller rulings + +The sealed stage protects against accidental mutation and unprivileged or different-identity +writers. Require fresh create-new topology, physical containment, no links or aliases, stable native +identities, regular-file link count one, exact closure and digest revalidation, and owner-only sealed +permissions. Directory link counts are platform-defined and are not required to be one. Do not +claim resistance to the filesystem owner, administrator/root, writable ancestors, or an actor able +to re-grant permissions. The path-based loader is not an adversarial atomic byte-binding mechanism. + +Keep mutable compiler output and authorized stage bytes separate: + +```text +output/GraphKit.Auth/capture/.build-/publish/{provider/,payload/} +output/GraphKit.Auth/capture/.build-/dotnet-test/ +output/GraphKit.Auth/capture//{manifest.json,payload/} +output/GraphKit.Auth/stage///{manifest.json,payload/} +``` + +`stage` is never the direct `dotnet publish` destination. A version path is create-new and fails +closed if it exists; it is never reused, merged, or overwritten. Bind Task 5 to one Release lineage: +locked restore, build without restore, machine-readable xUnit with no build or restore and no +skipped/unexecuted outcome, then provider publish without build or restore. Contracts, tested +provider, and published provider must come from that one build. The build workspace is one exact +owner-only child created before either mutable output root; no top-level `publish` or `dotnet-test` +child is permitted beneath the GraphKit.Auth authority root. + +The focused private C# helper is authorized only for relative no-follow opens, physical +containment, native identity and link count, stable-handle hashing, and platform permission +evidence. Do not modify `GraphKit.SourceCapture.cs` or embed a large native implementation in the +Invoke-Build task. The helper is build-time/private and changes no public or runtime ABI. + +- [x] **Step 1: Record the approved baseline and write genuine failing tests** + +Record the approved boundary before implementation: 48 .NET tests; 23 focused +`GraphKitAuth.Tests.ps1` cases; 8 package cases; and 31 combined cases with 26 passed and exactly +five missing direct package paths. Reds must be attributable to absent Task 5 behavior, with no +discovery error, skip, or NotRun. + +Test all five package paths, exact five-file closure, existing-version refusal without mutation, +and missing, extra, renamed, writable, byte-mutated, byte-identical-replaced, hard-linked, +escaped-linked, case-aliased, separator-aliased, Unicode-normalization-aliased, symlink, and +junction/reparse mutations. Require native identity stability and link count one for regular files +and the manifest, but never require directory link count one. Keep the source `RequiredAssemblies` +empty and the built value exact. Match stage, built-module, and archive digests. Use a fresh process +to load contracts in Default ALC and the provider/MSAL/IdentityModel in one named collectible ALC, +construct without acquisition from the reverified sealed payload, preserve any Default-ALC MSAL, +and prove unload. + +Extend release-proof mutations for exact duplicates, portable case, NFC, separators, traversal, +and ZIP external attributes encoding symlinks, reparse points, or non-regular files. Negative +fixtures must not contact Graph, read a vault, acquire a token, or require external state. + +- [x] **Step 2: Implement one locked build lineage and fresh sealed staging** + +`Build_GraphKitAuth` asserts `dotnet --version` is exactly `10.0.400`, restores the solution once +with `--locked-mode`, builds the complete Release solution with `--no-restore`, runs the Release test +project with `--no-build --no-restore`, and publishes only the already-built provider with +`--no-build --no-restore --no-self-contained` and no RID. Parse TRX and require at least the approved +48 tests with `total = executed = passed` and zero failed, skipped, not-executed, aborted, timeout, +or error outcomes. Preserve `TreatWarningsAsErrors` and compare provider and dependency identities +and digests across the build/test/publish lineage. + +Raw provider publish is untrusted and contains exactly: + +```text +GraphKit.Auth.dll +GraphKit.Auth.deps.json +Microsoft.Identity.Client.dll +Microsoft.IdentityModel.Abstractions.dll +``` + +Obtain `GraphKit.Auth.Contracts.dll` separately from the same build, verify its dependency-free +identity and lineage, and form the exact fixed five-file payload. Verify managed identities, +including MSAL `4.82.1.0` and IdentityModel `8.14.0.0`; do not permit another runtime dependency. + +Create an owner-only capture on the same filesystem. Copy each file individually with create-new +semantics, flush it, reopen without following links, and verify digest, native identity, link count, +closure, containment, and aliases. Write a fixed-property-order canonical UTF-8-without-BOM manifest +containing no absolute path, run ID, timestamp, or volatile data. It records full module version, +ordinal payload paths, lengths, SHA-256 values, native file identities, link counts, directory +identities, and final permission policy; it does not recursively contain its own hash or identity. +The manifest itself is a no-follow regular link-count-one file. + +Seal all files and directories against owner writes, using exact Unix modes or protected Windows +ACLs rather than the read-only attribute alone. Hash canonical manifest bytes, same-filesystem +atomically rename the capture into `stage//`, then reopen and +revalidate the manifest-name/hash binding, closures, identities, permissions, aliases, and physical +containment. `manifest.json` is metadata; only `payload/` supplies runtime bytes. + +`Prepare_GraphKitAuth_Clean` runs before Sampler `Clean`. It first verifies every prior stage and +may unseal only exact physically contained envelopes whose canonical manifest, digest path, +identities, permissions, and closure pass. Missing or forged manifests, partial sealing, links, +aliases, and containment ambiguity fail closed before `Clean`. + +Machine-readable .NET results and provider-publish scratch live only below one unique owner-only +`output/GraphKit.Auth/capture/.build-/` workspace. Once the sealed stage no longer depends +on its payload source, an outer `finally` atomically moves that exact captured workspace, with +no-replace semantics, into a task-specific `output/GraphKit.Auth.quarantine-/` sibling. This +workspace move is independent of the source-build quarantine and runs on both success and failure, +so a completed build restores an empty `capture` root before returning. A hard process death can +leave the workspace in `capture`; the next Prepare must fail closed rather than delete it. + +Separately, after capture and before module version is recalculated, move only these literal source +generated roots intact into the same recoverable task-specific quarantine, including on failure: + +```text +src/GraphKit.Auth/GraphKit.Auth.Contracts/bin +src/GraphKit.Auth/GraphKit.Auth.Contracts/obj +src/GraphKit.Auth/GraphKit.Auth/bin +src/GraphKit.Auth/GraphKit.Auth/obj +src/GraphKit.Auth/GraphKit.Auth.Tests/bin +src/GraphKit.Auth/GraphKit.Auth.Tests/obj +src/GraphKit.Auth/GraphKit.Auth.Tests/TestResults +``` + +Do not resolve these through recursion, wildcard expansion, or discovery. Quarantine must finish +before module version or proof state is recaptured. + +The approved Task 3/4 ABI Pester boundary resolves its actual-provider candidate from three of +those historical source-build paths. After release-proof capture, materialize only the sealed, +immediately reverified five payload files at those exact paths for that test boundary; refuse any +pre-existing destination and require every copied digest and link count to match the manifest. +Use a temporary process-scoped `core.excludesFile` containing exactly five root-anchored literal +file patterns, saving and restoring every inherited `GIT_CONFIG_*` value without changing any Git +configuration file. Prove an unrelated untracked sentinel still changes source identity. One +outer `try/finally` runs Pester, removes exactly the five files and only empty literal parents, +restores Git environment, and proves fingerprint/status restoration before release-proof +finalization runs with no exclusion active. This is test-fixture projection from the authorized +stage, not another build or another package source. + +- [x] **Step 3: Copy only a freshly reverified stage into the built module** + +Immediately before copy, repeat the full sealed-stage verification. Create +`Assemblies/GraphKit.Auth` fresh and copy the five literal names individually with create-new +semantics; never use `Copy-Item *`. Rehash destinations and reject extras, links, aliases, or +replacement. Only after contracts exists, update only the built manifest so `RequiredAssemblies` +is exactly `@('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')`, then run +`Test-ModuleManifest`. Keep the source value empty and retain both current runtime modules. + +The build order is exact: + +```text +Prepare_GraphKitAuth_Clean + -> Clean + -> Build_GraphKitAuth + -> Build_Module_ModuleBuilder + -> Copy_GraphKitAuth_Into_BuiltModule + -> Build_NestedModules_ModuleBuilder + -> Create_changelog_release_output + -> package_graphkit_r8_nupkg +``` + +- [x] **Step 4: Reverify before import and harden canonical proof** + +The release-proof verifier must match packed runtime bytes to the sealed manifest and reject +duplicates, portable-case and NFC-equivalent collisions, backslashes, absolute/drive paths, +empty/dot/dot-dot segments, and ZIP link/reparse/non-regular encodings. The package test +independently proves the exact five-file auth subtree. Reverify the stage immediately before the +fresh-process `GraphAuthHost` probe; never load that probe from raw publish or the mutable built +copy. Built and archive bytes independently match all five stage digests. + +PowerShell 7.4 CI is the future .NET 8 runtime/import evidence; local xUnit on this host rolls the +net8 project to installed .NET 10. Record the distinction and do not claim observed .NET 8 evidence +until the exact-SHA PowerShell 7.4 row passes. + +- [x] **Step 5: Enforce exact-event-source CI and run all gates** + +CI retains all six Windows/Ubuntu/macOS by PowerShell `7.4.19`/`7.6.5` rows and triggers on pushes +to `main` and `codex/**`, pull requests, and manual dispatch. Checkout selects PR head repository +and full head SHA for PRs, otherwise current repository and `github.sha`, with `fetch-depth: 0`. +Immediately assert `git rev-parse HEAD` ordinally against that event SHA before SDK setup or restore. +Use one `actions/setup-dotnet@v4` for `10.0.400`, assert the complete three-part PowerShell version, +and reach `Build_GraphKitAuth` through pack in every row. A same-SHA push run is later release +authority; the PR merge-ref run is supplementary. + +Run locked dependency restore, pack, focused Auth ABI/package/built/release-proof Pester, then the +full `./build.ps1 -Tasks test`. Require zero failures, discovery errors, skips, and NotRun. After +the dirty candidate is green, commit once, then repeat pack/test on the exact clean commit because +its prerelease identity changes. Run the standalone whole-result gate and canonical verifier +against the already-tested package. Require clean status, `git diff --check`, unchanged lock graphs, +exact five-file closure, no generated files tracked, all seven literal generated roots absent, and +no blanket ignore rule. Retain ignored package, proof, and stage evidence. + +- [x] **Step 6: Commit and report** + +```bash +git add .build/GraphKitAuth.tasks.ps1 scripts/private/GraphKit.AuthStageCapture.cs build.yaml source/GraphKit.psd1 .github/workflows/ci.yml docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md scripts/Test-GraphKitReleaseProof.ps1 tests/QA/GraphKitAuthPackage.tests.ps1 tests/QA/BuiltModule.tests.ps1 tests/QA/ReleaseProof.tests.ps1 +git commit -m "build: package the isolated GraphKit Auth runtime" +``` + +Write ignored `.superpowers/sdd/2026-08-30-r8-graphkit-auth/task-5-report.md` after the commit with +the exact SHA, red/green counts, SDK/runtime distinction, threat boundary, platform fixtures, +dependency closure, stage/build/archive digests, remaining external CI evidence, and concerns. No +push, PR, publication, tenant, vault, token acquisition, Azure, merge, or gallery action occurs. + +### Task 6: Cut built-in context construction over to compiled sources + +**Files:** + +- Create: `source/Private/TokenSources/New-GraphAuthTokenSource.ps1` +- Modify: `source/Private/Initialize-GraphModuleLifecycle.ps1` +- Modify: `source/Private/TokenSources/GraphTokenSource.ps1` +- Modify: `source/Private/Transport/Send-GraphHttpRequest.ps1` +- Modify: `source/Public/Get-GraphContext.ps1` +- Test: `tests/Unit/Auth/GraphKitAuth.Tests.ps1` +- Test: `tests/Unit/Profiles/Get-GraphContext.Tests.ps1` +- Test: `tests/Adapter/Send-GraphHttpRequest.Tests.ps1` + +- [x] **Step 1: Write failing bridge/ownership tests** + +Assert that production certificate, client-secret, managed-identity, and bearer contexts return an +object implementing `GraphKit.Auth.IGraphTokenSource`; `-TokenProvider` and `-MsalFactory` return the +legacy same-runspace source. Assert that persisted PFX bytes are read once, unsupported vault +version metadata fails before vault access, and failed host creation disposes owned material. + +- [x] **Step 2: Verify red** + +Run the three focused Pester files. Expected: built-in contexts still return PowerShell classes. + +- [x] **Step 3: Implement the bridge** + +`New-GraphAuthTokenSource` constructs a `GraphTokenRequest` and transfers material only +after generation verification. Production `New-GraphTokenSource` selects it when `-MsalFactory` is +absent. Register the host before any source and register each compiled source as GraphKit-owned. + +In the sender, adopt shared results for either legacy `GraphTokenSourceBase` or compiled +`IGraphTokenSource`. Apply the creation-runspace preflight only to the legacy base class. + +- [x] **Step 4: Run focused tests green and commit** + +```bash +git add source/Private/TokenSources/New-GraphAuthTokenSource.ps1 source/Private/Initialize-GraphModuleLifecycle.ps1 source/Private/TokenSources/GraphTokenSource.ps1 source/Private/Transport/Send-GraphHttpRequest.ps1 source/Public/Get-GraphContext.ps1 tests/Unit/Auth/GraphKitAuth.Tests.ps1 tests/Unit/Profiles/Get-GraphContext.Tests.ps1 tests/Adapter/Send-GraphHttpRequest.Tests.ps1 +git commit -m "feat: use compiled token sources for built-in auth" +``` + + +### Task 7: Prove deterministic parity and genuine cross-runspace use + +**Approved base:** exact independently approved clean Task 6 commit +`d16ca572f3746a596456dc8421d4b821f8bcc583`. At dispatch, require +`git rev-parse HEAD` to equal that SHA and `git status --short` to be empty. The stale tracked +Task 7 plan section is a known exception on this sealed base; replacing it is the first tracked +Task 7 edit and remains part of the one Task 7 implementation commit. + +**Files:** + +- Modify: `docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md` +- Create: `tests/Fixtures/GraphKitAuthParityCases.json` +- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs` +- Create: `tests/Unit/Auth/GraphKitAuthParity.Tests.ps1` +- Create: `tests/Concurrency/GraphKitAuthRunspace.Tests.ps1` +- Modify: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj` +- Modify: `src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs` +- Modify: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs` +- Modify: `source/Private/TokenSources/GraphTokenSource.ps1` +- Modify: `tests/Unit/TokenSources/GraphTokenSource.Tests.ps1` +- Modify: `tests/Concurrency/TokenIsolation.Tests.ps1` +- Modify: `tests/Adapter/GraphModuleLifecycleSender.Tests.ps1` +- Modify: `tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1` +- Modify: `tests/Unit/Auth/GraphKitAuth.Tests.ps1` + +Do not change public command signatures, the frozen CLR ABI, runtime package closure, +dependency/import guard, package identity policy, or Task 8+ implementation files. Do not contact +Graph, a real vault/credential, tenant, IMDS, or Azure; do not push, publish, or use Pester +parallelism. A locked-cache miss that would require network access stops for controller authority. + +#### Controller rulings + +Parity covers normalized behavior common to legacy and compiled sources using one strict checked-in +matrix with independently authored literal expectations for each runner. Disposal, reference +clearing, owned-material drain, host shutdown, and ALC unload are compiled-only successor guarantees +because legacy PowerShell sources are not `IDisposable`; never retrofit legacy disposal for symmetry. + +Parent-created contexts and sources enter child thread runspaces only through a unique AppDomain +holder. Child `ArgumentList` carries primitive manifest paths, holder keys, and flags. A required +child module import may automatically create that module instance's ordinary module-scoped host, +but Step 5 children never construct a host, context, source, profile, or vault and use only the exact +parent context/source recovered from the holder; their automatic import host is unused and cleaned. +Step 6's separate owning lifecycle job may create the synthetic public fixed-bearer context solely +to prove module removal, later-use refusal, and ALC collection. Every child module is removed and its +cleanup observed before the parent ALC collection assertion. + +The real fixed-bearer runspace gate uses public `Get-GraphContext` against a temporary raw schema-1 +profile containing a clearly synthetic inline `Credential.Token`. It does not use +`Register-GraphTenant`, SecretManagement, a vault, `-MsalFactory`, or private source construction; +this test fixture is not a supported operator workflow for real bearer tokens. + +Add private bounded waiter-count instrumentation to `GraphTokenFlight` as the outer-flight +happens-before gate. Do not alter keys, results, removal races, cancellation replacement, public +commands, or the frozen CLR ABI. + +Prove lifecycle by composition without production marker hooks: provider xUnit owns source/material +drain; retained Task 3 gates own host/proxy shutdown and unload; module tests own actual registration +plus generic test-probe LIFO; packaged fixed bearer owns exact runspace crossing, use-after-removal, +and ALC collection. + +Tests for behavior already delivered by Tasks 3-6, including compiled runspace neutrality, may +characterize green on the clean base only when a reversible semantic mutation makes the intended +named case fail and the source is restored byte-for-byte. New Task 7 waiter instrumentation and +dead-host-field removal require genuine base failures; any lifecycle behavior absent on the base +does too. The new runspace harness itself is not a production red: mutation-prove that it rejects +source reconstruction, legacy cross-runspace use, lost `ReferenceEquals`, and leaked child module +hosts. Discovery/setup errors, stale output, skips, NotRun, timing, and external state are invalid +reds. + +Five count authorities remain separate. Task 7 records and asserts exact post-discovery equality for +.NET, focused Pester, Task 6 owning, expanded regression, and whole Pester. The existing build check +accepting at least 48 .NET tests is not Task 7 count authority; durable global ratchet +synchronization remains Task 9 scope. + +- [x] **Step 1: Replace the tracked plan section and record exact clean baselines** + +Verify exact HEAD and clean status, then replace the complete tracked Task 7 section with this final +controller contract before authoring tests. Do not create an intervening documentation commit. + +Record these exact clean Task 6 baselines: + +```text +.NET GraphKit.Auth: 48 +Task 7 focused Pester: 95 +Task 6 owning Pester: 246 +Task 6 expanded regression: 440 +Whole repository Pester: 1,180 +``` + +The focused baseline is the sum of the five existing Task 7 files on clean Task 6: +`TokenIsolation` 8, `GraphTokenSource` 48, `GraphModuleLifecycleSender` 2, +`GraphModuleLifecycle` 13, and `GraphKitAuth` 24. The two new files contribute zero at base. + +- [x] **Step 2: Add the strict shared 16-row matrix and test-only discovery** + +Create `GraphKitAuthParityCases.json` with exact top-level fields `schemaVersion`, `rowCount`, and +`rows`; require schema `1` and `rowCount` `16`. Every row has exactly `id`, `runners`, `scenario`, +`authMode`, `callLayerByRunner`, `input`, and `expectedByRunner`. Every row runs once in both +`xunit-compiled` and `pester-legacy`. + +| Row ID | Auth mode | xUnit layer | Pester layer | +| --- | --- | --- | --- | +| `construction-certificate` | Certificate | construction only | construction only | +| `construction-client-secret` | ClientSecret | construction only | construction only | +| `construction-managed-identity` | user-assigned ManagedIdentity | construction only | construction only | +| `construction-bearer-token` | BearerToken | construction only | construction only | +| `ordinary-cache-hit` | Certificate | direct source | direct source | +| `expired-result-refresh` | ClientSecret | direct source | direct source | +| `ordinary-forced-ordinary` | ManagedIdentity | direct source | direct source | +| `acquisition-failure-fanout-retry` | Certificate | compiled internal source flight | legacy production outer keyed flight | +| `caller-cancellation-no-cache` | ClientSecret | direct source | direct source | +| `fixed-bearer-cache-force-refusal` | BearerToken | direct source | direct source | +| `fingerprint-certificate` | Certificate | direct source | direct source | +| `fingerprint-client-secret` | ClientSecret | direct source | direct source | +| `fingerprint-managed-identity` | ManagedIdentity | direct source | direct source | +| `fingerprint-bearer-token` | BearerToken | direct source | direct source | +| `adoption-generation-mismatch` | Certificate | direct source | direct source | +| `adoption-valid` | ManagedIdentity | direct source | direct source | + +Each `expectedByRunner` record is independently and literally authored for its runner with one +closed field set: source contract/metadata, token sequence, expiries, token types, ordered scopes, +tenant proof, fingerprint, generation, received-time rule, application/provider acquisition counts, +force flags, reference identity, normalized failure kind, cache state, and final flight-registry +count. Null and empty values remain explicit. Neither runner derives expectations from production +code, matrix input, or the other runner. + +Compare token, expiry, type, ordered scopes, tenant proof, fingerprint, generation, and source +metadata ordinally. Check reference identity separately. Normalize only `AcquisitionFailure`, +`Canceled`, `RefreshRefused`, `GenerationMismatch`, and `Disposed`. Legacy `ReceivedOnUtc` is +wall-clock: require non-default, monotonic, and no later than valid expiry. Compiled acquisition time +uses the injected clock; adopted literal results retain their supplied time in both runners. + +Construction expects zero token acquisitions. Compiled Certificate, ClientSecret, and +ManagedIdentity create exactly one application/client during factory construction; legacy +`-MsalFactory` remains uninvoked until acquisition. BearerToken creates none. + +Use these exact fingerprint inputs and literal lowercase SHA-256 values: + +```text +task7-fingerprint-certificate +245d574cc6b41262c018a65535f9937601045f29abff3df9d112e491048948b6 +task7-fingerprint-client-secret +b4ec6c20d74417b5be0b54ce83bc39a9f0b2e990ec173e6ee9373491a65de55e +task7-fingerprint-managed-identity +6973fa751d466a0429077804c84708dc98188047d415ca992a583a6bf7744866 +task7-fingerprint-bearer-token +04fa2face75f1b6e4df7e9270266abec23741a9e91c7fad9b12b1c8585c30bca +``` + +Both loaders independently reject these nine permanent malformed cases: + +```text +unsupported-schema-version +incorrect-row-count +duplicate-row-id +missing-required-row-id +unknown-property +missing-required-property +duplicate-json-property +invalid-runner-call-layer +missing-runner-expectation +``` + +They also reject unknown or wrong-typed fields, invalid ID/runner sets, and duplicate JSON +properties before executing any semantic row. Mutation cases must be individually discoverable in +both runners rather than hidden in a setup failure. + +Link the exact repository fixture into the .NET test output: + +```xml + +``` + +xUnit loads only `AppContext.BaseDirectory/Fixtures/GraphKitAuthParityCases.json`; Pester loads only +`/tests/Fixtures/GraphKitAuthParityCases.json`. No current-directory or alternate fallback is +allowed. Each reports the same matrix SHA-256 and proves no row was filtered. + +After all test-only files/changes discover successfully and before any production edit, record a +literal per-file and per-theory inventory. Let `D`, `F`, `O`, `E`, and `W` be net new .NET, +Task 7 focused, Task 6 owning, expanded-regression, and whole-repository discoveries. Assert exact +equality, never a minimum: + +```text +.NET exact total = 48 + D +Task 7 focused exact total = 95 + F +Task 6 owning exact total = 246 + O +Expanded exact total = 440 + E +Whole Pester exact total = 1,180 + W +``` + +The planned .NET ledger is `+16` semantic rows, `+9` loader-mutation cases, `+2` +Certificate/ClientSecret drain-theory cases, and `-1` superseded single-mode fact: planned net +`D = 26`, exact .NET total `74`. Any discovery difference stops the task for inventory +reconciliation before production work. Additional reviewed cases must be itemized into `D`. + +Run the test-only base phase. Characterization-green Task 3-6 families, including the new harness +over already runspace-neutral compiled sources, require reversible mutation proof; genuinely absent +Task 7 waiter/dead-field/lifecycle behavior must fail for the intended reason. Restore every +mutation byte-for-byte and repack before proceeding. + +- [x] **Step 3: Make compiled-source drain deterministic for both owned modes** + +Replace the existing single-mode, delay-based active-acquisition disposal fact with a +Certificate/ClientSecret theory. The fake client signals entry, waits on the supplied cancellation +token, records cancellation, exits, and only then may material cleanup record disposal. Require: + +```text +acquire-entered -> cancellation-observed -> acquire-exited -> material-disposed +``` + +Require one client and one material disposal, bounded event/task completion, and no sleep, finite +`Task.Delay`, or `CancelAfter` as ordering evidence. + +- [x] **Step 4: Add exact outer-flight waiter instrumentation** + +Add private thread-safe follower entry/departure counts to `GraphTokenFlight`. Increment only after +a follower obtains the exact registry flight and before it awaits; decrement in `finally`. Expose +only minimal in-module observation to tests. + +Remove duration-based scheduling assumptions from the Task 7 file set. Leaders/providers wait on +explicit gates. Tests require exact bounded waiter counts before release, failure, cancellation, +disposal, or replacement. Cancellation-aware infinite waits used only to model work until +cancellation are allowed; elapsed duration is never evidence. + +Cover ordinary collapse, provider-failure fanout, leader-cancellation replacement, +production-sender collapse, ordinary/forced partitioning, concurrent credential reuse, +active-source disposal, and exact empty-registry cleanup. + +- [x] **Step 5: Prove exact parent-source use across thread runspaces** + +Use `Start-ThreadJob` with a GUID AppDomain holder containing the parent context/source, +ready/go/release gates, a `ConcurrentQueue` of child-observed sources, counters, and results. +Children retrieve and enqueue the actual parent source; the parent requires `ReferenceEquals` for +every child. + +Every case is bounded: child ready uses `Wait(5000)`; provider work waits on a +cancellation-aware gate; the parent uses `Wait-Job -Timeout 10` and `Receive-Job` without `-Wait`. +`finally` releases/cancels gates, removes modules/jobs, clears AppDomain data and references, and +disposes synchronization objects. No unbounded wait, sleep, delay, `Receive-Job -Wait`, child +`Get-GraphContext`, request reconstruction, profile/vault access, or explicit host/source creation. + +Each child retains its exact imported `ModuleInfo`, removes it in `finally`, requires that module's +lifecycle `CleanupDone.Wait(5000)`, clears child module/host references, and reports cleanup before +the parent removes the job. The import-created child host is allowed but never used as the parent +source under test. + +Required cases: + +1. A real compiled fixed-bearer context created through public `Get-GraphContext` against a temporary + raw schema-1 store whose only material is + `Credential.Token = 'task7-synthetic-fixed-bearer-token'`. Use a fixed synthetic TenantId, + `ClientId = $null`, no selector, no `-MsalFactory`, vault, or private constructor. Two children + prove exact source identity, stable same result reference/token, and force refusal. Delete the + store in `finally`. +2. Distinct controlled tenant/source/key fixtures released together show no token, fingerprint, + proof, generation, or adoption crossover and perform no network call. +3. Two sources with one key show exact follower count, one acquisition, one adoption, identical + result reference/properties, and empty registry. +4. One ordinary and one forced flight for one tuple are simultaneously resident, receive exact + force flags, make two calls, never join, and do not contaminate unrelated cache state. +5. A legacy `GraphTokenSourceBase` rejects cross-runspace before entering or waiting on a flight; + label this compatibility containment. + +A controlled C# sender fixture may implement the default-context interface for observations, but +cannot replace the real public fixed-bearer case or production-source xUnit matrix. + +- [x] **Step 6: Prove lifecycle by composition and collect the packaged ALC** + +Remove unused private `_drained`, `_shutdownCompleted`, and their dead Reset/Set calls from +`GraphAuthHost`. Assert those fields absent while the literal public ABI and retained Task 3 +shutdown, reentrant cancellation, sanitized failure, clearing, and weak-reference gates stay green. + +Do not add production marker hooks. Prove: + +- provider xUnit: Certificate and ClientSecret cancellation/drain/material order; +- retained Task 3: host/proxy shutdown and unload; +- actual module registration by reference as `[real host, real source1, real source2]`; +- generic module cleanup with test-only marker disposables registered as `[host, source1, source2]` + and disposed exactly once as `[source2, source1, host]`; and +- sender/module integration: cancellation and source drain precede host cleanup, + `CleanupDone.Wait(5000)` succeeds, active operations are zero, owned resources are empty, and no + duplicate disposal occurs. + +In an isolated bounded thread job, import the package and create the real synthetic fixed-bearer +context. Capture the source, lifecycle state, and host `LoadContextWeakReference`. Finish/clean all +child modules/jobs, remove the owning module, require cleanup complete, zero active operations, and +an empty owned-resource collection. + +While retaining the exact source, call `Acquire` and require `ObjectDisposedException`. Only then +clear source, context, module, host, state, holder, queues, closures, AppDomain data, and every other +strong reference. Run a finite GC/finalizer loop and require the provider ALC weak reference dead. + +- [x] **Step 7: Run exact focused and complete gates** + +Pack before any Pester import. Run locked .NET restore/build/test and parse TRX to require exactly +`48 + D` passed cases and every other outcome zero. The build's existing `>=48` check is not this +authority. + +Run repository-pinned Pester 6.1.0 serially over: + +```text +tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 +tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 +tests/Concurrency/TokenIsolation.Tests.ps1 +tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 +tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +tests/Unit/Auth/GraphKitAuth.Tests.ps1 +``` + +Require exact `95 + F` with zero failure, skip, NotRun, inconclusive, failed blocks, or failed +containers. Repeat the frozen Task 6 owning and expanded projections at exact `246 + O` and +`440 + E`. + +The exact Task 6 owning projection is these seven files, with no implicit glob or helper-owned +addition: + +```text +tests/Unit/Auth/GraphKitAuth.Tests.ps1 +tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 +tests/Unit/Profiles/Get-GraphContext.Tests.ps1 +tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 +tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 +tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +tests/Adapter/Send-GraphHttpRequest.Tests.ps1 +``` + +The exact expanded projection is those seven plus these eight files, for 15 total: + +```text +tests/Unit/Auth/SecretManagementBoundary.Tests.ps1 +tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 +tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +tests/QA/GraphKitAuthPackage.tests.ps1 +tests/QA/BuiltModule.tests.ps1 +tests/QA/ReleaseProof.tests.ps1 +tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 +tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 +``` + +Record per-file counts for both projections before and after Task 7 so `O` and `E` are reproducible. + +Run `./build.ps1 -Tasks test`. Before commit, the inner Pester result must equal `1,180 + W`; the +outer tested-release recorder may refuse dirty authority and must be the only outer failure. After +commit, repeat clean and require the whole workflow, proof record, standalone no-rebuild verifier, +generated-output cleanup, and clean status green. + +Reject the task if scheduler duration is used as ordering evidence, a child reconstructs a source, +an automatic child host remains alive, generated output is tracked, public ABI changes, or external +access occurs. + +- [x] **Step 8: Commit, repeat on exact clean SHA, and report** + +Commit only the reviewed file set: + +```bash +git add docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md \ + tests/Fixtures/GraphKitAuthParityCases.json \ + src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs \ + src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj \ + src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs \ + src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs \ + source/Private/TokenSources/GraphTokenSource.ps1 \ + tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 \ + tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 \ + tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 \ + tests/Concurrency/TokenIsolation.Tests.ps1 \ + tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 \ + tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 \ + tests/Unit/Auth/GraphKitAuth.Tests.ps1 +git commit -m "test: prove GraphKit Auth parity and runspace isolation" +``` + +Repeat pack, exact TRX total, focused/owning/expanded Pester equality, complete test, release-proof +verification, generated-output checks, and clean status on the exact clean commit because the +prerelease identity changes with SHA. + +Write `.superpowers/sdd/2026-08-30-r8-graphkit-auth/task-7-report.md` outside the commit. Report the +matrix schema and 16 IDs, independent compiled/legacy results, D/F/O/E/W inventories and totals, +object-identity queue evidence, acquisition/adoption/waiter counts, ordinary/forced partitioning, +tenant isolation, certificate/secret phase order, actual registration order, probe LIFO order, +cleanup state, use-after-removal result, ALC result, ABI result, package/source identities, and +evidence limits. + +Task 7 makes no live MSAL, Graph, vault, tenant, IMDS, Azure, remote CI, merge, publication, or +service-behavior claim. + + +### Task 8: Prove protected live parity before transitive cutover + +**Files:** + +- Create: `scripts/Invoke-GraphKitAuthParity.ps1` +- Create: `scripts/private/Invoke-GraphKitAuthParityWorker.ps1` +- Create: `tests/QA/GraphKitAuthLiveParity.tests.ps1` +- Create: `source/Private/Operations/Assert-GraphOperationAuthMode.ps1` +- Modify: `source/Data/Operations/*.psd1` +- Modify: `source/Private/Operations/Import-GraphOperationDescriptor.ps1` +- Modify: `source/Public/Get-GraphObject.ps1` +- Modify: `source/Public/Invoke-GraphOperation.ps1` +- Modify: `source/Public/Invoke-GraphBatch.ps1` +- Modify: `source/Private/Confirm-GraphTenantBinding.ps1` +- Modify: `source/Private/Invoke-GraphPaging.ps1` +- Modify: `source/Private/Invoke-GraphRetry.ps1` +- Modify: `source/Private/Transport/Send-GraphHttpRequest.ps1` +- Modify: `source/Private/Wait-GraphThrottleGate.ps1` +- Modify: `tests/Adapter/TokenIdentityPipeline.Tests.ps1` +- Modify: `tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1` +- Modify: `tests/Unit/Operations/DescriptorInvariants.Tests.ps1` +- Modify: `tests/Unit/Operations/Get-GraphObject.Tests.ps1` +- Modify: `tests/Unit/Operations/Import-GraphOperationDescriptor.Tests.ps1` +- Modify: `tests/Unit/Pipeline/Invoke-GraphBatch.Tests.ps1` +- Modify: `tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1` +- Modify: `tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1` +- Modify: `tests/Unit/Throttle/ThrottleGate.Tests.ps1` +- Modify: `tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1` +- Modify: `docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md` +- Modify after separately authorized observed results: + `docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md` + +Current Task 8 authority is deterministic only. Do not run live mode, access or change a +credential/profile/vault, contact Graph or a tenant, change a permission, create/delete Azure +resources, use external network access, or push, open/merge a PR, merge, or publish without +separate explicit authority. The ignored controller record +`.superpowers/sdd/2026-08-30-r8-graphkit-auth/progress.md` remains outside every commit. + +- [x] **Step 0: Close deterministic prerequisites exposed by the parity red phase** + +The protected BearerToken read is not a valid parity proof unless the descriptor catalog and every +descriptor-backed public entry point actually allow that mode. Normalize `SupportedAuthModes` to +the four implemented public modes, reject empty, unknown, non-string, or case-insensitive duplicate +values at descriptor import, and fail closed before URI construction or transport in +`Get-GraphObject`, descriptor-mode `Invoke-GraphOperation`, and descriptor-backed +`Invoke-GraphBatch`. Preserve the explicit `Provider`-context exemption and raw-mode compatibility. + +A successful safe read is not protected-live evidence unless its tenant proof is the proof returned +by the transport for that same token. Require tenant proof for descriptors whose +`IdentityRequirement` is `Verified`; reject blank or ambiguous token identity before caching; +preserve cloud, client, fingerprint, generation, actual tenant, and proof provenance through every +page; and enforce the caller's one inherited deadline across admission, acquisition, nested proof, +retry delay, paging, and the final target send. Cancellation wins when caller cancellation and +deadline expiry coincide. No row may be retained and no target request may be sent after proof, +identity, cancellation, or deadline certainty is lost. + +Write focused red tests for the catalog and every public execution path, Provider/raw exemptions, +cache-key collisions, proof scope, verified paged provenance, cached-proof deadline expiry, +acquisition/proof boundary expiry, admission and retry-delay clamping, and cancellation forwarding. +Require independent static review of both prerequisite tranches before the first coherent pack. +Land the reviewed prerequisite repair as its own commit before the runner commit so the artifact +lineage records why previously inert descriptor metadata and unpropagated read proof changed. + +Stage that prerequisite commit only from this reviewed literal set: + +```text +docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +source/Data/Operations/AndroidEnrollmentProfile.List.psd1 +source/Data/Operations/AndroidManagedStoreAccountEnterpriseSettings.Get.psd1 +source/Data/Operations/AppConfigurationPolicy.List.psd1 +source/Data/Operations/AppInstallSummaryReport.Get.psd1 +source/Data/Operations/AppProtectionPolicy.List.psd1 +source/Data/Operations/AppleEnrollmentProgramToken.List.psd1 +source/Data/Operations/ApplePushNotificationCertificate.Get.psd1 +source/Data/Operations/AppleVppToken.List.psd1 +source/Data/Operations/AuthenticationMethodsPolicy.Get.psd1 +source/Data/Operations/AuthorizationPolicy.Get.psd1 +source/Data/Operations/AutopilotDevice.List.psd1 +source/Data/Operations/CertificateConnector.List.psd1 +source/Data/Operations/ConditionalAccessPolicy.List.psd1 +source/Data/Operations/ConfigurationConflict.List.psd1 +source/Data/Operations/ConfigurationPolicy.AssignBeta.psd1 +source/Data/Operations/ConfigurationPolicy.ListBeta.psd1 +source/Data/Operations/ConfigurationPolicyAssignment.ListBeta.psd1 +source/Data/Operations/ConfigurationPolicySetting.ListBeta.psd1 +source/Data/Operations/ConfigurationSettingDefinition.ListBeta.psd1 +source/Data/Operations/CrossTenantAccessPolicy.GetDefault.psd1 +source/Data/Operations/DeviceCategory.List.psd1 +source/Data/Operations/DeviceCategory.ListBeta.psd1 +source/Data/Operations/DeviceCompliancePolicy.Assign.psd1 +source/Data/Operations/DeviceCompliancePolicy.List.psd1 +source/Data/Operations/DeviceCompliancePolicy.ListBeta.psd1 +source/Data/Operations/DeviceCompliancePolicyAssignment.List.psd1 +source/Data/Operations/DeviceConfiguration.Assign.psd1 +source/Data/Operations/DeviceConfiguration.List.psd1 +source/Data/Operations/DeviceConfiguration.ListBeta.psd1 +source/Data/Operations/DeviceConfigurationAssignment.List.psd1 +source/Data/Operations/DeviceEnrollmentConfiguration.List.psd1 +source/Data/Operations/DeviceEnrollmentConfiguration.ListBeta.psd1 +source/Data/Operations/DeviceManagementConfigurationPolicyTemplate.ListBeta.psd1 +source/Data/Operations/DeviceManagementIntent.ListBeta.psd1 +source/Data/Operations/DeviceManagementRoleAssignment.List.psd1 +source/Data/Operations/DeviceManagementRoleDefinition.List.psd1 +source/Data/Operations/DeviceManagementScript.List.psd1 +source/Data/Operations/DeviceManagementTemplate.ListBeta.psd1 +source/Data/Operations/DeviceManagementUnifiedRoleAssignment.ListBeta.psd1 +source/Data/Operations/DeviceReport.Export.psd1 +source/Data/Operations/DirectoryRoleAssignment.List.psd1 +source/Data/Operations/DirectoryRoleDefinition.List.psd1 +source/Data/Operations/DirectoryRoleDefinition.ListBeta.psd1 +source/Data/Operations/DirectorySetting.List.psd1 +source/Data/Operations/DirectorySettingTemplate.List.psd1 +source/Data/Operations/Domain.List.psd1 +source/Data/Operations/DomainConnector.List.psd1 +source/Data/Operations/EntraDevice.List.psd1 +source/Data/Operations/EntraDevice.ListBeta.psd1 +source/Data/Operations/Group.Get.psd1 +source/Data/Operations/Group.List.psd1 +source/Data/Operations/Group.ListBeta.psd1 +source/Data/Operations/GroupMember.List.psd1 +source/Data/Operations/GroupPolicyConfiguration.ListBeta.psd1 +source/Data/Operations/GroupPolicyDefinitionValue.ListBeta.psd1 +source/Data/Operations/GroupPolicyPresentationValue.ListBeta.psd1 +source/Data/Operations/IntuneBrandingProfile.List.psd1 +source/Data/Operations/ManagedDevice.Delete.psd1 +source/Data/Operations/ManagedDevice.Get.psd1 +source/Data/Operations/ManagedDevice.List.psd1 +source/Data/Operations/ManagedDevice.ListBeta.psd1 +source/Data/Operations/ManagedDevice.Retire.psd1 +source/Data/Operations/ManagedDevice.SyncDevice.psd1 +source/Data/Operations/ManagedDevice.Wipe.psd1 +source/Data/Operations/ManagedDeviceCleanupRule.ListBeta.psd1 +source/Data/Operations/ManagedDeviceSetting.Get.psd1 +source/Data/Operations/MobileApp.Assign.psd1 +source/Data/Operations/MobileApp.List.psd1 +source/Data/Operations/MobileApp.ListBeta.psd1 +source/Data/Operations/MobileAppAssignment.List.psd1 +source/Data/Operations/MobileAppCategory.List.psd1 +source/Data/Operations/MobileThreatDefenseConnector.List.psd1 +source/Data/Operations/NamedLocation.List.psd1 +source/Data/Operations/OperationApprovalPolicy.List.psd1 +source/Data/Operations/Organization.GetMdmAuthority.psd1 +source/Data/Operations/Organization.List.psd1 +source/Data/Operations/Organization.ListBeta.psd1 +source/Data/Operations/RoleAssignmentScheduleInstance.List.psd1 +source/Data/Operations/RoleEligibilityScheduleInstance.List.psd1 +source/Data/Operations/SecurityDefaultsPolicy.Get.psd1 +source/Data/Operations/ServicePrincipal.List.psd1 +source/Data/Operations/SubscribedSku.List.psd1 +source/Data/Operations/User.List.psd1 +source/Data/Operations/WindowsAutopilotDeploymentProfile.List.psd1 +source/Data/Operations/WindowsFeatureUpdateProfile.List.psd1 +source/Data/Operations/WindowsUpdateCatalogItem.List.psd1 +source/Private/Confirm-GraphTenantBinding.ps1 +source/Private/Invoke-GraphPaging.ps1 +source/Private/Invoke-GraphRetry.ps1 +source/Private/Operations/Assert-GraphOperationAuthMode.ps1 +source/Private/Operations/Import-GraphOperationDescriptor.ps1 +source/Private/Transport/Send-GraphHttpRequest.ps1 +source/Private/Wait-GraphThrottleGate.ps1 +source/Public/Get-GraphObject.ps1 +source/Public/Invoke-GraphBatch.ps1 +source/Public/Invoke-GraphOperation.ps1 +tests/Adapter/TokenIdentityPipeline.Tests.ps1 +tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +tests/Unit/Operations/DescriptorInvariants.Tests.ps1 +tests/Unit/Operations/Get-GraphObject.Tests.ps1 +tests/Unit/Operations/Import-GraphOperationDescriptor.Tests.ps1 +tests/Unit/Pipeline/Invoke-GraphBatch.Tests.ps1 +tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 +tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 +tests/Unit/Throttle/ThrottleGate.Tests.ps1 +tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 +``` + +- [x] **Step 1: Write and test a digest-bound protected runner** + +The public runner keeps its literal six-parameter contract, requires the exact package path and +SHA-256, and accepts one auth mode per invocation. The runner and its private worker are trusted +verifier code. The parent alone snapshots, extracts, seals, retains native identity/closure evidence, +starts the worker, validates its strict nonce- and request-hash-bound primitive JSON response, and +performs exact cleanup. The parent never imports the candidate manifest or loads +`GraphKit.Auth.Contracts`; the worker alone revalidates the sealed state, imports and diagnoses the +candidate, removes it, emits one bounded redacted frame, and exits. No environment-selected role, +scriptblock serialization, raw-stream test hook, or production worker override is accepted. + +The parent creates lifecycle ownership before start, withholds stdin until ownership is established, +drains bounded stdout/stderr concurrently under one operation clock plus a bounded teardown phase, +and authorizes cleanup only after root exit, OS-owner emptiness, and EOF on both pipes. Windows uses +an unnamed kill-on-close Job Object and proves its active-process count is zero. Unix starts the +trusted worker in a new session/process group and proves that group empty; this covers the worker and +descendants that remain in that group, while inherited stdout/stderr EOF is an additional escape +detector. This is lifecycle containment within the same-identity, non-adversarial verifier boundary, +not a hostile-process sandbox: a descendant that deliberately creates a new session/group and closes +both IPC streams is outside the claim. An escaped descendant that retains IPC makes exit +unconfirmed, preserves the sealed stage, and produces `CleanupFailed`. Tests pin normal and forced +exit, a grandchild retaining a staged DLL and stdout, a Unix `setsid` escape, permanently failing +lifecycle polls, malformed protocol frames, path rederivation, and two sequential imports in one +long-lived parent with no GraphKit assemblies retained there. + +Dry-run tests prove certificate, client-secret, managed-identity, and fixed-bearer routing without +reading a credential, calling Graph, granting a permission, or creating Azure resources. Real mode +emits only redacted counts, auth mode, adapter diagnostics, package digest, and success/failure state. + +- [x] **Step 2: Commit deterministic prerequisites and runner in sequence** + +First commit the reviewed prerequisite set above and repeat its focused and complete local gates on +that exact clean SHA. Then commit only +`docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md`, +`scripts/Invoke-GraphKitAuthParity.ps1`, +`scripts/private/Invoke-GraphKitAuthParityWorker.ps1`, and +`tests/QA/GraphKitAuthLiveParity.tests.ps1`. No observed-evidence file belongs in either commit. + +- [x] **Step 3: Pack/test and freeze the exact clean runner commit** + +Run the complete local gates with the transitive dependency still present but production contexts +already using the isolated provider. Pack, test, run canonical proof and the standalone no-rebuild +verifier on the exact clean runner commit; freeze that verified package outside every Clean/pack +root; record its source revision, full prerelease, package digest, and proof digest. All four DryRun +modes must pass against that one frozen copy. Do not rebuild after the freeze or between live modes. + +- [ ] **Step 4: Run Ivy24 parity only after separate explicit authority** + +Using the exact tested package, prove certificate, client-secret, and fixed-bearer acquisition plus +a safe read. Do not persist tokens, secret values, tenant IDs, client IDs, or response content in +repository evidence. + +- [ ] **Step 5: Provision a fresh managed-identity host only after separate explicit authority** + +Create the minimum throwaway Azure host and permission grant, install the same package digest, +perform the managed-identity read, record redacted evidence, and delete the host/resources. The +earlier legacy container run is not compiled-provider parity. + +- [ ] **Step 6: Commit only redacted observed evidence without rebuilding** + +After all four modes pass against one frozen artifact under separately authorized live execution, +commit only `docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md`. That docs-only evidence +commit is not the packaged source revision and must not trigger a rebuild or change the frozen +artifact. Do not proceed to dependency removal until all four applicable protected-live parity +modes pass. + +### Task 9: Remove transitive MSAL and run the final local gate + +**Files:** + +- Modify: `source/GraphKit.psd1` +- Delete: `source/Private/Assert-GraphMsalEnvironment.ps1` +- Modify: `source/Private/TokenSources/New-GraphMsalApplication.ps1` +- Modify: `tests/QA/ImportOrderMatrix.tests.ps1` +- Modify: `tests/QA/PackageDependencies.tests.ps1` +- Modify: `tests/Unit/Auth/MsalGuard.Tests.ps1` +- Modify: `scripts/Install-GraphKitPinned.ps1` +- Modify: every minimum-test ratchet location reported by `tests/QA/MinimumTestsRatchetSync.tests.ps1` +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `CHANGELOG.md` +- Modify: `docs/superpowers/specs/2026-08-14-graphkit-design.md` +- Modify: `docs/superpowers/specs/2026-08-19-graphkit-tenantpulse-product-program-design.md` +- Modify: `docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md` + +- [ ] **Step 1: Write red final dependency/import-order tests** + +Preload each available competing module in a fresh process, record the default-context MSAL +assembly/version/location before GraphKit import, create a compiled source, and assert: + +```powershell +$afterDefault.FullName | Should -Be $beforeDefault.FullName +$diagnostics.MsalVersion | Should -Be '4.82.1.0' +$diagnostics.MsalLoadContext | Should -Not -Be 'Default' +``` + +Clean package metadata must contain no `Microsoft.Graph.Authentication` dependency. + +- [ ] **Step 2: Remove the transitive runtime path** + +Remove the manifest dependency and import-time default-ALC guard. Retain legacy factory code only as +the documented `-MsalFactory` compatibility/test path; it may require a caller-supplied factory and +must not make production GraphKit depend on Graph Authentication. + +- [ ] **Step 3: Pack before the full test run** + +```powershell +./build.ps1 -Tasks pack +./build.ps1 -Tasks test +``` + +Expected: zero failed, errors, skips, and NotRun across Pester; zero .NET test failures. + +- [ ] **Step 4: Synchronize the measured ratchet and repeat** + +Update all six ratchet authorities to the actual full Pester total, then pack and run the full suite +again because ratchet files are source changes. + +- [ ] **Step 5: Verify exact artifact identity** + +Run the standalone whole-result gate and canonical proof verifier. Independently compare built +module, package entries, and proof records byte-for-byte. Require a clean-tree full prerelease, +source revision match, exactly one private MSAL 4.82.1, and no default-context copy. + +- [ ] **Step 6: Run clean-install smoke from empty module state** + +Install the exact local prerelease into an isolated `PSModulePath`, import in fresh PowerShell 7.4 +and 7.6 processes, create fixed-bearer and managed-identity contexts without a vault or Graph SDK, +and assert operation data/default views. + +- [ ] **Step 7: Reconcile claims** + +Document deterministic completion separately from protected live parity. State the compatibility +scope of `TokenProvider`/`MsalFactory`, the eager local vault read at context creation, exact SDK/MSAL +pins, and the immutable public `0.3.0` boundary. + +- [ ] **Step 8: Independent reviews and final local commit** + +Require code, silent-failure, type-design, package, and simplification reviews. If any edit results, +repeat pack/test/proof. Commit only the reviewed clean state. + +### Task 10: Exact-SHA CI and promotion boundary + +**Files:** + +- Modify only evidence ledgers/docs after observed results. + +- [ ] **Step 1: Push and require six exact-SHA jobs** + +Push the R8 branch, open/update one PR, and require Windows, Ubuntu, and macOS on PowerShell 7.4 and +7.6 for the exact final SHA. Do not treat an older green run as evidence. + +- [ ] **Step 2: Decide stable publication at the explicit approval gate** + +If TenantPulse/CI requires a stable GraphKit dependency, request publication authority for the +already-tested bytes. Publish no rebuilt artifact. Verify gallery hash and clean remote install +before changing TenantPulse's `RequiredVersion`. + +- [ ] **Step 3: Mark R8 complete only after every applicable gate** + +Until protected live parity and exact-SHA CI are observed, record R8 as implemented/deterministic +but not service-verified. If authority is withheld, retain the exact executable runbook and active +program status; do not convert readiness into completion. + +## Self-review record + +- Spec coverage: ABI, ALC isolation, four modes, compatibility seams, package identity, deterministic + parity, runspaces, lifecycle, dependency removal, clean install, CI, live proof, and publication + boundaries each map to an explicit task. +- Placeholder scan: no implementation step is deferred without an evidence gate; protected actions + name their authority boundary rather than claiming completion. +- Type consistency: every task uses `GraphKit.Auth.Contracts`, `GraphKit.Auth`, + `GraphTokenRequest`, `GraphTokenResult`, `GraphAuthException`, + `IGraphTokenSource`, `IGraphTokenSourceFactory`, and `GraphAuthHost` with the ABI-v1 shapes frozen + in the R8 design. diff --git a/docs/superpowers/specs/2026-08-14-graphkit-design.md b/docs/superpowers/specs/2026-08-14-graphkit-design.md index 7ebf287..a9e3b31 100644 --- a/docs/superpowers/specs/2026-08-14-graphkit-design.md +++ b/docs/superpowers/specs/2026-08-14-graphkit-design.md @@ -228,15 +228,17 @@ otherwise indistinguishable from a working configuration until a customer engage ### GraphKit.Auth — the end-state authentication boundary -**Status: much later. Not v1, not phase 1.** Recorded here so the interim above is understood as -a deliberate stopgap with a known exit, and so the `IGraphTokenSource` contract is designed to -accommodate it now rather than being retrofitted. +**Status correction, 2026-08-30: active R8 gate; absent from immutable `0.3.0`.** The interim +PowerShell source was subsequently proven unsafe when a parent-created source was invoked from a +child runspace: nested PowerShell-class acquisition can hang before its method guard executes. +Post-release development therefore rejects crossed legacy sources in the public sender before +single-flight or method dispatch. That is containment, not delivery of the contract below. -A small compiled adapter owns the MSAL boundary outright: +The required end state is a small compiled adapter that owns the MSAL boundary outright: -`GraphKit.Auth` is the much-later end-state boundary, not a v1 dependency. In v1, the transitive -MSAL delivery contract above remains in force; the isolated adapter below is recorded for the -future migration only. +`GraphKit.Auth` is the required end-state boundary. The transitive MSAL delivery contract remains +the immutable `0.3.0` behavior; a successor must not claim runspace-neutral contexts until the +isolated adapter below replaces the legacy PowerShell acquisition path. The end-state adapter: @@ -326,6 +328,12 @@ deadlines**, so a clock change mid-session cannot extend a five-minute budget. #### Contexts and concurrency +> **Implementation correction, 2026-08-30:** the paragraph below is the approved target contract, +> not a claim about the post-`0.3.0` legacy source. That source is same-runspace-only and fails fast +> at the sender if crossed. Creating a fresh child-runspace context can observe credential rotation +> and is not equivalent to passing one immutable context. `GraphKit.Auth` must restore and prove the +> target with real runspaces. + Because nothing is process-global, no connection coordinator, lease manager, or session generation is required. A context is an immutable value resolved before parallel work begins and passed into each runspace. Correctness comes from the absence of shared mutable identity state diff --git a/docs/superpowers/specs/2026-08-19-graphkit-tenantpulse-product-program-design.md b/docs/superpowers/specs/2026-08-19-graphkit-tenantpulse-product-program-design.md index 1dd1ed4..ee1fabc 100644 --- a/docs/superpowers/specs/2026-08-19-graphkit-tenantpulse-product-program-design.md +++ b/docs/superpowers/specs/2026-08-19-graphkit-tenantpulse-product-program-design.md @@ -5,6 +5,14 @@ **Scope:** GraphKit and TenantPulse, including the recorded deferred end state **Delivery model:** Vertical release trains +> **Scope amendment, 2026-09-01:** the owner confirmed that there are no installed users, +> legacy consumers, customer-tenant consumers, or repoint targets. R9 therefore has a split +> disposition: reusable app-registration provisioning and actual-grant verification remain +> applicable product work; adopter migration, customer repointing, rollback-window operation, +> legacy-layer retirement, and destructive directory cleanup are **NotApplicable**. Those actions +> must not be executed to manufacture program-completion evidence. A future identified adopter +> reopens the applicable cutover gates before that adopter can be claimed supported. + ## Summary GraphKit and TenantPulse form one product system with two deliberately separate responsibilities. @@ -16,9 +24,9 @@ The program completes three scopes back to back: 1. Complete the TenantPulse catalog and its consumer-facing coverage. 2. Complete the current GraphKit and TenantPulse product contracts, including privacy, scale, reliability, and verification debt. -3. Deliver the recorded deferred end state, including `GraphKit.Auth`, app-registration - provisioning, the IntuneHealthAutomation phase-6 cutover, and a separate Azure Resource - Manager provider for `TP.INT.0010`. +3. Deliver the applicable deferred end state: `GraphKit.Auth`, reusable app-registration + provisioning, and a separate Azure Resource Manager provider for `TP.INT.0010`. The scope + amendment above supersedes the adopter-specific IntuneHealthAutomation phase-6 cutover. Work ships as vertical release trains. A train adds producer support in GraphKit when needed, proves that support, consumes it in TenantPulse, proves the resulting behavior, and leaves both @@ -45,7 +53,10 @@ per-train requirement. - Never read, stage, commit, quote, inventory, or hand off `.env` contents. GraphKit's current `.env` ignore gap is an R0 source-hygiene defect. -## Current baseline +## Baseline at approval, 2026-08-19 + +The dated bullets below preserve what was known when this design was approved. The 2026-09-01 scope +amendment supersedes their R9 adopter-repoint, legacy-retirement, and destructive-cleanup posture. ### GraphKit @@ -430,6 +441,11 @@ does not turn all Detail or reason fields into empty strings. ### R8: GraphKit.Auth +**Current status:** active and incomplete. The post-`0.3.0` source rejects legacy PowerShell token +sources that cross runspaces because the nested class path can hang. Lifecycle and credential- +generation hardening are prerequisites, but that containment is not the compiled adapter and does +not satisfy this milestone. + - Define GraphKit-owned auth request and result types. - Build and package the isolated adapter reproducibly. - Implement certificate, client-secret, managed-identity, and fixed-bearer token sources behind @@ -440,15 +456,20 @@ does not turn all Detail or reason fields into empty strings. ### R9: Provisioning and IntuneHealthAutomation phase 6 -- Convert the proven standalone certificate app-registration flow into - `New-GraphAppRegistration` without removing the script before all callers migrate. -- Preserve role-grant verification that checks what the service actually granted. -- Package and install exact GraphKit dependencies on target hosts. -- Run same-session read-only customer repoint verification only after explicit approval. -- Keep a rollback window and previous pin until the new path is proven. -- Retire the legacy authentication layer only after approved customer verification. -- Purge deleted directory objects and rotate or revoke credentials only as explicit operator - actions. +**Current split disposition:** + +- **Applicable product work:** convert the proven standalone certificate app-registration flow + into reusable `New-GraphAppRegistration`; preserve verification of the roles the service actually + granted; cover the supported contract deterministically and with safe, explicitly authorized + Ivy24 proof. Clean-machine exact-package verification remains R11 work and does not require an + adopted target host. +- **NotApplicable while the owner-confirmed no-adopter state holds:** migration of existing callers, + installation or cutover on adopted hosts, customer-tenant repoint verification, rollback-window + operation, legacy-authentication retirement, and deleted-directory purge. Do not perform any of + these merely to produce completion evidence. +- If a future adopter is identified, reopen the relevant exact-package installation, read-only + repoint, rollback, and retirement gates before claiming that adopter supported. Destructive + cleanup remains a separately authorized operator action, never a proof-only gate. ### R10: ARM provider and TP.INT.0010 @@ -556,10 +577,11 @@ The program is complete when all of these statements are true: closed. - `GraphKit.Auth` replaces transitive MSAL delivery without changing TenantPulse's public contract. - The separate ARM provider supports `TP.INT.0010` without entering the Graph operation catalog. -- IntuneHealthAutomation's approved customer cutover and legacy authentication retirement are - complete. -- Approved destructive operator cleanup is complete. If required approval is withheld, the - program remains `Blocked`; an executable runbook does not make it `Complete`. +- Reusable R9 app-registration provisioning and actual-grant verification are complete and proven. +- Adopter-specific cutover, rollback, and legacy-authentication retirement remain **NotApplicable** + while the owner-confirmed no-adopter state holds. Any future adopter reopens those gates before + support can be claimed. +- Destructive directory cleanup is neither required nor permitted as program-completion proof. - Deterministic, CI, live, customer, and publication claims remain separately evidenced. ## Explicit non-goals diff --git a/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md b/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md new file mode 100644 index 0000000..729e678 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md @@ -0,0 +1,291 @@ +# GraphKit R8 compiled authentication boundary + +**Date:** 2026-08-30 + +**Status:** Approved by the active end-to-end product-program goal. Deterministic implementation is complete; protected live parity, exact-SHA CI, publication, and Task 9 removal of the transitive `Microsoft.Graph.Authentication` dependency remain approval-gated. + +**Scope:** GraphKit R8 only + +**Successor train:** `0.4.0-r8.g` + +## Problem + +The immutable public `0.3.0` package acquires tokens through PowerShell classes that bind late to +the `Microsoft.Identity.Client.dll` delivered as a private implementation detail of +`Microsoft.Graph.Authentication`. The post-release hardening branch rejects a parent-created +PowerShell token source when it reaches another runspace because invoking the nested PowerShell +class path there can hang. That is safe containment, but it does not satisfy the approved +immutable-context contract. + +R8 replaces the built-in certificate, client-secret, managed-identity, and fixed-bearer paths with +a compiled, runspace-neutral adapter. It does not change public command signatures or TenantPulse's +public contract. + +## Release identity + +Published `0.3.0` remains immutable. R8 uses base version `0.4.0` and a prerelease identity derived +from the exact source used to build it: + +```text +0.4.0-r8.g<12-lowercase-hex-commit> +``` + +A development build from a dirty tree adds a deterministic dirty-tree suffix: + +```text +0.4.0-r8.g<12-lowercase-hex-commit>.d<12-lowercase-hex-source-state-hash> +``` + +Only a clean-tree package may become release authority or cross a repository/machine boundary. +The tested-release proof records the full semantic version, source revision, clean/dirty state, +and package digest. No R8 build may create or publish changed bytes as `0.3.0`. + +The canonical source-state byte stream is version 4. In addition to the length-framed Git +HEAD/index/worktree fields, it explicitly frames the SHA-256 of the exact build-time source-capture +template and each opened file handle's native identity. The helper is compiled under a fresh, +unpredictable type identity on every invocation; that generated type name is deliberately excluded +from the canonical stream. Package-producing source entries are limited to 16 MiB each so capture +fails actionably before any unbounded near-`int.MaxValue` allocation. + +## Assembly boundary + +R8 ships two GraphKit-owned assemblies under `Assemblies/GraphKit.Auth/`: + +```text +GraphKit.Auth.Contracts.dll default AssemblyLoadContext +GraphKit.Auth.dll isolated collectible AssemblyLoadContext +GraphKit.Auth.deps.json isolated dependency resolver input +Microsoft.Identity.Client.dll exact 4.82.1, isolated only +Microsoft.IdentityModel.Abstractions.dll and the locked runtime closure +``` + +`GraphKit.Auth.Contracts.dll` has no NuGet dependency. PowerShell loads it through the built module +manifest before parsing the root module. It owns all DTOs, interfaces, the strict loader, default- +context source proxies, and host lifetime. The source manifest leaves `RequiredAssemblies` empty +because generated binaries are intentionally absent from `source/`; the post-build copy task adds +the contracts path to the built manifest only after the allowlisted DLL exists, then validates that +manifest with `Test-ModuleManifest`. + +`GraphKit.Auth.dll` references `Microsoft.Identity.Client` 4.82.1 and +`GraphKit.Auth.Contracts`. A named collectible `AssemblyLoadContext` loads the provider and its +locked dependency closure. When the provider requests `GraphKit.Auth.Contracts`, the load context +returns the already-loaded default-context contract assembly. This preserves CLR type identity +while keeping every MSAL assembly outside the default load context. + +No public member in `GraphKit.Auth.Contracts` or any cross-boundary interface may name an MSAL +type. Reflection QA enforces that constraint. + +## ABI version 1 + +The contract marker is the ordinal string `GraphKit.Auth.Abi/1`. + +```csharp +public enum GraphAuthMode +{ + Certificate, + ClientSecret, + ManagedIdentity, + BearerToken +} + +public abstract class GraphCredential { } + +public sealed class CertificateCredential : GraphCredential +{ + public X509Certificate2 Certificate { get; } + public bool OwnsMaterial { get; } +} + +public sealed class ClientSecretCredential : GraphCredential +{ + public SecureString Secret { get; } + public bool OwnsMaterial { get; } +} + +public sealed class ManagedIdentityCredential : GraphCredential +{ + public string? UserAssignedClientId { get; } +} + +public sealed class FixedBearerCredential : GraphCredential +{ + public string AccessToken { get; } +} +``` + +`GraphTokenRequest` is immutable after construction and contains the source-constant request +fields: + +- `Environment` +- `TenantId` (`Guid`) +- `Authority` (`Uri`) +- `Resource` (`Uri`) +- `ClientId` (`Guid?`) +- `AuthMode` +- `Credential` +- `CredentialGeneration` + +The existing `IGraphTokenSource.Acquire(bool forceRefresh, CancellationToken cancellation)` method +continues to carry the two per-call fields. This deliberately refines the earlier conceptual field +list: certificate/secret objects are transferred once when the source is created, not copied into a +second request object on every acquisition. There is no duplicate descriptor DTO. + +`GraphTokenResult` contains: + +- `AccessToken` +- `ExpiresOnUtc` +- `ReceivedOnUtc` +- `TokenType` +- `Scopes` +- `VerifiedTenantId` +- `TokenFingerprint` +- `CredentialGeneration` + +`ReceivedOnUtc` remains explicit because cache replacement needs acquisition order without parsing +the resource-owned JWT. `VerifiedTenantId` remains settable because the tenant-binding pipeline +records proof on the exact result that supplied the bearer. + +`GraphAuthException` is the only provider-failure exception permitted across the ALC. The isolated +provider catches every MSAL-derived exception before returning and creates a GraphKit-owned failure +with sanitized `Code`, `Category`, `Message`, `RetryAfter`, and `CorrelationId` fields. It never +assigns an MSAL exception as `InnerException`, stores an MSAL object in `Data`, or exposes an MSAL +stack/type name through another contract member. Cancellation remains `OperationCanceledException`, +a framework type shared by both contexts. + +`IGraphTokenSource : IDisposable` preserves the existing duck surface: + +```csharp +bool CanRefresh { get; } +string AuthMode { get; } +string Audience { get; } +string? ClientId { get; } +DateTimeOffset ExpiresOn { get; } +string? VerifiedTenantId { get; } +string CredentialGeneration { get; } +GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation); +void AdoptSharedResult(GraphTokenResult result, bool forceRefresh); +``` + +`IGraphTokenSourceFactory.Create(GraphTokenRequest)` is the only provider factory member +used across the ALC. The factory and every returned source implement contract-assembly interfaces. + +## Source and host lifetime + +`GraphAuthHost` owns one isolated load context per module import. It validates the contract marker, +factory type, provider assembly identity, exact MSAL version, load-context identity, and public +surface before accepting the provider. + +The contracts assembly itself is loaded in the default context and therefore follows normal CLR +first-load-wins identity. Module import validates `GraphKit.Auth.Abi/1` and the exact expected +contract assembly identity before using an already-loaded copy. An incompatible in-process +GraphKit.Auth ABI upgrade requires a fresh PowerShell process; GraphKit fails clearly rather than +casting across mismatched contract identities. + +The host returns default-context proxies around isolated sources. A proxy: + +- is runspace-neutral; +- forwards only ABI-v1 members; +- rejects use after disposal; +- participates in the existing GraphKit single-flight and tenant-proof pipeline; +- clears its inner-source reference during disposal; and +- unregisters itself from the host exactly once. + +The module lifecycle registers the host before registering sources. Existing LIFO cleanup therefore +disposes every source before the host. Host shutdown refuses new sources, cancels/drains active +acquisitions within the module cleanup deadline, disposes remaining sources, clears strong +`Assembly`, `Type`, factory, and load-context references, calls `Unload()`, and exposes a weak +reference for bounded unload verification. + +Certificates and secure strings carry explicit ownership. Persisted material is transferred to a +GraphKit-owned source and disposed exactly once. Caller-injected certificates remain caller-owned. +Fixed bearer strings cannot be zeroed in managed memory, so the source clears all references on +disposal and never logs or exports them. + +## Context construction and credential resolution + +The four built-in modes create compiled sources. Certificate and client-secret profiles resolve +vault material in the runspace that creates `GraphKit.Context`, validate the exact credential +generation there, and transfer only framework/GraphKit-owned types to the adapter. No PowerShell +credential-resolver scriptblock crosses a runspace or ALC boundary. + +Context construction still performs no token acquisition and no service call. For persisted +certificate or secret profiles it now performs the local vault read needed to make the resulting +context immutable and runspace-neutral. Managed identity and inline fixed bearer remain vault-free; +a vault-backed fixed bearer necessarily resolves its named vault value during context construction. + +PFX resolution remains one-read: the exact byte snapshot used to calculate the generation is the +snapshot imported into the owned `X509Certificate2`. Unversioned mutable selectors retain a +per-context nonce; versioned immutable selectors may share a process flight only when the version +API can actually resolve them. + +## Compatibility paths + +`Get-GraphContext -TokenProvider` remains public and behaves as the existing caller-owned, +same-runspace PowerShell compatibility path. It is not one of the four R8 parity modes and must not +be described as runspace-neutral. + +`Get-GraphContext -MsalFactory` remains an internal-test/public compatibility seam. Supplying it +selects the legacy same-runspace source so deterministic legacy-versus-compiled parity can be +measured without allowing an MSAL object to cross the isolated adapter. Its help text identifies +the scope. Removing or replacing either parameter requires a separate public-contract decision. + +The supported claim after R8 is precise: contexts created through the four built-in modes are +runspace-neutral; arbitrary PowerShell provider/factory scriptblocks are not. + +## Build and package + +The repository pins .NET SDK `10.0.400` in `global.json`, targets `net8.0`, commits NuGet lock files, +and restores with locked mode. The package is framework-dependent, RID-neutral, non-self-contained, +and does not contain PDBs, reference assemblies, native broker assets, or runtime-specific output. + +The build workflow is: + +```text +Clean + -> Build_GraphKitAuth + -> Build_Module_ModuleBuilder + -> Copy_GraphKitAuth_Into_BuiltModule + -> Build_NestedModules_ModuleBuilder + -> Create_changelog_release_output + -> package_module_nupkg +``` + +Generated binaries stay under `output/`; source directories never contain generated assemblies. +The copy task uses an explicit allowlist and fails on a missing or unexpected runtime file. It then +updates only the built `GraphKit.psd1` with +`RequiredAssemblies = @('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')` and runs +`Test-ModuleManifest`. The canonical release proof already hashes every built-module and package +entry; it is extended for the full prerelease version and source-revision identity. + +## Verification gates + +Deterministic gates must prove: + +- ABI marker and exact member shapes; +- no MSAL type crosses the contract surface; +- MSAL failures become GraphKit-owned exceptions with no MSAL inner exception, `Data` value, or + public member type; +- exact MSAL 4.82.1 loads only in the named isolated context; +- preloaded Az/Graph/PSResourceGet MSAL remains unchanged; +- fixed bearer cannot refresh; +- certificate, secret, managed identity, and bearer match the legacy deterministic contracts; +- force-refresh, cache adoption, cancellation, fingerprint, generation, and disposal semantics; +- one exact parent-created built-in context works in real child runspaces; +- two tenant contexts do not exchange tokens or tenant proof; +- same-key work shares one flight and a `401` refresh does not poison another context; +- source disposal precedes host disposal and the isolated context becomes collectible; +- a clean installed package imports with no `Microsoft.Graph.Authentication` dependency; and +- all Windows/macOS/Linux PowerShell 7.4/7.6 jobs pass on the exact SHA. + +Protected live parity is separate. Certificate, client secret, and fixed bearer require Ivy24 +proof using the exact tested prerelease. Managed identity requires a fresh Azure host because the +earlier container was deleted. The transitive dependency and legacy built-in implementation are +removed only after all applicable parity gates pass. Public publication remains approval-gated and +can use only already-tested bytes. + +## Rollback + +Before stable publication, rollback means returning consumers to immutable GraphKit `0.3.0` and +discarding the unpublished R8 prerelease. No profile schema migration is required. The legacy +PowerShell implementation remains in source until protected parity passes, so an R8 prerelease can +be rebuilt with the compiled cutover disabled during development without changing persisted data. diff --git a/global.json b/global.json new file mode 100644 index 0000000..8aaa898 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 new file mode 100644 index 0000000..a0885d7 --- /dev/null +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -0,0 +1,264 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string] $RepositoryRoot, [switch] $AsObject) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +function Invoke-GraphKitGitBytes { + param([string] $Root, [string[]] $Arguments, [byte[]] $InputBytes = [byte[]] @(), [int[]] $AllowedExitCodes = @(0)) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = 'git'; $start.WorkingDirectory = $Root; $start.UseShellExecute = $false + $start.RedirectStandardInput = $true; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true + foreach ($argument in $Arguments) { $null = $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::new(); $process.StartInfo = $start; $null = $process.Start() + $standardErrorTask = $process.StandardError.ReadToEndAsync() + $output = [IO.MemoryStream]::new() + $standardOutputTask = $process.StandardOutput.BaseStream.CopyToAsync($output) + try { + if ($InputBytes.Length) { + $standardInputTask = $process.StandardInput.BaseStream.WriteAsync( + $InputBytes, 0, $InputBytes.Length) + $null = $standardInputTask.GetAwaiter().GetResult() + } + } + finally { + $process.StandardInput.Close() + } + $null = $standardOutputTask.GetAwaiter().GetResult() + $standardError = $standardErrorTask.GetAwaiter().GetResult() + $process.WaitForExit() + if ($process.ExitCode -notin $AllowedExitCodes) { throw "git $($Arguments -join ' ') failed: $standardError" } + return ,$output.ToArray() +} + +function Test-GraphKitBytesEqual { param([byte[]] $Left, [byte[]] $Right) + if ($Left.Length -ne $Right.Length) { return $false } + for ($i = 0; $i -lt $Left.Length; $i++) { if ($Left[$i] -ne $Right[$i]) { return $false } } + return $true +} + +function Get-GraphKitNulRecords { param([byte[]] $Bytes, [string] $Source) + $records = [Collections.Generic.List[byte[]]]::new(); $offset = 0 + while ($offset -lt $Bytes.Length) { + $end = [Array]::IndexOf($Bytes, [byte] 0, $offset) + if ($end -lt 0) { throw "$Source returned an unterminated NUL record." } + if ($end -eq $offset) { throw "$Source returned an empty record." } + $record = [byte[]]::new($end - $offset); [Array]::Copy($Bytes, $offset, $record, 0, $record.Length) + $records.Add($record); $offset = $end + 1 + } + return [pscustomobject] @{ records = @($records) } +} + +function Get-GraphKitRecordParts { param([byte[]] $Record, [string] $Source) + $tab = [Array]::IndexOf($Record, [byte] 9) + if ($tab -lt 1 -or $tab -eq $Record.Length - 1) { throw "$Source returned a malformed record." } + $path = [byte[]]::new($Record.Length - $tab - 1); [Array]::Copy($Record, $tab + 1, $path, 0, $path.Length) + [pscustomobject] @{ header = [Text.Encoding]::ASCII.GetString($Record, 0, $tab); path = $path } +} + +function Add-GraphKitMapEntry { param($Map, [byte[]] $Path, $Entry, [string] $Source) + $key = [Convert]::ToHexString($Path) + if (-not $Map.TryAdd($key, $Entry)) { throw "$Source reported duplicate source path bytes." } +} + +function ConvertFrom-GraphKitTree { param([byte[]] $Bytes, [int] $ObjectIdLength) + $map = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($record in (Get-GraphKitNulRecords $Bytes 'git ls-tree').records) { + $part = Get-GraphKitRecordParts $record 'git ls-tree' + if ($part.header -cnotmatch "^(?[0-7]{6}) (?blob|commit) (?[0-9a-f]{$ObjectIdLength})$") { throw 'git ls-tree returned an unsupported entry header or invalid object identity.' } + if ($Matches.type -eq 'commit') { throw 'Git HEAD contains an unsupported gitlink/submodule entry.' } + if ($Matches.mode -notin @('100644', '100755')) { throw "Git HEAD contains unsupported mode '$($Matches.mode)'." } + Add-GraphKitMapEntry $map $part.path ([pscustomobject] @{ path=$part.path; mode=$Matches.mode; type=$Matches.type; object=$Matches.object }) 'git ls-tree' + } + return $map +} + +function ConvertFrom-GraphKitIndex { param([byte[]] $Bytes, [int] $ObjectIdLength) + $map = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($record in (Get-GraphKitNulRecords $Bytes 'git ls-files --stage').records) { + $part = Get-GraphKitRecordParts $record 'git ls-files --stage' + if ($part.header -cnotmatch "^(?[0-7]{6}) (?[0-9a-f]{$ObjectIdLength}) (?[0-3])$") { throw 'git ls-files --stage returned an unsupported entry header or invalid object identity.' } + if ($Matches.stage -ne '0') { throw 'Git index contains an unmerged source entry.' } + if ($Matches.mode -eq '160000') { throw 'Git index contains an unsupported gitlink/submodule entry.' } + if ($Matches.mode -notin @('100644', '100755')) { throw "Git index contains unsupported mode '$($Matches.mode)'." } + Add-GraphKitMapEntry $map $part.path ([pscustomobject] @{ path=$part.path; mode=$Matches.mode; type='blob'; object=$Matches.object }) 'git ls-files --stage' + } + return $map +} + +function Get-GraphKitRelativePath { param([byte[]] $RawPath, [Text.UTF8Encoding] $Utf8) + try { $path = $Utf8.GetString($RawPath) } catch { throw 'Git reported a non-strict-UTF-8 source path.' } + if ([string]::IsNullOrEmpty($path) -or [IO.Path]::IsPathRooted($path) -or @($path -split '[\\/]' | Where-Object { $_ -in @('', '.', '..') }).Count) { throw 'Git reported an unsafe source path.' } + return $path +} + +function Resolve-GraphKitPhysicalPath { param([string] $Path) + $pathComparer = if ($IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal } + $visitedLinks = [Collections.Generic.HashSet[string]]::new($pathComparer) + + function Resolve-GraphKitExistingPathComponents { param([string] $FullPath, $VisitedLinks) + $fullPath = [IO.Path]::GetFullPath($FullPath) + $root = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrEmpty($root)) { throw "Cannot resolve physical path '$FullPath' without a filesystem root." } + $separators = [char[]] @([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + $segments = $fullPath.Substring($root.Length).Split($separators, [StringSplitOptions]::RemoveEmptyEntries) + $current = $root + foreach ($segment in $segments) { + $candidate = [IO.Path]::Combine($current, $segment) + $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop + $target = $item.ResolveLinkTarget($true) + if ($null -ne $target) { + $linkPath = [IO.Path]::GetFullPath($item.FullName) + if (-not $VisitedLinks.Add($linkPath)) { throw "Filesystem link cycle detected while resolving '$Path'." } + $current = Resolve-GraphKitExistingPathComponents ([IO.Path]::GetFullPath($target.FullName)) $VisitedLinks + } + else { + $current = [IO.Path]::GetFullPath($item.FullName) + } + } + return $current + } + + return Resolve-GraphKitExistingPathComponents ([IO.Path]::GetFullPath($Path)) $visitedLinks +} + +function Get-GraphKitProofBoundHelperInventoryPath { param([string] $Root, [string] $Helper, [Text.UTF8Encoding] $Utf8) + $rootPath = Resolve-GraphKitPhysicalPath $Root + $helperPath = Resolve-GraphKitPhysicalPath $Helper + $relative = [IO.Path]::GetRelativePath($rootPath, $helperPath) + if ([IO.Path]::IsPathRooted($relative) -or + $relative -eq '..' -or + $relative.StartsWith("..$([IO.Path]::DirectorySeparatorChar)", [StringComparison]::Ordinal) -or + $relative.StartsWith("..$([IO.Path]::AltDirectorySeparatorChar)", [StringComparison]::Ordinal)) { + return $null + } + $gitPath = $relative.Replace([IO.Path]::DirectorySeparatorChar, '/').Replace([IO.Path]::AltDirectorySeparatorChar, '/') + return ,$Utf8.GetBytes($gitPath) +} + +function Initialize-GraphKitSourceCapture { + $helper = Join-Path $PSScriptRoot 'private/GraphKit.SourceCapture.cs' + if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { throw "The GraphKit source-capture helper is missing at '$helper'." } + $helperBytes = [IO.File]::ReadAllBytes($helper) + $helperHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($helperBytes)).ToLowerInvariant() + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + try { $template = $strictUtf8.GetString($helperBytes) } + catch { throw "The GraphKit source-capture helper '$helper' is not strict UTF-8." } + $marker = '__GRAPHKIT_SOURCE_CAPTURE_NAMESPACE__' + if (($template.Split([string[]] @($marker), [StringSplitOptions]::None).Length - 1) -ne 1) { + throw "The GraphKit source-capture helper '$helper' must contain exactly one namespace identity marker." + } + $nonce = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(16)).ToLowerInvariant() + $namespace = "GraphKit.R8.Generated.H$helperHash.N$nonce" + $expectedTypeName = "$namespace.SourceCapture" + $collision = @([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ }) + if ($collision.Count) { throw "The generated GraphKit source-capture type identity '$expectedTypeName' already exists; refusing an ambient helper collision." } + $compiledTypes = @(Add-Type -TypeDefinition $template.Replace($marker, $namespace) -PassThru) + $captureTypes = @($compiledTypes | Where-Object FullName -CEQ $expectedTypeName) + if ($captureTypes.Count -ne 1) { throw "The proof-bound GraphKit source-capture helper did not return exactly one '$expectedTypeName' type." } + $loadedTypes = @([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ }) + if ($loadedTypes.Count -ne 1 -or -not [object]::ReferenceEquals($loadedTypes[0], $captureTypes[0])) { + throw "The generated GraphKit source-capture type identity '$expectedTypeName' collided during compilation; refusing ambient code." + } + [pscustomobject] @{ type = $captureTypes[0]; sourceBytes = $helperBytes; sourceSha256 = $helperHash; sourcePath = [IO.Path]::GetFullPath($helper) } +} + +function Get-GraphKitWorktreeEntry { param([string] $Root, [byte[]] $RawPath, [Text.UTF8Encoding] $Utf8, [AllowNull()][string] $IndexMode, [type] $CaptureType) + $relative = Get-GraphKitRelativePath $RawPath $Utf8 + try { $capture = $CaptureType::Capture($Root, $relative) } + catch { + $failure = if ($_.Exception.InnerException) { $_.Exception.InnerException } else { $_.Exception } + if ($failure -is [IO.FileNotFoundException] -or $failure.InnerException -is [IO.FileNotFoundException]) { + return [pscustomobject] @{ type='missing'; mode=''; identity=''; length=0; content=[byte[]] @() } + } + throw "Cannot root-anchored no-follow capture source entry '$relative': $($failure.Message)" + } + $mode = $CaptureType::ResolveEffectiveGitMode($capture.Mode, $capture.HasExecutableMode, $IndexMode) + return [pscustomobject] @{ type='regular'; mode=$mode; identity=$capture.Identity; length=$capture.Length; content=$capture.Content } +} + +function Assert-GraphKitRawPathSetUnambiguous { param($Records, [Text.UTF8Encoding] $Utf8) + $portable = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($record in $Records) { + $relative = Get-GraphKitRelativePath $record.path $Utf8 + $normalized = $relative.Normalize([Text.NormalizationForm]::FormC) + $rawKey = [Convert]::ToHexString($record.path) + $existing = '' + if ($portable.TryGetValue($normalized, [ref] $existing) -and $existing -cne $rawKey) { + throw "Git source paths collide by case or Unicode normalization at '$relative'; refusing an ambiguous package-source inventory." + } + $portable[$normalized] = $rawKey + } +} + +function Get-GraphKitBlobId { param([string] $Format, [byte[]] $Content) + $header = [Text.Encoding]::ASCII.GetBytes("blob $($Content.Length)`0"); $bytes = [byte[]]::new($header.Length + $Content.Length) + [Array]::Copy($header, 0, $bytes, 0, $header.Length); [Array]::Copy($Content, 0, $bytes, $header.Length, $Content.Length) + $hash = if ($Format -eq 'sha1') { [Security.Cryptography.SHA1]::HashData($bytes) } elseif ($Format -eq 'sha256') { [Security.Cryptography.SHA256]::HashData($bytes) } else { throw "Unsupported Git object format '$Format'." } + return [Convert]::ToHexString($hash).ToLowerInvariant() +} + +function Get-GraphKitFilesystemExtras { param([string] $Root, $Known, [Text.UTF8Encoding] $Utf8) + $found = [Collections.Generic.List[byte[]]]::new(); $directories = [Collections.Generic.Stack[string]]::new(); $directories.Push($Root) + while ($directories.Count) { foreach ($fullPath in [IO.Directory]::EnumerateFileSystemEntries($directories.Pop())) { + $relative = $fullPath.Substring($Root.Length).TrimStart([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + if ($relative -eq '.git' -or $relative.StartsWith(".git$([IO.Path]::DirectorySeparatorChar)", [StringComparison]::Ordinal)) { continue } + $raw = $Utf8.GetBytes(($relative -replace '\\', '/')); $item = Get-Item -LiteralPath $fullPath -Force + if ($item -is [IO.DirectoryInfo] -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { $directories.Push($fullPath) } elseif (-not $Known.Contains([Convert]::ToHexString($raw))) { $found.Add($raw) } + } } + $stream = [IO.MemoryStream]::new(); foreach ($raw in @($found | Sort-Object { [Convert]::ToHexString($_) })) { $stream.Write($raw,0,$raw.Length); $stream.WriteByte(0) }; return ,$stream.ToArray() +} + +function Get-GraphKitObjectFormat { param([byte[]] $Bytes) + $format = [Text.Encoding]::ASCII.GetString($Bytes).Trim().ToLowerInvariant() + if ($format -eq 'sha256') { throw 'Git SHA-256 object-format repositories are not supported by the GraphKit R8 release-identity proof. Use a SHA-1 clone for package production.' } + if ($format -ne 'sha1') { throw "Unsupported Git object format '$format'." } + return [pscustomobject] @{ name='sha1'; objectIdLength=40 } +} + +function Get-GraphKitCommitOid { param([byte[]] $Bytes, [int] $ObjectIdLength) + $oid = [Text.Encoding]::ASCII.GetString($Bytes).Trim() + if ($oid -cnotmatch "^[0-9a-f]{$ObjectIdLength}$") { throw "Git returned an invalid $ObjectIdLength-character HEAD commit object identity." } + return $oid +} + +function Get-GraphKitInventory { param([string] $Root, [Text.UTF8Encoding] $Utf8) + $formatBytes = Invoke-GraphKitGitBytes $Root @('rev-parse','--show-object-format') + $format = Get-GraphKitObjectFormat $formatBytes + $headOidBytes = Invoke-GraphKitGitBytes $Root @('rev-parse','--verify','HEAD^{commit}') + $headOid = Get-GraphKitCommitOid $headOidBytes $format.objectIdLength + $headBytes = Invoke-GraphKitGitBytes $Root @('ls-tree','-r','-z',$headOid) + $indexBytes = Invoke-GraphKitGitBytes $Root @('ls-files','--stage','-z') + $untrackedBytes = Invoke-GraphKitGitBytes $Root @('ls-files','--others','--exclude-standard','-z') + $head = ConvertFrom-GraphKitTree $headBytes $format.objectIdLength; $index = ConvertFrom-GraphKitIndex $indexBytes $format.objectIdLength + $known = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal); foreach ($key in $head.Keys) { $null=$known.Add($key) }; foreach ($key in $index.Keys) { $null=$known.Add($key) }; foreach ($path in (Get-GraphKitNulRecords $untrackedBytes 'git ls-files --others').records) { $null=$known.Add([Convert]::ToHexString($path)) } + $extras = (Get-GraphKitNulRecords (Get-GraphKitFilesystemExtras $Root $known $Utf8) 'filesystem inventory').records + if ($extras.Count) { + $input=[IO.MemoryStream]::new(); foreach($path in $extras){$input.Write($path,0,$path.Length);$input.WriteByte(0)} + $ignored = (Get-GraphKitNulRecords (Invoke-GraphKitGitBytes $Root @('check-ignore','-z','--stdin') $input.ToArray() @(0,1)) 'git check-ignore').records + $ignoredKeys=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal); foreach($path in $ignored){$null=$ignoredKeys.Add([Convert]::ToHexString($path))} + $joined=[IO.MemoryStream]::new();$joined.Write($untrackedBytes,0,$untrackedBytes.Length);foreach($path in $extras){if(-not $ignoredKeys.Contains([Convert]::ToHexString($path))){$joined.Write($path,0,$path.Length);$joined.WriteByte(0)}};$untrackedBytes=$joined.ToArray() + } + [pscustomobject] @{ formatBytes=$formatBytes; format=$format.name; objectIdLength=$format.objectIdLength; headOidBytes=$headOidBytes; headOid=$headOid; headBytes=$headBytes; indexBytes=$indexBytes; untrackedBytes=$untrackedBytes; head=$head; index=$index } +} + +function Get-GraphKitR8SourceState { param([string] $Root) + # v4: domain-separated, length-framed HEAD/index/worktree inventory. Git plumbing supplies + # raw paths; each entry binds HEAD/index mode/type/object and no-follow worktree + # mode/type/handle identity/bytes. The helper template bytes are proof-bound separately from + # its per-invocation unpredictable compiled type identity. + # Snapshots before/after reads and a second no-follow content read make source races fatal. + $utf8=[Text.UTF8Encoding]::new($false,$true); $captureHelper=Initialize-GraphKitSourceCapture; $captureType=$captureHelper.type;$helperPath=Get-GraphKitProofBoundHelperInventoryPath $Root $captureHelper.sourcePath $utf8 + $before=Get-GraphKitInventory $Root $utf8; $untracked=Get-GraphKitNulRecords $before.untrackedBytes 'git untracked inventory'; $records=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal) + foreach($entry in $before.head.Values){$records.Add([Convert]::ToHexString($entry.path),[pscustomobject]@{path=$entry.path;head=$entry;index=$null})};foreach($entry in $before.index.Values){$key=[Convert]::ToHexString($entry.path);if($records.ContainsKey($key)){$records[$key].index=$entry}else{$records.Add($key,[pscustomobject]@{path=$entry.path;head=$null;index=$entry})}};foreach($path in $untracked.records){$key=[Convert]::ToHexString($path);if($records.ContainsKey($key)){throw 'Git reported a duplicate path across tracked and untracked inventories.'};$records.Add($key,[pscustomobject]@{path=$path;head=$null;index=$null})} + Assert-GraphKitRawPathSetUnambiguous @($records.Values) $utf8 + $captured=[Collections.Generic.List[object]]::new();foreach($record in @($records.Values|Sort-Object{[Convert]::ToHexString($_.path)})){$indexMode=if($record.index){[string]$record.index.mode}else{$null};$worktree=Get-GraphKitWorktreeEntry $Root $record.path $utf8 $indexMode $captureType;if($worktree.type -eq 'missing' -and -not $record.head -and -not $record.index){throw 'A non-ignored untracked source entry disappeared during capture.'};$blob=if($record.index -and $worktree.type -eq 'regular' -and $worktree.mode -eq $record.index.mode){Get-GraphKitBlobId $before.format $worktree.content}else{''};$captured.Add([pscustomobject]@{path=$record.path;head=$record.head;index=$record.index;worktree=$worktree;blob=$blob})} + $after=Get-GraphKitInventory $Root $utf8;if(-not(Test-GraphKitBytesEqual $before.formatBytes $after.formatBytes) -or -not(Test-GraphKitBytesEqual $before.headOidBytes $after.headOidBytes) -or -not(Test-GraphKitBytesEqual $before.headBytes $after.headBytes) -or -not(Test-GraphKitBytesEqual $before.indexBytes $after.indexBytes) -or -not(Test-GraphKitBytesEqual $before.untrackedBytes $after.untrackedBytes)){throw 'Git HEAD commit or source inventory changed during capture; refusing to emit a train version.'} + foreach($entry in $captured){$indexMode=if($entry.index){[string]$entry.index.mode}else{$null};$again=Get-GraphKitWorktreeEntry $Root $entry.path $utf8 $indexMode $captureType;if($again.type -ne $entry.worktree.type -or $again.mode -ne $entry.worktree.mode -or $again.identity -ne $entry.worktree.identity -or $again.length -ne $entry.worktree.length -or -not(Test-GraphKitBytesEqual $again.content $entry.worktree.content)){throw 'Source entry changed during capture; refusing to emit a train version.'}} + if($null -ne $helperPath){$helperRecord=@($captured|Where-Object{Test-GraphKitBytesEqual $_.path $helperPath});if($helperRecord.Count -ne 1){throw "The proof-bound source-capture helper inside RepositoryRoot requires exactly one exact raw inventory record; found $($helperRecord.Count)."};if(-not(Test-GraphKitBytesEqual $helperRecord[0].worktree.content $captureHelper.sourceBytes)){throw 'The compiled source-capture helper bytes do not match the proof-bound package-source inventory.'}} + $clean=$untracked.records.Count -eq 0 -and $before.head.Count -eq $before.index.Count;foreach($entry in $captured){if(-not $entry.head -or -not $entry.index -or $entry.head.mode -ne $entry.index.mode -or $entry.head.type -ne $entry.index.type -or $entry.head.object -ne $entry.index.object -or $entry.worktree.type -ne 'regular' -or $entry.worktree.mode -ne $entry.index.mode -or $entry.blob -ne $entry.index.object){$clean=$false}} + $stream=[IO.MemoryStream]::new();$write={param([byte[]]$b)$stream.Write($b,0,$b.Length)};$field={param([string]$n,[byte[]]$b)&$write([Text.Encoding]::ASCII.GetBytes($n));&$write([BitConverter]::GetBytes([uint64]$b.Length));&$write $b};&$write([Text.Encoding]::ASCII.GetBytes('GraphKit-R8-source-entry-state-v4'));&$write([byte[]]@(0));&$field 'capture-helper-sha256' ([Text.Encoding]::ASCII.GetBytes([string]$captureHelper.sourceSha256));foreach($entry in $captured){&$write([Text.Encoding]::ASCII.GetBytes('entry'));&$field 'path' $entry.path;foreach($side in @('head','index')){$value=$entry.$side;if($null -eq $value){$mode='';$type='';$object=''}else{$mode=[string]$value.mode;$type=[string]$value.type;$object=[string]$value.object};&$field "$side-mode" ([Text.Encoding]::ASCII.GetBytes($mode));&$field "$side-type" ([Text.Encoding]::ASCII.GetBytes($type));&$field "$side-object" ([Text.Encoding]::ASCII.GetBytes($object))};&$field 'worktree-type' ([Text.Encoding]::ASCII.GetBytes([string]$entry.worktree.type));&$field 'worktree-mode' ([Text.Encoding]::ASCII.GetBytes([string]$entry.worktree.mode));&$field 'worktree-identity' ([Text.Encoding]::UTF8.GetBytes([string]$entry.worktree.identity));&$field 'worktree-content' $entry.worktree.content};&$write([Text.Encoding]::ASCII.GetBytes('end'));&$write([byte[]]@(0)) + [pscustomobject]@{revision=$before.headOid;clean=[bool]$clean;sha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant()} +} + +$RepositoryRoot=Resolve-GraphKitPhysicalPath (Resolve-Path -LiteralPath $RepositoryRoot).ProviderPath;$base='0.4.0';$train='r8';$state=Get-GraphKitR8SourceState $RepositoryRoot;$revision=$state.revision;$version="$base-$train.g$($revision.Substring(0,12))$(if($state.clean){''}else{".d$($state.sha256.Substring(0,12))"})";if($AsObject){[pscustomobject][ordered]@{version=$version;baseVersion=$base;train=$train;revision=$revision;clean=[bool]$state.clean;sourceStateSha256=$state.sha256}}else{$version} diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 new file mode 100644 index 0000000..9963a1b --- /dev/null +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -0,0 +1,3585 @@ +<# + Verification-only GraphKit.Auth protected parity runner. + + This script never provisions credentials, profiles, permissions, app registrations, modules, + repositories, Azure resources, or live infrastructure. Its only live behavior, when explicitly + invoked in the Live parameter set, is one existing-profile context resolution and one + ManagedDevice.List read through the exact supplied package. +#> +[CmdletBinding(DefaultParameterSetName = 'Live')] +param( + [Parameter(Mandatory)] [string] $PackagePath, + [Parameter(Mandatory)] [string] $PackageSha256, + [Parameter(Mandatory)] [string] $AuthMode, + [Parameter(Mandatory, ParameterSetName = 'Live')] [string] $ProfileId, + [Parameter(ParameterSetName = 'Live')] [string] $StorePath, + [Parameter(Mandatory, ParameterSetName = 'DryRun')] [switch] $DryRun +) + +Set-StrictMode -Version Latest + +$script:GraphKitAuthParityModes = @('Certificate','ClientSecret','ManagedIdentity','BearerToken') +$script:GraphKitAuthParityFailureStages = @( + 'None','Artifact','Import','Context','Acquisition','Read','Diagnostics','Cleanup','Evidence') +$script:GraphKitAuthParityFailureCodes = @( + 'None','ArtifactRejected','ImportRejected','ContextRejected','AcquisitionFailed','ReadFailed', + 'DiagnosticsRejected','CleanupFailed','EvidenceRejected') +$script:GraphKitAuthParityChecks = @( + 'packageDigestMatched','snapshotBound','archiveValidated','extractionSealed','exactImport', + 'routeMatched','contextMatched','sourceMatched','tenantProofVerified','cleanupVerified') +$script:GraphKitAuthParityAdapterChecks = @( + 'abiMarkerExact','contractsDefault','providerCollectibleNonDefault','msalVersionExact', + 'providerMsalSameContext','publicAbiExact') +$script:GraphKitAuthParityExpectedPublicAbiSha256 = + '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' +$script:GraphKitAuthParityExpectedNativeSourceSha256 = + 'c4132fbc857e8c96e741c6f0eda371f62ec59bd61c669573faaab18d998a3808' +$script:GraphKitAuthParityNativeType = $null +$script:GraphKitAuthParityProcessTreeType = $null +$script:GraphKitAuthParityMaxEntries = 4096 +$script:GraphKitAuthParityMaxPackageBytes = 512MB +$script:GraphKitAuthParityMaxEntryBytes = 64MB +$script:GraphKitAuthParityMaxTotalBytes = 256MB +$script:GraphKitAuthParityRatioThresholdBytes = 1MB +$script:GraphKitAuthParityMaxCompressionRatio = 200 +$script:GraphKitAuthParityMarkerName = '.graphkit-auth-parity-runner' +$script:GraphKitAuthParitySnapshotName = 'candidate.nupkg' +$script:GraphKitAuthParityModuleName = 'module' +$script:GraphKitAuthParityWorkerKind = 'GraphKit.Task8.ParityWorkerRequest/1' +$script:GraphKitAuthParityWorkerResultKind = 'GraphKit.Task8.ParityWorkerResult/1' +$script:GraphKitAuthParityMaxWorkerRequestBytes = 16MB +$script:GraphKitAuthParityMaxWorkerStreamBytes = 64KB + +function Get-GraphKitAuthParityAbiTypeDisplayName { + param([Parameter(Mandatory)][Type] $Type) + if ($Type.IsArray) { + return "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $Type.GetElementType())[]" + } + if ($Type.IsGenericType) { + $definition = $Type.GetGenericTypeDefinition().FullName + $definition = $definition.Substring(0, $definition.IndexOf('`')) + $arguments = @($Type.GetGenericArguments() | ForEach-Object { + Get-GraphKitAuthParityAbiTypeDisplayName -Type $_ + }) -join ',' + return "$definition<$arguments>" + } + return $Type.FullName +} + +function Get-GraphKitAuthParityAbiParameterDisplay { + param([Parameter(Mandatory)][Reflection.ParameterInfo] $Parameter) + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $Parameter.ParameterType) $($Parameter.Name)" +} + +function Get-GraphKitAuthParityAbiNullabilityDisplay { + param([Reflection.NullabilityInfo] $Info) + if ($null -eq $Info) { return '' } + + $display = "$($Info.ReadState)/$($Info.WriteState)" + if ($null -ne $Info.ElementType) { + $display += ";element=$(Get-GraphKitAuthParityAbiNullabilityDisplay -Info $Info.ElementType)" + } + if ($Info.GenericTypeArguments.Count -ne 0) { + $arguments = @($Info.GenericTypeArguments | ForEach-Object { + Get-GraphKitAuthParityAbiNullabilityDisplay -Info $_ + }) -join ',' + $display += ";arguments=[$arguments]" + } + return $display +} + +function Get-GraphKitAuthParityAbiModifierDisplay { + param([AllowEmptyCollection()][Type[]] $Modifiers) + $names = [string[]]@($Modifiers | ForEach-Object FullName) + [Array]::Sort($names, [StringComparer]::Ordinal) + return '[' + ($names -join ',') + ']' +} + +function Get-GraphKitAuthParityAbiCallableId { + param([Parameter(Mandatory)][Reflection.MethodBase] $Callable) + $parameters = @($Callable.GetParameters() | ForEach-Object { + Get-GraphKitAuthParityAbiTypeDisplayName -Type $_.ParameterType + }) -join ',' + $name = if ($Callable -is [Reflection.ConstructorInfo]) { '.ctor' } else { $Callable.Name } + return "$($Callable.DeclaringType.FullName)::$name($parameters)" +} + +function Get-GraphKitAuthParityAbiDefaultDisplay { + param([Parameter(Mandatory)][Reflection.ParameterInfo] $Parameter) + if (-not $Parameter.HasDefaultValue) { return '' } + if ($null -eq $Parameter.DefaultValue) { return '' } + if ($Parameter.DefaultValue -is [string]) { + return '"' + ([string]$Parameter.DefaultValue).Replace('"', '\"') + '"' + } + if ($Parameter.DefaultValue -is [char]) { + return "'$($Parameter.DefaultValue)'" + } + if ($Parameter.DefaultValue -is [bool]) { + return ([string]$Parameter.DefaultValue).ToLowerInvariant() + } + return [Convert]::ToString( + $Parameter.DefaultValue, + [Globalization.CultureInfo]::InvariantCulture) +} + +function Add-GraphKitAuthParityAbiParameterMetadata { + param( + [Parameter(Mandatory)][Collections.Generic.List[string]] $Lines, + [Parameter(Mandatory)][Reflection.NullabilityInfoContext] $NullabilityContext, + [Parameter(Mandatory)][string] $OwnerKind, + [Parameter(Mandatory)][string] $OwnerId, + [Parameter(Mandatory)][Reflection.ParameterInfo] $Parameter + ) + + $direction = if ($Parameter.IsOut) { + 'out' + } + elseif ($Parameter.ParameterType.IsByRef -and $Parameter.IsIn) { + 'in' + } + elseif ($Parameter.ParameterType.IsByRef) { + 'ref' + } + else { + 'value' + } + $isParams = $Parameter.IsDefined([ParamArrayAttribute], $false).ToString().ToLowerInvariant() + $isOptional = $Parameter.IsOptional.ToString().ToLowerInvariant() + $hasDefault = $Parameter.HasDefaultValue.ToString().ToLowerInvariant() + $requiredModifiers = Get-GraphKitAuthParityAbiModifierDisplay ` + -Modifiers $Parameter.GetRequiredCustomModifiers() + $optionalModifiers = Get-GraphKitAuthParityAbiModifierDisplay ` + -Modifiers $Parameter.GetOptionalCustomModifiers() + $nullability = Get-GraphKitAuthParityAbiNullabilityDisplay ` + -Info $NullabilityContext.Create($Parameter) + $Lines.Add( + "PARAMETER-META|$OwnerKind|$OwnerId|$($Parameter.Position)|$($Parameter.Name)|" + + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $Parameter.ParameterType)|direction=$direction|params=$isParams|" + + "optional=$isOptional|hasDefault=$hasDefault|default=$(Get-GraphKitAuthParityAbiDefaultDisplay -Parameter $Parameter)|" + + "requiredMods=$requiredModifiers|optionalMods=$optionalModifiers|nullable=$nullability") +} + +function Add-GraphKitAuthParityAbiGenericParameterMetadata { + param( + [Parameter(Mandatory)][Collections.Generic.List[string]] $Lines, + [Parameter(Mandatory)][string] $OwnerKind, + [Parameter(Mandatory)][string] $OwnerId, + [AllowEmptyCollection()][Type[]] $GenericParameters + ) + + foreach ($parameter in @($GenericParameters | Where-Object IsGenericParameter | + Sort-Object GenericParameterPosition)) { + $constraints = [string[]]@($parameter.GetGenericParameterConstraints() | + ForEach-Object { Get-GraphKitAuthParityAbiTypeDisplayName -Type $_ }) + [Array]::Sort($constraints, [StringComparer]::Ordinal) + $Lines.Add( + "GENERIC-PARAMETER|$OwnerKind|$OwnerId|$($parameter.GenericParameterPosition)|" + + "$($parameter.Name)|attributes=$($parameter.GenericParameterAttributes)|constraints=[$($constraints -join ',')]") + } +} + +function Get-GraphKitAuthParityPublicAbiRecords { + param([Parameter(Mandatory)][Reflection.Assembly] $Assembly) + + $lines = [Collections.Generic.List[string]]::new() + $flags = [Reflection.BindingFlags]'Public,Instance,Static,DeclaredOnly' + $nullabilityContext = [Reflection.NullabilityInfoContext]::new() + $exportedTypes = if ($Assembly.IsDynamic) { + @($Assembly.GetTypes() | Where-Object IsVisible) + } + else { + @($Assembly.GetExportedTypes()) + } + foreach ($type in @($exportedTypes | Sort-Object FullName)) { + $kind = if ($type.IsEnum) { + 'enum' + } + elseif ($type.IsInterface) { + 'interface' + } + elseif ($type.IsAbstract) { + 'abstract-class' + } + elseif ($type.IsSealed) { + 'sealed-class' + } + else { + 'class' + } + $baseType = if ($null -eq $type.BaseType) { + '' + } + else { + Get-GraphKitAuthParityAbiTypeDisplayName -Type $type.BaseType + } + $interfaces = [string[]]@($type.GetInterfaces() | ForEach-Object { + Get-GraphKitAuthParityAbiTypeDisplayName -Type $_ + }) + [Array]::Sort($interfaces, [StringComparer]::Ordinal) + $lines.Add("TYPE|$($type.FullName)|$kind|$baseType|$($interfaces -join ',')") + $isStaticType = ($type.IsAbstract -and $type.IsSealed -and + -not $type.IsEnum).ToString().ToLowerInvariant() + $enumUnderlying = if ($type.IsEnum) { + Get-GraphKitAuthParityAbiTypeDisplayName -Type ([Enum]::GetUnderlyingType($type)) + } + else { + '' + } + $genericParameters = @($type.GetGenericArguments() | Where-Object IsGenericParameter) + $lines.Add( + "TYPE-META|$($type.FullName)|staticType=$isStaticType|" + + "enumUnderlying=$enumUnderlying|genericArity=$($genericParameters.Count)") + Add-GraphKitAuthParityAbiGenericParameterMetadata -Lines $lines -OwnerKind TYPE ` + -OwnerId $type.FullName -GenericParameters $genericParameters + + if ($type.IsEnum) { + foreach ($name in [Enum]::GetNames($type)) { + $value = [Convert]::ToInt64([Enum]::Parse($type, $name)) + $lines.Add("ENUM|$($type.FullName)|$name=$value") + } + } + + foreach ($constructor in @($type.GetConstructors($flags) | + Sort-Object { $_.ToString() })) { + $parameters = @($constructor.GetParameters() | ForEach-Object { + Get-GraphKitAuthParityAbiParameterDisplay -Parameter $_ + }) -join ',' + $lines.Add("CTOR|$($type.FullName)|($parameters)") + $ownerId = Get-GraphKitAuthParityAbiCallableId -Callable $constructor + $lines.Add("MEMBER-META|CTOR|$ownerId|static=false|genericArity=0") + foreach ($parameter in $constructor.GetParameters()) { + Add-GraphKitAuthParityAbiParameterMetadata -Lines $lines ` + -NullabilityContext $nullabilityContext -OwnerKind CTOR ` + -OwnerId $ownerId -Parameter $parameter + } + } + + foreach ($property in @($type.GetProperties($flags) | Sort-Object Name)) { + $accessors = [Collections.Generic.List[string]]::new() + if ($null -ne $property.GetMethod -and $property.GetMethod.IsPublic) { + $accessors.Add('get') + } + if ($null -ne $property.SetMethod -and $property.SetMethod.IsPublic) { + $isInit = @($property.SetMethod.ReturnParameter.GetRequiredCustomModifiers() | + Where-Object FullName -eq 'System.Runtime.CompilerServices.IsExternalInit').Count -ne 0 + $accessors.Add($(if ($isInit) { 'init' } else { 'set' })) + } + $isRequired = @($property.GetCustomAttributesData() | + Where-Object AttributeType -EQ ( + [Runtime.CompilerServices.RequiredMemberAttribute])).Count -ne 0 + if ($isRequired) { $accessors.Add('required') } + $lines.Add( + "PROPERTY|$($type.FullName)|$($property.Name)|" + + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $property.PropertyType)|" + + "$($accessors -join ',')") + $propertyAccessor = if ($null -ne $property.GetGetMethod($true)) { + $property.GetGetMethod($true) + } + else { + $property.GetSetMethod($true) + } + $propertyIsStatic = $propertyAccessor.IsStatic.ToString().ToLowerInvariant() + $propertyNullability = Get-GraphKitAuthParityAbiNullabilityDisplay ` + -Info $nullabilityContext.Create($property) + $indexParameters = @($property.GetIndexParameters()) + $setter = $property.GetSetMethod($true) + $setterRequiredModifiers = if ($null -eq $setter) { + '' + } + else { + Get-GraphKitAuthParityAbiModifierDisplay ` + -Modifiers $setter.ReturnParameter.GetRequiredCustomModifiers() + } + $setterOptionalModifiers = if ($null -eq $setter) { + '' + } + else { + Get-GraphKitAuthParityAbiModifierDisplay ` + -Modifiers $setter.ReturnParameter.GetOptionalCustomModifiers() + } + $propertyOwnerId = "$($type.FullName)::$($property.Name)" + $lines.Add( + "PROPERTY-META|$propertyOwnerId|static=$propertyIsStatic|" + + "nullable=$propertyNullability|indexCount=$($indexParameters.Count)|" + + "setterRequiredMods=$setterRequiredModifiers|" + + "setterOptionalMods=$setterOptionalModifiers") + foreach ($parameter in $indexParameters) { + Add-GraphKitAuthParityAbiParameterMetadata -Lines $lines ` + -NullabilityContext $nullabilityContext -OwnerKind INDEX ` + -OwnerId $propertyOwnerId -Parameter $parameter + } + } + + foreach ($method in @($type.GetMethods($flags) | + Where-Object { -not $_.IsSpecialName -or $_.Name.StartsWith('op_') } | + Sort-Object Name, { $_.ToString() })) { + $parameters = @($method.GetParameters() | ForEach-Object { + Get-GraphKitAuthParityAbiParameterDisplay -Parameter $_ + }) -join ',' + $lines.Add( + "METHOD|$($type.FullName)|$($method.Name)|($parameters)->" + + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $method.ReturnType)") + $ownerId = Get-GraphKitAuthParityAbiCallableId -Callable $method + $methodGenericParameters = @($method.GetGenericArguments() | + Where-Object IsGenericParameter) + $lines.Add( + "MEMBER-META|METHOD|$ownerId|static=$($method.IsStatic.ToString().ToLowerInvariant())|" + + "genericArity=$($methodGenericParameters.Count)") + Add-GraphKitAuthParityAbiGenericParameterMetadata -Lines $lines ` + -OwnerKind METHOD -OwnerId $ownerId ` + -GenericParameters $methodGenericParameters + foreach ($parameter in $method.GetParameters()) { + Add-GraphKitAuthParityAbiParameterMetadata -Lines $lines ` + -NullabilityContext $nullabilityContext -OwnerKind METHOD ` + -OwnerId $ownerId -Parameter $parameter + } + $returnParameter = $method.ReturnParameter + $lines.Add( + "RETURN-META|METHOD|$ownerId|" + + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $method.ReturnType)|" + + "requiredMods=$(Get-GraphKitAuthParityAbiModifierDisplay -Modifiers $returnParameter.GetRequiredCustomModifiers())|" + + "optionalMods=$(Get-GraphKitAuthParityAbiModifierDisplay -Modifiers $returnParameter.GetOptionalCustomModifiers())|" + + "nullable=$(Get-GraphKitAuthParityAbiNullabilityDisplay -Info $nullabilityContext.Create($returnParameter))") + } + + foreach ($eventInfo in @($type.GetEvents($flags) | Sort-Object Name)) { + $accessors = [Collections.Generic.List[string]]::new() + if ($null -ne $eventInfo.AddMethod -and $eventInfo.AddMethod.IsPublic) { + $accessors.Add('add') + } + if ($null -ne $eventInfo.RemoveMethod -and $eventInfo.RemoveMethod.IsPublic) { + $accessors.Add('remove') + } + $lines.Add( + "EVENT|$($type.FullName)|$($eventInfo.Name)|" + + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $eventInfo.EventHandlerType)|" + + "$($accessors -join ',')") + $eventAccessor = if ($null -ne $eventInfo.AddMethod) { + $eventInfo.AddMethod + } + else { + $eventInfo.RemoveMethod + } + $lines.Add( + "EVENT-META|$($type.FullName)::$($eventInfo.Name)|" + + "static=$($eventAccessor.IsStatic.ToString().ToLowerInvariant())|" + + "nullable=$(Get-GraphKitAuthParityAbiNullabilityDisplay -Info $nullabilityContext.Create($eventInfo))") + } + + foreach ($field in @($type.GetFields($flags) | + Where-Object { -not $type.IsEnum } | Sort-Object Name)) { + $literal = if ($field.IsLiteral) { + [string]$field.GetRawConstantValue() + } + else { + '' + } + $lines.Add( + "FIELD|$($type.FullName)|$($field.Name)|" + + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $field.FieldType)|$literal") + $lines.Add( + "FIELD-META|$($type.FullName)::$($field.Name)|" + + "static=$($field.IsStatic.ToString().ToLowerInvariant())|" + + "nullable=$(Get-GraphKitAuthParityAbiNullabilityDisplay -Info $nullabilityContext.Create($field))") + } + } + + $records = [string[]]$lines.ToArray() + [Array]::Sort($records, [StringComparer]::Ordinal) + return $records +} + +function Get-GraphKitAuthParityPublicAbiSha256 { + param([Parameter(Mandatory)][Reflection.Assembly] $Assembly) + + $records = [string[]]@(Get-GraphKitAuthParityPublicAbiRecords -Assembly $Assembly) + $canonical = $records -join "`n" + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($canonical) + $hash = [Security.Cryptography.SHA256]::HashData($bytes) + return ([Convert]::ToHexString($hash)).ToLowerInvariant() +} + +function Test-GraphKitAuthParityContractsIdentity { + param([Parameter(Mandatory)][Reflection.AssemblyName] $Name) + + if ($Name.Name -cne 'GraphKit.Auth.Contracts' -or + -not $Name.Version.Equals([version]'1.0.0.0') -or + -not [string]::IsNullOrEmpty($Name.CultureName)) { + return $false + } + [byte[]]$publicKeyToken = $Name.GetPublicKeyToken() + return $null -eq $publicKeyToken -or $publicKeyToken.Count -eq 0 +} + +function Get-GraphKitAuthParityUtcText { + return [DateTime]::UtcNow.ToString( + "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", + [Globalization.CultureInfo]::InvariantCulture) +} + +function Test-GraphKitAuthParityExactProperties { + param( + [Parameter(Mandatory)] $Value, + [Parameter(Mandatory)][string[]] $Names + ) + if ($null -eq $Value) { return $false } + return (($Value.PSObject.Properties.Name -join '|') -ceq ($Names -join '|')) +} + +function Test-GraphKitAuthParityUtcText { + param([Parameter(Mandatory)][string] $Value) + $parsed = [DateTime]::MinValue + return [DateTime]::TryParseExact( + $Value, + "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal -bor + [Globalization.DateTimeStyles]::AdjustToUniversal, + [ref] $parsed) +} + +function Test-GraphKitAuthParityForbiddenString { + param([AllowNull()][string] $Value) + if ($null -eq $Value) { return $false } + return $Value -match '(?i)(?:\bBearer\s+|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.|' + + '\btokenFingerprint\b|\bcorrelationId\b|\bresponseBody\b|\bSystem\.[A-Za-z]+Exception\b|' + + '(?:^|\s)/Users/|(?:^|\s)/home/|[A-Za-z]:\\|task8-secret-sentinel|' + + '\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b)' +} + +function New-GraphKitAuthParityRoute { + param([Parameter(Mandatory)][string] $Mode) + if ($Mode -cnotin $script:GraphKitAuthParityModes) { + throw [InvalidOperationException]::new('The protected parity route is not allowlisted.') + } + return [pscustomobject][ordered]@{ + AuthMode = $Mode + CanRefresh = $Mode -cne 'BearerToken' + UsesVault = $Mode -cin @('Certificate','ClientSecret','BearerToken') + OperationType = 'ManagedDevice' + Operation = 'List' + OperationId = 'ManagedDevice.List' + UsesImds = $Mode -ceq 'ManagedIdentity' + } +} + +function Assert-GraphKitAuthParitySourceBound { + param([Parameter(Mandatory)] $Evidence) + if ($null -eq $Evidence.PSObject.Properties['Length'] -or + [long]$Evidence.Length -lt 0 -or + [long]$Evidence.Length -gt $script:GraphKitAuthParityMaxPackageBytes) { + throw [InvalidOperationException]::new('The package source exceeds the protected bound.') + } + return $true +} + +function Assert-GraphKitAuthParityProviderWeakReference { + param( + [Parameter(Mandatory)][WeakReference] $WeakReference, + [Parameter(Mandatory)] $ProviderContext + ) + if (-not $WeakReference.IsAlive -or + -not [object]::ReferenceEquals($WeakReference.Target, $ProviderContext)) { + throw [InvalidOperationException]::new( + 'The provider unload observer does not identify the inspected load context.') + } + return $true +} + +function New-GraphKitAuthParityModeRecord { + param( + [Parameter(Mandatory)][string] $Execution, + [Parameter(Mandatory)][string] $Mode, + [Parameter(Mandatory)][string] $StartedUtc, + [string] $ModuleVersion = '0.0.0-rejected', + [string] $Digest = $('0' * 64) + ) + $checks = [ordered]@{} + foreach ($name in $script:GraphKitAuthParityChecks) { $checks[$name] = $false } + $adapter = [ordered]@{} + foreach ($name in $script:GraphKitAuthParityAdapterChecks) { $adapter[$name] = $false } + return [pscustomobject][ordered]@{ + schemaVersion = [int] 1 + execution = $Execution + moduleVersion = $ModuleVersion + packageSha256 = $Digest + authMode = $(if ($Mode -cin $script:GraphKitAuthParityModes) { $Mode } else { 'Certificate' }) + state = 'Failed' + failureStage = 'Artifact' + failureCode = 'ArtifactRejected' + checks = [pscustomobject] $checks + adapter = [pscustomobject] $adapter + read = [pscustomobject][ordered]@{ + operation = 'ManagedDevice.List' + attempted = $false + succeeded = $false + rowCount = [long] 0 + } + startedUtc = $StartedUtc + completedUtc = $StartedUtc + } +} + +function Set-GraphKitAuthParityFailure { + param( + [Parameter(Mandatory)] $Record, + [Parameter(Mandatory)][string] $Stage, + [Parameter(Mandatory)][string] $Code + ) + if ($Stage -cnotin $script:GraphKitAuthParityFailureStages -or + $Code -cnotin $script:GraphKitAuthParityFailureCodes -or + $Stage -ceq 'None' -or $Code -ceq 'None') { + throw [InvalidOperationException]::new('The protected parity failure mapping is invalid.') + } + $Record.state = 'Failed' + $Record.failureStage = $Stage + $Record.failureCode = $Code +} + +function Set-GraphKitAuthParityPassed { + param([Parameter(Mandatory)] $Record) + $Record.state = 'Passed' + $Record.failureStage = 'None' + $Record.failureCode = 'None' +} + +function Test-GraphKitAuthParityEvidence { + param([Parameter(Mandatory)] $Record) + $top = @( + 'schemaVersion','execution','moduleVersion','packageSha256','authMode','state','failureStage', + 'failureCode','checks','adapter','read','startedUtc','completedUtc') + if (-not (Test-GraphKitAuthParityExactProperties $Record $top)) { + throw [InvalidOperationException]::new('The mode-run evidence schema is not exact.') + } + if ($Record.schemaVersion.GetType() -ne [int] -or [int]$Record.schemaVersion -ne 1 -or + $Record.execution.GetType() -ne [string] -or $Record.execution -cnotin @('DryRun','Live') -or + $Record.moduleVersion.GetType() -ne [string] -or + $Record.moduleVersion -cnotmatch '^\d+\.\d+\.\d+-[0-9A-Za-z][0-9A-Za-z.-]*$' -or + $Record.packageSha256.GetType() -ne [string] -or + $Record.packageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $Record.authMode.GetType() -ne [string] -or + $Record.authMode -cnotin $script:GraphKitAuthParityModes -or + $Record.state.GetType() -ne [string] -or $Record.state -cnotin @('Passed','Failed') -or + $Record.failureStage.GetType() -ne [string] -or + $Record.failureStage -cnotin $script:GraphKitAuthParityFailureStages -or + $Record.failureCode.GetType() -ne [string] -or + $Record.failureCode -cnotin $script:GraphKitAuthParityFailureCodes) { + throw [InvalidOperationException]::new('The mode-run evidence has an invalid scalar.') + } + if (($Record.state -ceq 'Passed') -ne + ($Record.failureStage -ceq 'None' -and $Record.failureCode -ceq 'None')) { + throw [InvalidOperationException]::new('The mode-run state and failure tuple disagree.') + } + $failureMap = @{ + Artifact='ArtifactRejected'; Import='ImportRejected'; Context='ContextRejected' + Acquisition='AcquisitionFailed'; Read='ReadFailed'; Diagnostics='DiagnosticsRejected' + Cleanup='CleanupFailed'; Evidence='EvidenceRejected' + } + if ($Record.state -ceq 'Failed' -and + (-not $failureMap.ContainsKey($Record.failureStage) -or + $failureMap[$Record.failureStage] -cne $Record.failureCode)) { + throw [InvalidOperationException]::new('The mode-run failure tuple is not allowlisted.') + } + if (-not (Test-GraphKitAuthParityExactProperties $Record.checks $script:GraphKitAuthParityChecks)) { + throw [InvalidOperationException]::new('The mode-run checks object is not exact.') + } + foreach ($property in $Record.checks.PSObject.Properties) { + if ($property.Value.GetType() -ne [bool]) { + throw [InvalidOperationException]::new('A mode-run check is not Boolean.') + } + } + if (-not (Test-GraphKitAuthParityExactProperties $Record.adapter $script:GraphKitAuthParityAdapterChecks)) { + throw [InvalidOperationException]::new('The adapter evidence object is not exact.') + } + foreach ($property in $Record.adapter.PSObject.Properties) { + if ($property.Value.GetType() -ne [bool]) { + throw [InvalidOperationException]::new('An adapter check is not Boolean.') + } + } + if (-not (Test-GraphKitAuthParityExactProperties $Record.read @( + 'operation','attempted','succeeded','rowCount')) -or + $Record.read.operation.GetType() -ne [string] -or + $Record.read.operation -cne 'ManagedDevice.List' -or + $Record.read.attempted.GetType() -ne [bool] -or + $Record.read.succeeded.GetType() -ne [bool] -or + $Record.read.rowCount.GetType() -ne [long] -or + [long]$Record.read.rowCount -lt 0) { + throw [InvalidOperationException]::new('The read evidence is invalid.') + } + if ($Record.startedUtc.GetType() -ne [string] -or + $Record.completedUtc.GetType() -ne [string] -or + -not (Test-GraphKitAuthParityUtcText $Record.startedUtc) -or + -not (Test-GraphKitAuthParityUtcText $Record.completedUtc)) { + throw [InvalidOperationException]::new('The mode-run timestamp is not canonical UTC.') + } + foreach ($value in @( + $Record.execution,$Record.moduleVersion,$Record.packageSha256,$Record.authMode,$Record.state, + $Record.failureStage,$Record.failureCode,$Record.read.operation,$Record.startedUtc,$Record.completedUtc)) { + if (Test-GraphKitAuthParityForbiddenString $value) { + throw [InvalidOperationException]::new('The mode-run evidence contains a forbidden string.') + } + } + if ($Record.state -ceq 'Passed') { + foreach ($name in @( + 'packageDigestMatched','snapshotBound','archiveValidated','extractionSealed', + 'exactImport','routeMatched','cleanupVerified')) { + if (-not [bool]$Record.checks.$name) { + throw [InvalidOperationException]::new('Passed evidence is missing a required check.') + } + } + if ($Record.execution -ceq 'DryRun') { + if ($Record.checks.contextMatched -or $Record.checks.sourceMatched -or + $Record.checks.tenantProofVerified -or $Record.read.attempted -or + $Record.read.succeeded -or [long]$Record.read.rowCount -ne 0) { + throw [InvalidOperationException]::new('DryRun evidence contains live behavior.') + } + } + else { + foreach ($name in $script:GraphKitAuthParityChecks) { + if (-not [bool]$Record.checks.$name) { + throw [InvalidOperationException]::new('Passed live evidence is missing a required check.') + } + } + if (-not $Record.read.attempted -or -not $Record.read.succeeded) { + throw [InvalidOperationException]::new('Passed live evidence did not complete its read.') + } + } + foreach ($name in $script:GraphKitAuthParityAdapterChecks) { + if (-not [bool]$Record.adapter.$name) { + throw [InvalidOperationException]::new('Passed evidence is missing an adapter check.') + } + } + } + return $true +} + +function Test-GraphKitAuthParityFrozenArtifact { + param([Parameter(Mandatory)] $Record) + if (-not (Test-GraphKitAuthParityExactProperties $Record @( + 'schemaVersion','moduleVersion','sourceRevision','packageSha256','proofSha256'))) { + throw [InvalidOperationException]::new('The frozen-artifact schema is not exact.') + } + if ($Record.schemaVersion.GetType() -ne [int] -or [int]$Record.schemaVersion -ne 1 -or + $Record.moduleVersion.GetType() -ne [string] -or + $Record.moduleVersion -cnotmatch '^\d+\.\d+\.\d+-[0-9A-Za-z][0-9A-Za-z.-]*$' -or + $Record.sourceRevision.GetType() -ne [string] -or + $Record.sourceRevision -cnotmatch '^[0-9a-f]{40}$' -or + $Record.packageSha256.GetType() -ne [string] -or + $Record.packageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $Record.proofSha256.GetType() -ne [string] -or + $Record.proofSha256 -cnotmatch '^[0-9a-f]{64}$') { + throw [InvalidOperationException]::new('The frozen-artifact evidence has an invalid scalar.') + } + foreach ($value in @( + $Record.moduleVersion,$Record.sourceRevision,$Record.packageSha256,$Record.proofSha256)) { + if (Test-GraphKitAuthParityForbiddenString $value) { + throw [InvalidOperationException]::new('The frozen artifact contains a forbidden string.') + } + } + return $true +} + +function Test-GraphKitAuthParityRetention { + param( + [Parameter(Mandatory)] $Artifact, + [Parameter(Mandatory)][object[]] $ModeRecords + ) + $null = Test-GraphKitAuthParityFrozenArtifact $Artifact + if ($ModeRecords.Count -ne 4) { + throw [InvalidOperationException]::new('Retention requires exactly four mode records.') + } + $modes = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($record in $ModeRecords) { + $null = Test-GraphKitAuthParityEvidence $record + if ($record.execution -cne 'Live' -or + $record.state -cne 'Passed' -or + $record.failureStage -cne 'None' -or + $record.failureCode -cne 'None' -or + $record.moduleVersion -cne $Artifact.moduleVersion -or + $record.packageSha256 -cne $Artifact.packageSha256 -or + -not $modes.Add([string]$record.authMode)) { + throw [InvalidOperationException]::new('A retained mode is not bound uniquely to the artifact.') + } + } + if (($modes | Sort-Object) -join '|' -cne + (($script:GraphKitAuthParityModes | Sort-Object) -join '|')) { + throw [InvalidOperationException]::new('Retention does not contain the four literal modes.') + } + return $true +} + +function Initialize-GraphKitAuthParityNative { + if ($null -ne $script:GraphKitAuthParityNativeType) { return } + $helperGzipBase64 = @' +H4sIAAAAAAAAE+09a3PbOJLf8ysQVSqWahSN7WSyOXuUnGM7iWuT2GUlk7ubSaVgErJwoUgtSfkx +du63XzVexJOkHs7s7A5ramKRQKPRaADdje7GvKDpOXpHozwrsnE5+ETTx9uDER6TNziNE1Ls3puz +IqProiRT89dgP0sSEpU0S4vBa5KSnEZWiaNj68XpPC3plAyO0pLk2WxE8gsa2c0MRiSa57S8HuxF +ESmK/Swt8ywJFdrPr2dldp7j2eQ6VOYkp2lEZ9gG8oFclbv37qV4SooZjgj68uX16d7Jm78fffiy +9/HDmy+jD3uvD7/s7518+Hh6+OX93rvD0cne/uGXL7v37s3mZwmNUEFwQmIUJbgo0GtA4++03JuX +kxNcTg4vaEzSiNy7uYcQQrJKmQMSpyTBJb0gUBDdoHNS7iKa0nIXfUNDUWhwOJ2V17ue2ieT64JG +OFmu9nvW8lFM0pKW14vXH03w9k9PF6iXZOk5ekvScwdbtxRNv+5n87SsKUjTEn1M6dW7LCY1xeZQ +7vgyJflHGjeVOxyPgZ8vSH1ZSX+ST2lR0CyVg7wANc6yLEFHxQHNSVRmuT0AnqKn5Hye4PwVTUib +wjOcF+Qkg241lGbU+ZTTEp/VghbdZsVHDoFasMz+PM9JWkqeWwXGEkiwvvIF5XSekOIkz0oSlaRu +pFmdN7g4SickpyWJtfqtyHqcJte8TlPxwysclcvUkSOn6iqeagcEGOqU4Biq2mW/NS9y+9ns2r/I +hZZCNMrmuW+ypOSy29ttBeKAFCVNMew9RyktKU7WBS4IpwUtYCjIgsT4Y3tS4pJGnp6MSnxO9vGs +nOeqIzm9wCVBUZYWJV8vxaYP3IOGaPPq2SZ/dhsqMDqxGk+aahyQhJREcDLU2Nzc3KqtwYAf4Mis +86S5DptAZq1ntbVGE5yzqSOLw7NVX7zquii/XV+e91+v8CRYYT8nuCTvySUaojAaxzOSHl5RYJNz +NESPgwVhYdgry5yezUvyPsunONHweLZZW/FVgs+hJWMj4rW360dc1n6Jo6/z2YhMcVrSSAwJr9tc +mZH5wyTP5ueTdqxp9Fath60G1qrqdJizn686G2YC9Y/P/pdEZf3AsT1PSLRH6RhGhM3zVkge4Chp +qOzlLKj7IftKUtY8GqJgqcM8z/KjtJiPxzSiJC1fzsdjVmVrO8zl7/BVBV5DTEiKQ/T0p58ePw21 +CaQ7oMUsKyhfQcfZPlvMGJ6m/MCWuuDCeZQWMxKVALArZI08y0oo05fCR67J6z0GHJ7hc1m5W9XQ +i/YRuYLPamPeQWOcFKSPJriYgHZD0nIHlfmc9JZG+h0pcYxL3FV42b1wPugoqo9MAJ/iKzqdT/kY +8J7yTQAeOkZdowD6GW1W5KgKwlNO8uwSNh60l5/PpyC6zcvj8SlOz8nhVURmMGxd0L+ysQm1J3Y8 +eLjUAk+QFET+MVSjYSCy0tCwdxo+QALZ4EAQ4bmPanUEOTquCPCgI4SijRsdvW8biFxFhMQFomWB +zrJ5GpMYUd5BmL4Ja2zQ8RIrJ+U8TxVpeJFvy3CYos93mBswDwL0XwVz+Cixn2E/Wwvl4ah4P0+S +4/zThJZkBHaBLqvRnsmrke3soVgpeQAF0QLl5B9zmpN40Okjwfu8Ad8oCpzH84Sr+kME/wxek/KV +eMUrV3WFiQOP2dbCTTlowv8ZMgEANrj32assSbLLroTcrzBVq5HFSpK6r/JsyuGas4w30lfI9g19 +bLHBDjJrpZUxedlSmrqNS4Q98nLQYNDVwA1Aerg8GsNLNdddghzPSI5BlJIWr+ITTePssuhWzALP +C9X6wFbyHj40SsJzP8yIJpgRjXs+AJLq/5jjpHDr9CtsLIW6j0as6n42neGcFlk6OM5jmuLE7M9O +BUFZYYYgS2w920QPH1q9BXvKcFi91A0tSwy0peH+04+4bufxDJZJK2WJqSvptWP4GEnVCFkxGvGp +59RqSFvZIv7svG6OZHAO7G8203XJORHkda79KfTUEDgC4QwDEbwiYTShSfweT50JY1T170ASqDZj +dKhoiE5JkSUXRFq9RZ2+1mpV9TAt5jnZSyNSlFle1Jb1bne8wpvgpicBhrc8biF/haOyENBeknGW +A7TXpNS+dvW2+qKsJS3e1yEYbHR7a0AfmMbbxcTIDhv1RxmorpXUEQFngLAoxI4CZSlBM3F8IAdV +lbeEyaobaYkI6Hhmz2rWwxDyqvNSIUWF/MMwdslH6a3McjymJEeRaUtGQyRalW+AO8V87/YGHwuS +O/PxxYsQGT9MiGxBwkVUNjXBBUozNDo6MCgVQDQTanOFTqXujmhsd1USYjAifJ3qMgA1parFXOwE +0ANabQtSxprlpCD5BRG7AE4j4io2BvC9WNspukAkmF98mLUPDl2tsalWGflUYE7p+aQsBrCMiFM+ +t7SGMJh2igGUxJSZC9gXdOuW4fYU8dqFeZJnM3zOZgUv/z5LiVvMOH/8cD0jgz1YPHQ5HZ6z65L8 ++hnFpIhyOiszGG9FxdeklGxxoAq8pCnOr19l+dRmgNf7cgGjaUriqgrwkPjGsIi6VXt99YkhecKq +WoDL/Nr4bU5JeCSaypZVIFz9KWamU8uFA48y37zDeTHByWBEfyfH45/dNp53ey7hdXQMEthUASbN +j8e8x3zQQ/AEM6gtYdMp9c2kmFq7ra31U/dDdnhVEtDFxerA9kC2N/VAvx1rhOu1JRlbWzWKvSbl +W1yU7Eye2dZsVtFxFJWH6Nkm7Cjq59azx277YRzg8a+JweLwPOjsldmURny1s/efWDuTiLIkoXBm +uoM2btRm/m0D4SQnOL5GBEzThbOyumrxCig/6Oxn8yRGaVYizBDHSSL2SeLvg4lslw0KumFk/tbz +Ymtiav4ag5CZNM1Hh9df5YTYTFABrv4iSUECWy9s4jkp5glIcdOvMc2xZShjLWsCzeAAbHV5Ni9e +E/Gq2xt8yI7S8vG2b64pQrmfhKl5a3+zt4t+/BFt/m3TnIXAygK9+0Pdquin0FJTxpguW39rOz0X +5rHvMSXc6bAAmqtPA5LnaRaeBjpvNltwqTj+HLpGx4Wl/5xkM5KS+EQqLGuR//fGpZTldPHfbKyV +AjDCU3Hk09VAtzAwOmKqd5iESB9NYOrGKJ4zPYyPsVIH6o3GYjTqrDNc6gGqLnhqclNj27SVRL/Z +uEZTbKywDuuoLTfrvHIW0BJt26jNIGeV/ld5+tzeCniWXqh9UOcPNC0H7/DVLziZk8U46UHHOXeg +oOSUTFOEVYhZpHKOGBrThAS4RzBFxI25XGjkL6MJib6SuNsFrdZAvfd51zx+ysbjgrDT0+rD5QTI +0RWffjY736vd7dgB/SlO40yoLQMYYDUeAtfBXjGa4bRr4Mmb6/X6AidrgePbFcBvsVlVpD9M4+Px +qMwJntaOAZMw5ewtmGXtkWDUiPtm1Cy58Ahq/TBkVPCNl8642L+8teRbbUljgHoui94f8jbkb62A +8viryshXK7OytQw2E9JZDAWLtDTL6S5RCH7ATFbOGY49rmAnf6dZpump5ifvQa0ookkSXhD6dy8c +ZmQXliHb0I6G4jjUnJ/m6e+QvfSsPMPndb1nOHq6rr334gtPsNP2xyCEQIfNQkY/LQzpdJ7gkpxk +Rck79wrTZJ5rNpV/Gy6p4w4LQpBsAcGAdy0gGmgk8dDAtUNrnQxAtMkUokuN+NEaKbviAm3XizK8 +1bDlu6Jpa5GGVwmZv/UGZad9G4UOxRVznK/LGsE1Xwp3AGzJhgs0afZozIhTJ9p4/V4czNfsDeLt +gc8nROxjfocQi2GY/ZWJHzqTiTecYzQ/0sDabc2mfmh10BCxGFWDoLjVwWdgVqqA6fZMS+oDT476 +hcZfER5DTmRuhPrMlEyupNrP6AZtXu39hL710aZHQTfAvUrmxeRDdkCLry5QT+WA2neUgpRFYjTL +ivKRUNejbHaNxryPQpLDoAcylfoS+tEgKwb18tj1UB42OoJYHCLp5jBNaOMJ+GV55W+/6PLwIbof +dhJxO9VrL7+bcxUcbnV7zsZNoFPfNlBMubnljJzTFF3ScqIr8JizSf04yYMh6Iw8b2f6tzN1HHL7 +jy3OpJumYuqtx1ubf9vWtLJazcynnXmWxCbycl3tH3NSAGsPEdO/3sE58zuadjmSAlTfu+Q+8uto +jXqguX2JhoQyuNmvcAopgQ2KoL+3rRTChl1A1w5D+qDLPxJdMVbWRoUeMUKtZhRtt3+BI2O7DSzc +kXartWdMce1whvRmF4mF1nejpitfBcyLLcQrv4ilaeRaA0wv982f+0Mdj5azdjkGkBo5XzZoiS4x +2M00Ro49q2C77Yo3qH6226pCNPbJ8C33pjb76YJYfp8N9b7pUGXSc8DjPo12nG9Btyp0exsgvYJR +MaOvhdWYUgQ0xe2364xwZWGKy2jCliuObhN3CrsRoBAMlqvvg5hAck7KWu6ZmieAzKCdeFlb0azh +xVA7G69WwIiRpatoDBEaU3BssGVts3+ceZVeIUq34fe6ImFjDzwZd1OCU7YODANNzztuKRP/Hbs/ +lkcFsJlNFmkdFKEqwvBzkdG4sdtN6tHiVhtRTvW9+lI/ZDcttaxWvl9uVXje4fwr9xnlNXgXX2U5 +i3iD+Vq/2gWsKuYErJ2jxtY9IiVfW3zN2ordUkpdzayJEoLT+axh1rQ82X3QuVHj/a12dQNtkcQI +pzEiMBIooReVXZzjhHBSZKLkLuqgHzztpRkPbIjFyLHdHJclmc5KEg/QvgAltNMddGN2ePCOFAU+ +J98C8I9zeg7bhwbAZFoFwDOjWVTG+XlOznGpRRyZAPr2EAQ8IGF3XMLf8Y5GbsASIEh/xFmWc79x +NpZyGH0U7cBoPVKjBaSgAByEHbVdDWC/giPxNMKl4JIxsDqJEVNWYTPkbONtg5slHkGnMx7bCBCk +/ssaJwinPCZmII/8BTPOEhrRMrlGOYmyC5KjcuLvyoPO7yTPHoHWWvlRDNBKHBNc8xsX+OY1be1r +vS+K6T44ZtOEaNGUL6+FTOlZ3bJ5ieR3qxqi6Thbna0rNxMRucbGkzOPxidtpTI/I4hjd873j5hW +Wa1I0mGrxlHI8l/5Zh5cVL4VzDIxzgZG1G+BHoLf/2aPuS3t2scmzJQdrhkMILbBwehWmNzeKtC3 +t2yoBu/n0zOSH4/h8LSAylsrD96HVYZKKnrl9YygLEcJTb+iCE51/ZtJxz98sKHkZDwvLL1QW5s9 +kcAorn5L91V0I1IKsGOHIdrSBVs2eUbhyXNvAeE0FJlsH0OOdSzNj10IkO5ZDrQeuM+7vXXO0SnO +v642QcdZHpik652djSfxRkIOnuWh/pR19XNSw/9lsVN24+S8Dtn1HIE7OLY+F1/98PvPMTDLHl43 +hxcKNP6gM+rlj5r/Ojn81zs5ZIeDfx0d/nV0uMDRYTtel4vtegwobY3q/zTGdLGk87IkfoMLWM/3 +s/SC5OXgQ/aGXHFLeXf0Zm/7p6cQoz05gBwucnuAOIu32SVEul3gnGKIZXQN9hqWmvlcurW+zdJz +5W/pUMEy9uugpDVfx7/Gtr+sUR64pyTpSjb5+WyWUBKzhbXp/Dxgmjdzt9V3Zf0m939ZUzuQ9a5N +7YaUyQwxuo+EjEvAZ0WWzEsxgU2lXvyGnRBMaMHod3/2FR2y5fO2hLHQ8O8QhWems16/wtQkk231 +1vRjMBWyPFGQpWDKUhVUAA0gL1BXgncG7YUBh4VVswR4t+57nunO8+HwikTz0oW90x62AGGnaVgf +3m2R0+gP3+AYQS8nBg7IbSW08VgPg449RhPCS6/g27TNogYrekyDS/Aja9QTp9/VP/T4CmMzrvzs +WW+chFty7GS8eiHI4cRlKaREIHrCYnHB8MuOcYyYbZj8TJyRiLefMKEAoaLeLVFLbSdmGcuJM+fx +jk+1eEcYj3E0mWYyFKUp0pLzkRUYubK5BxxdjCMDxyENsR1ZZ62gEVZGBK7FksNt6tlFFXld2Qcc +/3AVbmYLrrpRJQRM94i2ZUCp9b+l6fzqlEC6ro8pvsA04dxaY/Go64A9czUcGmwCAauEH79av3rf +Zub1L3IR8VUNqwxWH60kMoo6ENkpEGiwjNRC0Epa23E41ZCOGveQChZ1EFkmbFPEYlbG/Gl2QaoE +LaHkLNQSb03vdEuYl9SBf8HOI0nbR84XnWSuqFaza6AXQY3g6DzNcrKPC4J22qgNK9JuOi/AyXOK +aQoLFrj3gzN/wVBmUan1TvHdpcIo3ADiXiu4nHOagzT8QcpNbTgsGm7IKVrX2k2DByP/u5UHo7UR +u7A4NrUQ9b5VXntW8LUN3elwoIkADa0dwtOY5ojJINnZnsxPVlCvR0G3qeGHZxZohOqngg06WMof +neOyyJpWwSo+J7QQ8il9p1mroBps5jDPDq8+uVKVPyuMXHRbFq9fiTcbzSx+x6eVkmYslF+m7fg7 +xeB50DHdTyR/hLJlaB+cfBn+A2Z4OkL6YQ4pGuhLkhMmEotT6wZDUtDL1JdUg6ZFiZPEwRlCQMAB +IyezBEcETo1qs8tY3kguG7/D0fGoholBL8HRqUwBkzOB8epLOqs2tArBvpYc3MMUFaA/fbKW78F3 +K/HeXWV7qWNM4JTjkcYj6PQQrkD6cvhf+2+D3ay0QMHAu+CeNsZJcoajr5ZzYBtbbTAbIc8UtGhI +YJ2i5K9tUvowLfNrtvO9z8pX4MngO2HjRMPlNprLRmgC+eCaemxMTFxudx9tbW5K6aaP+C//HN2q +dTcNo80CcthfK9oVOoy2Wt8Fv7w/Pj08ebu3fwjBt4oeCanhDDZDwAvFmCXM1bFE03kJTokDj1m5 +6koNLQ6S5J+HCOiMRHheEJTQswico8QkPSMoyXAc8rvtfGfSBVNz3axRurBX+H8facIgw+NnhpC1 +vd0D6+Hh++PRf4/Aje7w6P0ve293DC7K+dV6P2pKr/LsHayTqIuyd5ajeQpHdFkOzOlsDkHCrsjg +Swhv99axaTbSp950uuCmWeeU3HQ3wqp3hTD7o3UUbn3VMvv/qXN/Wb1cx80ItddgGHci1J4dBQe5 +DiMvBZxRV7jeBTugIbcuWVyxhkxqKmFaZcu4P7QRs60i3ft2AUgg4MvJ5kQxehO0rSsBGyxz8roQ +zQ5y07XRfYE6yo7SQTuoo2dr6/S+hdRIMaQwMt7rEyVVffRxZrfbXwEXnEcEE8rRMzO4eXG72+xk +zePfXUv+st7aMpjJeOlCJDGTSxdsDyK0IuwhH3B80deMAJ7GPbWgmlQ/B6fccNHd+O23jT7a+HHD +sloZt9RK2ugvzeLWtbSygj+XuLiDdsiYzPykcpPpA2YVUWPmDJlZsLrcQZSTL8xi1a0Ooph8YRYz +LpmVRfWXFv3cW2YVFZ1PZlV9AVSErN7ZhfV8TFVx7a1bwbjqrqpRvfZQSN1vYpBJvvVUgGtejbJw +dYdRzHOxrKzgfvJWdRqyrwoxKnlvX5E1fR/N6qGrWCSEwHcPabS7WQwKVe8t3vNdMqt40PPRU73x +jhcDXlNpN2hGu3uRw9FfVutanUgktjRtz/HLOzynDU84YsshXFI8SqOcGWVxwtw3xW5mvR7wY334 +swv/20vOs5yWkykcRw+4c6e2JrfPu9M6G6reB7MfC6fWSWqz6bTKqLp4Dp11JlLt7DGRR+THqdsy +ecYRGNNGfREKDfZmM5LGzEOXd7GPZDKZ3QXzrorN2OcMzJpiHkDFZC+NT0lBym6NK3DtHHByJi6R +pLnec0KCavCaMBpY6iI9cFRgmr8ExRwyC4TzSkKucY5g0soRO2QDSCQ2UeLRlJpgI24ZKZhks7AI +JwnOnUZ9wv08LfA4lGuQ0/HXz6gg50AGWFUN7EazhJZdELeq6iCJYjBvSncbXhfRVIGpO9uscY7h +tfkIS6AF6gw6YNzpDAYd/8k1LwoQ2e3B9HcSd+WfzGQCF6AM4H/7S7uPtyU0YAr61PtX+49ShU67 +M2Rt0vj8ntQMcHyWomx6RlO25jq1BJOxAgyEcMuRr9SQ6ckIbDcaaEH+OVzgarpmnx2jQth/x+ny +LCdjegXsCh43h2lcfKKyt9q9TzOc4zLL9yc4t3GDilbrjPI/oBogltYnCT8YlTgvOQocM4hKkZ1Y +96wmRYRnhOfl9DgcOUmlOY6NjsFOitnlE+0vwMJ+zyIoGHYm4my8jP8Q1Aw44lRN9lkxn7uNqm57 +sOgfVndaecA3oY0bAFvZaHCdp8r1ImvMgiu+XGbEVWV80hkbApP1EE1jAnNyc1f8+bNqpsqeuCU+ +/vBDaMCqdoyVSrzuK5i/MjifrW4vaHtVUMM3dNhMNPYzkEr0zyH6+Gfs453xGvlGzl7IiGdSycxI +rBipHfuEVw6Lzq6DoWXitSJi1hQ8EBptET5NE9Laj0oZ+NyDy9ckJTmNoH/ux9EE50x/RLf8bxmY +wn7wZBC+S+fKkzIf/A/JM68/bHoIx4I0PfffbgeXynGS6354evKOF6rcSxx9nc9GZIrTkkYF2kGb +vVqEPAwsAgiOCtAOEhq3Sba6lD+OjFRgGSicy6jauUXBbTlo40YOqOYFxZdOFsiiUpvMGO2abtzy +xvhxZIPuI6mYC17JSfhRoRfq0qrNTRga+LENvzWJQ789E0a0BcAtBlMC3HIBRklWkOMUIp2awXFo +CtwzF9wYZFAgvHampfp/a7RmsanZM2BOy8Q+jiER+qKi1KLswDLHLBteovEEYGKuSt0un1u9cdyH +4BeRtsnYbfxrrBaP1ZwzvVUwRyiH+h2uxj5DTA0Sfsh1gWKeC13v8FLXu7nYdT2Xu7a/4PXOL3ld +y0WvrS57XfzCV3icC139F7QucJfrAve5rvtO1zu919V3t2vYMXa5O17D8NZ01+td3Pfa7s7XwL2v +8PikVVpOJJ4eybW1a39AkPUItOhW/pJyKxdZxWHLLU+edIAj8wVbWnxHLF7ROFzEvMs2XE5teOEi +RpY7bgZEt0oKZlhDiqT5uSelQLMU7PeP9l20GmbmVheuug1Z4RX65av+ttqpQasy0ndgogYGqlWi +WnDNqhwT5pZv/8wa1ML3Oa8lBiIg9QXLw/MCeW65ZYdvXifbQacW2o6CVgdi3XERi/b7xQL35mo9 +YF1ytNhgKzsNrTSDrnd/bbwMXlOXKjVHmdLYzKxOftlUlOe+sPvvoCeb//G0j2ixV1ynkV8GNVFq +neDGj297EWiBLL32Y/tJ2o+yq9OE2KHo/OCzDfO2zg++1vnf+ZTj2QxU1Cr3p1SRtBygYu/Scocb +OT3rYp8edLxJxRdOH17fRjC5+EpJodeWTnxdskrzNhJYFVucRVzgHGWsY0Il0JaAY/4+YGEQbnIq +T05gg1eeQt515J4jYoiS7G9W0PKFUrhCKfFroMsHVS+r7oseMm8+jqfAfrFUQY4nuLVkOglFRLu1 +liX9VMM6zQi4Mmk5qtZkLWpKJT5ZPH+420g7E6FMG75xA/2Dm+cXzhhsMjs8LEcPS0w9RJ683GZV +Nwk4q+nP+u3L/C2Kt071rdy/Lvk4KSW724XXPYUxbLdv6PkE/fwzerzdQyITuPz01p7xglGU1WsI +ayar8kuWzKdkRHKKE55IfOfq2bcd/pGPbEyuoC14b71+m13C2463MXWqxUxlUpXRnIAFM1njJQsq +P1c+IWbqtzjs85dztzrJsVoiNc/Ae1NEMfbUp6GkX191rm8OVd+TkZ35i/H/KhwcNO/HRgyCYCJw +NBN/aASwvGeNT6M49ljXnMrg3mq89fjM+r77fWPtJprdYe0aIfdXF/OQn6oNsdmz1VtjMd9VG4Tu +tup3yBcmRljydS/Q7Z+eai6gsAofpRfZV8K2n1GJy7bpwgBwn+8ISyQN09ffMcOxWn2XOIZRf87Z +qhaTCxppeyd/S9Msdl7CyY+2GqvUatYrpnd9pJq3pebdu7ta1gSOLRqil7QULpskH3zIPnJac0Lb +SW5F+jdPla2nooqdVIF1taGO7knMOgQ089d5+kTUeWb7p1ahCnU92rLbEr7BTi29rf/QK1U7LpjF +AsRnAZPdHnr40GjslAe0auLE4CTPYOLt5RE4J0bsvsjhEOm/B3v59OmT0ED++CM6hzDrjQKdc4MY +evrk0RktRdQm4/O9l0eoC3dNoLNrtAfAnz7pIRbmUtjQYJB/TNnhJJ1OSUxxSSBxHgtWAkVKgBcc +BPoSH7AxJUlcDFqzmaLv5hrGP8yaNWMf5k1VaXtzOUbbfrIEoz159scy2n+F2ey7DWTNoKg6zki2 +GH1nQNoO5LP1DmTjjnWS4BKG7X1WjmSIeW08t5kSoCjxOVFXz6rU0jEZ05QgjEBvu+BaGUrwNXdK +ACcuD88cj+TJAQ8GZ1kz2/HWt42A0wLb2cC+B+d/aMiHDpSOV4aHB1M2qBHuVVWCDKRPfMVFaJdT ++JmvMMivTsk9x89kXsXKsSiTrsJ489WrV7p7KTOgm7Fw56Qkc/PYXCgQAFZzC3jQueFTbOeK6SdZ +DH9puoeld4jgw9c8O6+hePAMvTp8sFrWBHrbioCpU+pwjC86Kn3PFLHuZpmQ6CuJha7HZrl1yiMJ +3fdOU/OtTmbzi9ZT+4PgDvs18IF9TZEccxjmZ0IbtsLsVJyLCHJR6KNnvcEJjt+Scdl90kcbm3YA +qR6R3K/9JRIJN/7TJm7GxyqaiUXZW2Ry/Ip56v0o+ZEUpPmBsJyYqf7MhSQnOJlVDOk/uhI5WMyq +Q6Pw0jK+gKunHmU+7ivI/OYQqk63Sdgky2qnaydlLtnn44dXz2xCuGbxoBuQ3u2YRMC6/l4H+mNb +ak3Ux3BobCPnV4V8HtE54ZZ0j0u0lQbeDSlcXJUUrX1HXTInZzSN74LNdM2ywWeyXo9DO60FN01v +XUB0UxOCEX+Rjc2T5tddgiy4rS7taDeMJ8ZguWkJ1Kgq8yK/uo9PhnrPTHN58K/OIDTYLMzcbzWX +KW0u1JrFF73a2LERuBW13rRUKopLWkbV8UQYLDyungtJzZlxxnAagxcBpxBbg1EAvjBme/qkLaAv +UHNpOXxZeZwnJBwrNT0ojIeE7HsNvl+uY8j9oB7Z/lxjCerUUybCqX4uwu4BnpISx7jEQcXDUlDW +5SIQ4L82Z/01KQlBb8hT9buNOw4L7Cspz7P4C8nBDgtx6nc9E8U02uovMGkt+fiPnVXMJCZmlUHE +peZY+PC8dkwDjpjt/TokO14xfvQxA8ubWcelDAfnTQtuNVqr6WCTp8aSA1473iqdNqHlRFgltQHP +cvTlCycaItBFHnEzWNwJwhrefogqCzhU3lUW07WajRSBLcpq1ERl5hxiL5aCs0lb9R2qel0FbDEI +nD3O5jSJVS4SLi2+5O+6j7f/9vSZbbhRZj3mH5Dy9sDjSToCfNKSgTA4fXGdsfg52MczHDHhVBen +YcOVsIeIuT6Kn8+HyK66uq4LAwbhRyRWrk5MHVnlunCxDKmUB76BkT1R5hA5J2pHuRakKHOBEzen +Hr+TSFksuEPxxzQ6kbH6/9n57bcXv318v/+bpWowcHoQvVN7CeVCUAga7aAfOMqD0fyMI+g24UsR +Z8Fy8OQ96tRgh144LT8BzY+9bDEagWz/NcPgJSggOqhFdMEbUvYUKwtNmHG0CDFmB9QlZAGCXV1Y +t/ktTzWpUwLUZfyy/PhzqMKv5Tl65tpveBHn/U646xBEeI1AL4YALWtW06LKpLiG7i/TdWsO/gKQ +0dDDiF4fFOW0dZDTCyKy2Biweusg4vfhnxU4R2M/z/qxHcTDSz8+VVu1qFrzNXCXPOlfiJil2dsn +YxVCw+faZq/PuSF6DB5G0QRD3r69IqL0LSlLknOi/Lr52Tgr5S+3PsPWvLGzAVX5q23+6rffNnb9 +uQjcyMlgxKS1ZnITqRUGqjuv1UWI2odY3MVf3X3xmvDGNZ3GFP1kMwNWzJTWKrSttIihmBdL44Oj +RCZKyeF3NjmeuIOhfLhIoAlwutnV21sT0P0hYn8cpcV8PKYRJWn5kmejY1mwdISkHGbNCqPIc/QO +X1UE0egpOrXQDuaRvnVzfQlLUMplNzuUtwQcRFRuQX+voiL07oddk8ThjErMJ8nNwjLfvE6yM5x0 +1dEcSLPW4Glg662L9xt5r4n/6ngQHpGgzmO00TH2XN5b8SWsfFXXWhu7FrqJhCX9axrKdXj78qMz +vUvouT33luqiN6Zcxx50OgKpCMFAzgzz4FnEUyTW4+ydUiro3D4XmzMLzM/eSs9FwkJP6hAGjvtT +Bs8S10QPEWAfkzGGZYl3xBduH7yx291JdPyXOKyTRIRITznDHVLVaeABH+UWXskNOditG6pN/Xts +eizbmyYUAfzmwkFakS0dZ9609Ma7EUOLB1f78vFqwCRP3qIDHCWeLy03xzV+gAVMLOSVtc89hq3O +Xt9mEU7ekWmWC9uFGfQPEfF1gHjqREbrNd/MKw1GHitFxgZnoxBzCNwKD/b236qVkiMUPik1z8xU +bIE3rwH/qtHAe1hsUcQFvjBt2ELi7bfamxCGEBMWr1tlHNEQ9ffeSe3w8lolP+AfjT3e7kpPO1KX +y8d+NrtuolDfbpHHAZjvXFtHlTvDl1xFz9QD6GvZEcXV22DTUCFJ6qV2Fq2lGalNamEMmd2Ve8E0 +GyOerURGAqDbwGd7Bb+D/DDrzQsTRrDagtvlhqnJLNN1v/X0NCQ8hQwEF2VjX1mtFbBcZ7nIvAoh +FftZknDqo1wkINdBa7EXXZ5dRvy/TWN2xhd2vTt5hwtwInTSwYj0AyUW4f3GxyrrMjK1ED8YaZXT +MpzcBorqReoh83QJo/mZdv/oXhpDOS94Xr4J6j7zn6gEBC+oD/grj0ApJnRWQ2DpBiJ+DuubBr+i +PTg8IdGcJYRwSoyu02iSZyn9XTN3MKEks9L4M6c6q8wkmOPeVzpzUtJzqU54uDjTCswSnGcH/AaH +50O0ZcEk/lz3VdKjnHjz+FvO4QsiMgwg0iJrvh71ZRoNm1FeDU2VONqX9onVgxzSrH7NNS/e8b4d +snosr4b47HEs10booawgVt9TMiY53G1hdU5+d7z54WEQnGRPzIPfnwHKVYsWBsHui2G1nKn5sFr9 +bO85l5KKKNoMg4W3TkPy8vpaKHnfHr21kDsIxCHdcGgvbKGqdsYwrsx6sogFIdiJxACCN7mYORTt +5ve/ymjUpXJbaGy6zuCIjPtguGBf0K07gFwrFa976xzKb+aqPTavIqkWZ/veKFMVd0O8RQSopJ3z +xRtg0CaiuT4bHQT/MrF9BWHcE1vgv6eH7z7sBMqKYzCZuqZItVn5CjVtgf02m5EH51A8sG9B9ZRY +LEJYZ6leY9Z3mR8SSstcDUYEgmUg4r9ho6muM76p0yIF4dVvjclNrcnIBpkw+ZqHOCslQbCPa84w +VFJmfWKeuP1atjOg7KzYusxI0abhNrqdVOt8+o9F0Uo/M60dbfSn9SmX/D4EDfmwlLiy7eq4yn3F +TzAhfyCLpOVOhMJvXF54DAZxruhqlx3WmNID9px6CwlP7WJPJWUJMa0a1WySEwmy2nO6uUmszLwR +ZiJNI4WyMHhzXmaMq4pyvrSGy0wAIVGxd7suDm47XMFTG0/Q8sbwGRn4OMPKVhvnrdn4/1mtc1cl +SEYSQit0Rm9FTrljbC4CI3sRCK44Xcc0pgwpGjJO9saFGrYXG65Q1TTnjvaKg11ngrbYVbakvRqi +2lF1RZbVeMiHAZw28x3HYSe046nR8pjGs0a0XRhCBzHGfmu9lmuGvQ3/GY2aCyS5vmtbNTtrMUjr +cnlzgm0tMPGuUmw7mlMuFCjFMEERy1WrgvKQ15ZXIeFoXbR6YdmbqmXnBVpdL0M7fs3bc+jQMvW4 +nWVc0BNyLKl2rHtTvVnB22QAF2qYRNEvqBMM6Q+jBBdFQFmzZj47NoTsgwHVTt7ZBjmbPGZWFcmW +ebMumRcBGZmbzE9VxifWBm7UpeqsuZ6StlYVtsV6KrfTqVz9PHjfQRxDyjGg6W7tRbQGqT1F+c2w +kvS7zZfPugPhraQBt8Znt9WFs57Xuy2vmg0Mqafzrl2+erPb5npZ3+h7KrYwkbdiEFc60ew4Ott4 +RVA5TcW0YSx0A5kpdn2lGCua3BQuLECqQW8s6WGttnVaNMJw93JWQ50QU7Uhk8YYDcW9zNSmTiMf +NQAxOMYoW7cJ+D1Jdth7/gNcVY7zdzSdF8cpUYnGQ/uEF6C4qkaG8qAddIYL0nVurQktiMobwcm3 +qLlBzhQjZBckz2lMOGFOSUJwIW/NAb9ixBBkWfIlRqYLWfPmqaVSCVFCz7ZiZa+sArz19Cp65jf5 +w3+1u56ypm/mkRM/jdwpTqIdzyZGjSvTq3ehS9Fr9vqZe8H7X2LA4mKAplpJvjH3CD2QCu4c0X6a +BZWjlJ1VkH2ULAbf5d9mkY9VdiTJdZ4NlydDcvMZwnNoZkzSudMsqN9lONRZ1i6mcSsrqP12i2q3 +2fGy1YulJawTh8thDJyXf4llJnH/rcUyNaUb5SFjbodLs01CTO+mUmqehwvCBFGTPVyM7TBqzjeU +M6Z+gwyjz/7GovoK0KKwtgasVzD2LAR/CdP/ssK0W3aEp8LrvKvnv88ga4ERxQaPmQOpMgyx0srL +oi7+1/ayMgDqy4YEarxzjNk1GfZbXbhec8m6eVj8K4/1eMvSUXb5P3+naTwYkX/Mods46X22bOBQ +gY3FBzqFWTWbnyU04usKZIg3XkCG990FWoOL6yPw69wKt8vvqaBgjoMDEkvUF62D77W4maoSP1bt +tXsLnb9xRgpLrBOfhNLlOlk4JXl+Ku3OufX0wRvT4++GwJUVXk/jgbsd/M0zBgpdpCDKKD5kJzA0 +S+FHuBSEevHFprkcv/PDV4wh5t5w4C+n36tQX8K4XkEvYOT9D8NQVyo0FFHtyNE8SJKjKSQu6Xa+ +kjwlyePtQZwknT7an+B8REBAF3/BDSeQ5LAP+j9QSYaTMlPBZ99xGQSV56kdoqRfVyfvDxc3HQlt +OSYFxNNJFY0HIsGtLWaqUsGk0sRe8Yqowu4bolmqrRriyxhs+Xup5t0tc2UiuDAoEfj1dtdMpj6q +Ms+gIepopOgsSULjKsk25FTks8jKLmn0XOy5EHlN4H8UmWtJyYQETkAlhXwyfaJaU6IF5kuhV3Nf +jsUJMNTNl+eIv33o4vgCz6hAtxYzNqTBMMCGOERmE2NSGRyX2UyoQXSmt3tUJT6c59l85vsQ4yhx +VwnjpRbeVzj78XrJ5ITALd/u8twUjhKX89GMDedZA1TxfTCyOiTVClgDqn2xI8StyHAVqFY/j2o7 +KdCprMjixZSZvO9qjo7CczQ0KdhkNWg8NiFYdIZ1yCP22rUs4lf3J37fhVUyfiCTl3fhMpKEWf49 +DB68kjchVXvKH7BjvMsuGPqHV2qzIFcULmU7l/c0KrN6Si6rd/VYQ6K3JTiQLaczkpr71l21NI4m +0yx2UuSqO3TW3RxLvWi39uvxvPysp+UNttrMpdoVASHETUFN5jZctkc8myT8eSGTSC7fPxs5Rq8H +R++PDw6fPlmF5ip77yq4LdP69GtMcznmlfeswdx3wW0qj77Imm80aCfZ5x626+x2TlI8JVdf0pmS +3PNsqvpdZne4evC2cbnNiJ4lcXUaqU4HE9ZpzqopuXRLpOSSl2iBZi1GLFaBpbwXVGf5Lpm/3bd7 +/w8A/PyyYBQBAA== +'@ + $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) + $compressedStream = [IO.MemoryStream]::new($compressed, $false) + try { + $gzip = [IO.Compression.GZipStream]::new( + $compressedStream, [IO.Compression.CompressionMode]::Decompress, $false) + try { + $reader = [IO.StreamReader]::new( + $gzip, [Text.UTF8Encoding]::new($false, $true), $true, 4096, $false) + try { $template = $reader.ReadToEnd() } + finally { $reader.Dispose() } + } + finally { $gzip.Dispose() } + } + finally { $compressedStream.Dispose() } + $marker = '__GRAPHKIT_AUTH_STAGE_CAPTURE_NAMESPACE__' + if (($template.Split([string[]]@($marker), [StringSplitOptions]::None).Length - 1) -ne 1) { + throw [InvalidOperationException]::new('The embedded native helper marker is invalid.') + } + $helperBytes = [Text.UTF8Encoding]::new($false, $true).GetBytes($template) + $hash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($helperBytes)).ToLowerInvariant() + if ($hash -cne $script:GraphKitAuthParityExpectedNativeSourceSha256) { + throw [InvalidOperationException]::new('The embedded native helper digest is invalid.') + } + $nonce = [Convert]::ToHexString( + [Security.Cryptography.RandomNumberGenerator]::GetBytes(16)).ToLowerInvariant() + $namespace = "GraphKit.R8.Parity.H$hash.N$nonce" + $expected = "$namespace.GraphKitAuthStageCapture" + $types = @(Add-Type -TypeDefinition $template.Replace($marker, $namespace) ` + -PassThru -ErrorAction Stop) + $match = @($types | Where-Object FullName -CEQ $expected) + if ($match.Count -ne 1) { + throw [InvalidOperationException]::new('The embedded native helper did not load exactly once.') + } + $script:GraphKitAuthParityNativeType = $match[0] +} + +function Get-GraphKitAuthParityTestHooks { + $hooks = [AppDomain]::CurrentDomain.GetData('GraphKit.Task8.ParityTestHooks/1') + if ($null -eq $hooks -or + $null -eq $hooks.PSObject.Properties['ContractMarker'] -or + [string]$hooks.ContractMarker -cne 'GraphKit.Task8.ParityTestHooks/1') { + return $null + } + return $hooks +} + +function Invoke-GraphKitAuthParityHook { + param( + [AllowNull()] $Hooks, + [Parameter(Mandatory)][string] $Name, + [object[]] $Arguments = @(), + [switch] $PassThru, + [switch] $PreserveExceptionType + ) + if ($null -eq $Hooks) { return } + $property = $Hooks.PSObject.Properties[$Name] + if ($null -eq $property -or $property.Value -isnot [scriptblock]) { return } + try { + $records = @(& $property.Value @Arguments 2>&1 3>&1 4>&1 5>&1 6>&1) + } + catch { + if ($PreserveExceptionType) { throw } + throw [InvalidOperationException]::new('A protected parity internal seam failed.') + } + $streamRecords = @($records | Where-Object { + $_ -is [Management.Automation.ErrorRecord] -or + $_ -is [Management.Automation.WarningRecord] -or + $_ -is [Management.Automation.VerboseRecord] -or + $_ -is [Management.Automation.DebugRecord] -or + $_ -is [Management.Automation.InformationRecord] + }) + $allowStreamRecords = $null -ne $Hooks.PSObject.Properties['AllowStreamRecords'] -and + [bool]$Hooks.AllowStreamRecords + if ($streamRecords.Count -gt 0 -and -not $allowStreamRecords) { + throw [InvalidOperationException]::new('A protected parity internal seam wrote to a diagnostic stream.') + } + if (-not $PassThru) { return } + $success = @($records | Where-Object { + $_ -isnot [Management.Automation.ErrorRecord] -and + $_ -isnot [Management.Automation.WarningRecord] -and + $_ -isnot [Management.Automation.VerboseRecord] -and + $_ -isnot [Management.Automation.DebugRecord] -and + $_ -isnot [Management.Automation.InformationRecord] + }) + if ($success.Count -ne 1 -or $null -eq $success[0]) { + throw [InvalidOperationException]::new('A protected parity internal seam returned an invalid result count.') + } + return $success[0] +} + +function Test-GraphKitAuthParityContainedPhysicalPath { + param([Parameter(Mandatory)][string] $Root, [Parameter(Mandatory)][string] $Candidate) + $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } + $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd( + [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + $candidatePath = [IO.Path]::GetFullPath($Candidate) + return $candidatePath.StartsWith( + $rootPath + [IO.Path]::DirectorySeparatorChar, $comparison) +} + +function Test-GraphKitAuthParitySealedPermission { + param([Parameter(Mandatory)] $Evidence, [Parameter(Mandatory)][bool] $Directory) + if ([bool]$Evidence.OwnerWritable) { return $false } + if ($IsWindows) { + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + return -not [string]::IsNullOrWhiteSpace([string]$Evidence.CurrentOwnerSid) -and + [string]$Evidence.OwnerSid -ceq [string]$Evidence.CurrentOwnerSid -and + [string]$Evidence.CurrentIdentitySid -ceq $currentSid -and + [bool]$Evidence.AccessRulesProtected -and + -not [bool]$Evidence.HasInheritedAccessRules -and + [bool]$Evidence.ExactOwnerOnlyAccess -and + ($Directory -or [bool]$Evidence.FileReadOnly) + } + return [int]$Evidence.UnixMode -eq $(if ($Directory) { 0x140 } else { 0x100 }) -and + [uint32]$Evidence.OwnerUid -eq [uint32]$Evidence.EffectiveUid +} + +function Assert-GraphKitAuthParityPortableNameSet { + param([Parameter(Mandatory)][string[]] $Names, [Parameter(Mandatory)][string] $Kind) + $portable = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $normalized = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($name in $Names) { + if ([string]::IsNullOrWhiteSpace($name) -or + $name.IndexOf('\') -ge 0 -or + -not $name.IsNormalized([Text.NormalizationForm]::FormC) -or + -not $portable.Add($name) -or + -not $normalized.Add($name.Normalize([Text.NormalizationForm]::FormC))) { + throw [InvalidOperationException]::new("The protected parity $Kind name set is ambiguous.") + } + } +} + +function Get-GraphKitAuthParityFullVersion { + param([Parameter(Mandatory)][string] $ManifestPath) + $manifest = Import-PowerShellDataFile -Path $ManifestPath -ErrorAction Stop + $base = [string]$manifest.ModuleVersion + $prerelease = [string]$manifest.PrivateData.PSData.Prerelease + if ($base -cnotmatch '^\d+\.\d+\.\d+$' -or [string]::IsNullOrWhiteSpace($prerelease) -or + $prerelease -cnotmatch '^[0-9A-Za-z][0-9A-Za-z.-]*$') { + throw [InvalidOperationException]::new('The extracted module is not one exact prerelease.') + } + return "$base-$prerelease" +} + +function Read-GraphKitAuthParityArchiveEntry { + param([Parameter(Mandatory)][IO.Compression.ZipArchiveEntry] $Entry) + if ([long]$Entry.Length -lt 0 -or [long]$Entry.Length -gt $script:GraphKitAuthParityMaxEntryBytes) { + throw [InvalidOperationException]::new('An archive entry exceeds the protected size bound.') + } + $stream = $Entry.Open() + try { + $memory = [IO.MemoryStream]::new([int][Math]::Min([long]$Entry.Length, 1MB)) + try { + $buffer = [byte[]]::new(131072) + [long]$total = 0 + while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $total += $read + if ($total -gt $script:GraphKitAuthParityMaxEntryBytes -or + $total -gt [long]$Entry.Length) { + throw [InvalidOperationException]::new('An archive entry exceeded its validated byte bound.') + } + $memory.Write($buffer, 0, $read) + } + if ($total -ne [long]$Entry.Length) { + throw [InvalidOperationException]::new('An archive entry length changed while streaming.') + } + return $memory.ToArray() + } + finally { $memory.Dispose() } + } + finally { $stream.Dispose() } +} + +function Test-GraphKitAuthParityPortableArchiveSegment { + param([Parameter(Mandatory)][string] $Segment) + + if ([string]::IsNullOrEmpty($Segment) -or + $Segment -ceq '.' -or $Segment -ceq '..' -or + $Segment.EndsWith('.', [StringComparison]::Ordinal) -or + $Segment.EndsWith(' ', [StringComparison]::Ordinal) -or + $Segment.IndexOfAny([char[]]'<>:"\|?*') -ge 0) { + return $false + } + foreach ($character in $Segment.ToCharArray()) { + if ([int]$character -lt 32 -or [int]$character -eq 127) { + return $false + } + } + $dot = $Segment.IndexOf('.') + $baseName = if ($dot -lt 0) { $Segment } else { $Segment.Substring(0, $dot) } + if ($baseName -match '(?i)^(?:CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|' + + 'COM[1-9¹²³]|LPT[1-9¹²³])$') { + return $false + } + return $true +} + +function Get-GraphKitAuthParityArchivePlan { + param([Parameter(Mandatory)][IO.Compression.ZipArchive] $Archive) + if ($Archive.Entries.Count -lt 1 -or + $Archive.Entries.Count -gt $script:GraphKitAuthParityMaxEntries) { + throw [InvalidOperationException]::new('The archive entry count is outside the protected bound.') + } + $portable = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::OrdinalIgnoreCase) + $normalized = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::OrdinalIgnoreCase) + $files = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $directories = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $records = [Collections.Generic.List[object]]::new() + [long]$totalLength = 0 + foreach ($entry in $Archive.Entries) { + $path = [string]$entry.FullName + $segments = @($path -split '/') + $portableSegments = @($segments | Where-Object { + -not (Test-GraphKitAuthParityPortableArchiveSegment -Segment $_) + }) + if ([string]::IsNullOrWhiteSpace($path) -or + [string]::IsNullOrEmpty([string]$entry.Name) -or + $path.EndsWith('/') -or [IO.Path]::IsPathRooted($path) -or + $path -match '^[A-Za-z]:' -or $path.IndexOf('\') -ge 0 -or + $segments -contains '' -or $segments -contains '.' -or $segments -contains '..' -or + $portableSegments.Count -ne 0 -or + -not $path.IsNormalized([Text.NormalizationForm]::FormC) -or + -not $portable.TryAdd($path, $path) -or + -not $normalized.TryAdd($path.Normalize([Text.NormalizationForm]::FormC), $path)) { + throw [InvalidOperationException]::new('The archive contains an unsafe or ambiguous entry path.') + } + $external = ([int64]$entry.ExternalAttributes) -band 0xffffffffL + $unixMode = ($external -shr 16) -band 0xffff + $unixType = $unixMode -band 0xf000 + $windowsAttributes = $external -band 0xffff + if (($windowsAttributes -band 0x0010) -ne 0 -or + ($windowsAttributes -band 0x0400) -ne 0 -or + ($unixType -ne 0 -and $unixType -ne 0x8000)) { + throw [InvalidOperationException]::new('The archive contains a link, reparse point, or non-regular entry.') + } + if ([long]$entry.Length -lt 0 -or [long]$entry.Length -gt $script:GraphKitAuthParityMaxEntryBytes) { + throw [InvalidOperationException]::new('An archive entry exceeds the protected size bound.') + } + $totalLength += [long]$entry.Length + if ($totalLength -gt $script:GraphKitAuthParityMaxTotalBytes) { + throw [InvalidOperationException]::new('The archive exceeds the protected total-size bound.') + } + if ([long]$entry.Length -gt $script:GraphKitAuthParityRatioThresholdBytes -and + ([long]$entry.CompressedLength -le 0 -or + [long]$entry.Length -gt + [long]$entry.CompressedLength * $script:GraphKitAuthParityMaxCompressionRatio)) { + throw [InvalidOperationException]::new('The archive entry compression ratio exceeds the protected bound.') + } + if (-not $files.Add($path) -or $directories.Contains($path)) { + throw [InvalidOperationException]::new('The archive file/directory closure is ambiguous.') + } + if ($segments.Count -gt 1) { + for ($index = 1; $index -lt $segments.Count; $index++) { + $directory = ($segments[0..($index - 1)] -join '/') + if ($files.Contains($directory)) { + throw [InvalidOperationException]::new('The archive file/directory prefix is ambiguous.') + } + $null = $directories.Add($directory) + } + } + $records.Add([pscustomobject]@{ + Path = $path + Length = [long]$entry.Length + Entry = $entry + }) + } + if (-not $files.Contains('GraphKit.psd1') -or -not $files.Contains('GraphKit.psm1')) { + throw [InvalidOperationException]::new('The archive does not contain the exact module entry points.') + } + return [pscustomobject]@{ + Records = $records.ToArray() + Files = @($files | Sort-Object) + Directories = @($directories | Sort-Object { + ($_ -split '/').Count + }, { $_ }) + TotalLength = $totalLength + } +} + +function Assert-GraphKitAuthParitySameIdentity { + param( + [Parameter(Mandatory)] $Expected, + [Parameter(Mandatory)] $Actual, + [Parameter(Mandatory)][bool] $Directory, + [switch] $RequireSealed, + [switch] $RequireContent + ) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { + [StringComparison]::Ordinal + } + if ([string]$Expected.NativeIdentity -cne [string]$Actual.NativeIdentity -or + -not [string]::Equals( + [string]$Expected.PhysicalPath, [string]$Actual.PhysicalPath, $comparison) -or + [bool]$Actual.IsDirectory -ne $Directory -or + [bool]$Actual.IsReparsePoint -or + (-not $Directory -and (-not [bool]$Actual.IsRegularFile -or + [long]$Expected.LinkCount -ne [long]$Actual.LinkCount -or + [long]$Actual.LinkCount -ne 1))) { + throw [InvalidOperationException]::new('A protected parity path changed physical identity.') + } + if ($RequireContent -and + ([long]$Expected.Length -ne [long]$Actual.Length -or + [string]$Expected.Sha256 -cne [string]$Actual.Sha256)) { + throw [InvalidOperationException]::new('A protected parity file changed content.') + } + if ($RequireSealed -and + -not (Test-GraphKitAuthParitySealedPermission -Evidence $Actual -Directory $Directory)) { + throw [InvalidOperationException]::new('A protected parity path is not sealed.') + } +} + +function Get-GraphKitAuthParityExpectedChildren { + param([Parameter(Mandatory)] $State) + $children = [Collections.Generic.Dictionary[string,Collections.Generic.List[string]]]::new( + [StringComparer]::Ordinal) + foreach ($relative in @($State.ExpectedDirectories) + @($State.ExpectedFiles)) { + $separator = $relative.LastIndexOf('/') + $parent = if ($separator -lt 0) { '' } else { $relative.Substring(0, $separator) } + $name = if ($separator -lt 0) { $relative } else { $relative.Substring($separator + 1) } + if (-not $children.ContainsKey($parent)) { + $children[$parent] = [Collections.Generic.List[string]]::new() + } + $children[$parent].Add($name) + } + return $children +} + +function Assert-GraphKitAuthParityExactClosure { + param([Parameter(Mandatory)] $State) + $expected = Get-GraphKitAuthParityExpectedChildren -State $State + foreach ($parent in @('') + @($State.ExpectedDirectories)) { + $parentPath = if ([string]::IsNullOrEmpty($parent)) { + $State.RootPath + } + else { + Join-Path $State.RootPath ($parent -replace '/', [IO.Path]::DirectorySeparatorChar) + } + $actualNames = @([IO.Directory]::EnumerateFileSystemEntries($parentPath) | + ForEach-Object { [IO.Path]::GetFileName($_) }) + Assert-GraphKitAuthParityPortableNameSet -Names $actualNames -Kind 'directory child' + $expectedNames = if ($expected.ContainsKey($parent)) { @($expected[$parent]) } else { @() } + if (($actualNames | Sort-Object -CaseSensitive) -join "`n" -cne + (($expectedNames | Sort-Object -CaseSensitive) -join "`n")) { + throw [InvalidOperationException]::new('The protected parity extraction closure changed.') + } + } +} + +function Assert-GraphKitAuthParityState { + param( + [Parameter(Mandatory)] $State, + [ValidateSet('Import','Cleanup')][string] $Purpose + ) + $native = $script:GraphKitAuthParityNativeType + $requireSealed = $Purpose -ceq 'Import' -or + ($Purpose -ceq 'Cleanup' -and [bool]$State.Sealed) + $parent = $native::InspectDirectory($State.TempParentParent, $State.TempParentName) + Assert-GraphKitAuthParitySameIdentity -Expected $State.TempParentEvidence -Actual $parent ` + -Directory $true + $root = $native::InspectDirectory($State.TempParentPath, $State.RootName) + Assert-GraphKitAuthParitySameIdentity -Expected $State.RootEvidence -Actual $root ` + -Directory $true -RequireSealed:$requireSealed + if (-not (Test-GraphKitAuthParityContainedPhysicalPath ` + -Root $State.TempParentEvidence.PhysicalPath -Candidate $root.PhysicalPath)) { + throw [InvalidOperationException]::new('The protected parity root escaped its parent.') + } + foreach ($relative in $State.ExpectedDirectories) { + $actual = $native::InspectDirectory($State.RootPath, $relative) + Assert-GraphKitAuthParitySameIdentity -Expected $State.DirectoryEvidence[$relative] ` + -Actual $actual -Directory $true -RequireSealed:$requireSealed + if (-not (Test-GraphKitAuthParityContainedPhysicalPath ` + -Root $root.PhysicalPath -Candidate $actual.PhysicalPath)) { + throw [InvalidOperationException]::new('A protected parity directory escaped its root.') + } + } + foreach ($relative in $State.ExpectedFiles) { + $actual = $native::InspectFile($State.RootPath, $relative) + Assert-GraphKitAuthParitySameIdentity -Expected $State.FileEvidence[$relative] ` + -Actual $actual -Directory $false -RequireSealed:$requireSealed -RequireContent + if (-not (Test-GraphKitAuthParityContainedPhysicalPath ` + -Root $root.PhysicalPath -Candidate $actual.PhysicalPath)) { + throw [InvalidOperationException]::new('A protected parity file escaped its root.') + } + } + Assert-GraphKitAuthParityExactClosure -State $State +} + +function Protect-GraphKitAuthParityFile { + param( + [Parameter(Mandatory)] $State, + [Parameter(Mandatory)][string] $Relative, + [AllowNull()] $Hooks + ) + if ($State.FilePermissionEvidence.ContainsKey($Relative)) { return } + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name BeforeSealFile ` + -Arguments @($State, $Relative) + $native = $script:GraphKitAuthParityNativeType + $path = Join-Path $State.RootPath ( + $Relative -replace '/', [IO.Path]::DirectorySeparatorChar) + $native::SetOwnerOnly($path, $false, $false) + $sealed = $native::InspectFile($State.RootPath, $Relative) + Assert-GraphKitAuthParitySameIdentity -Expected $State.FileEvidence[$Relative] ` + -Actual $sealed -Directory $false -RequireContent -RequireSealed + $State.FilePermissionEvidence[$Relative] = $sealed +} + +function Protect-GraphKitAuthParityDirectory { + param( + [Parameter(Mandatory)] $State, + [Parameter(Mandatory)][string] $Relative, + [AllowNull()] $Hooks + ) + if ($State.DirectoryPermissionEvidence.ContainsKey($Relative)) { return } + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name BeforeSealDirectory ` + -Arguments @($State, $Relative) + $native = $script:GraphKitAuthParityNativeType + $path = Join-Path $State.RootPath ( + $Relative -replace '/', [IO.Path]::DirectorySeparatorChar) + $native::SetOwnerOnly($path, $true, $false) + $sealed = $native::InspectDirectory($State.RootPath, $Relative) + Assert-GraphKitAuthParitySameIdentity -Expected $State.DirectoryEvidence[$Relative] ` + -Actual $sealed -Directory $true -RequireSealed + $State.DirectoryPermissionEvidence[$Relative] = $sealed +} + +function Protect-GraphKitAuthParityState { + param( + [Parameter(Mandatory)] $State, + [AllowNull()] $Hooks + ) + foreach ($relative in $State.ExpectedFiles) { + Protect-GraphKitAuthParityFile -State $State -Relative $relative -Hooks $Hooks + } + foreach ($relative in @($State.ExpectedDirectories | Sort-Object { + ($_ -split '/').Count + } -Descending)) { + Protect-GraphKitAuthParityDirectory -State $State -Relative $relative -Hooks $Hooks + } + if ($null -eq $State.RootPermissionEvidence) { + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name BeforeSealRoot -Arguments @($State) + $native = $script:GraphKitAuthParityNativeType + $native::SetOwnerOnly($State.RootPath, $true, $false) + $sealedRoot = $native::InspectDirectory($State.TempParentPath, $State.RootName) + Assert-GraphKitAuthParitySameIdentity -Expected $State.RootEvidence -Actual $sealedRoot ` + -Directory $true -RequireSealed + $State.RootPermissionEvidence = $sealedRoot + } + $State.Sealed = $true +} + +function Expand-GraphKitAuthParitySnapshot { + param( + [Parameter(Mandatory)] $State, + [AllowNull()] $Hooks + ) + $native = $script:GraphKitAuthParityNativeType + $snapshot = $native::InspectFile($State.RootPath, $script:GraphKitAuthParitySnapshotName) + $expectedSnapshot = $State.FileEvidence[$script:GraphKitAuthParitySnapshotName] + Assert-GraphKitAuthParitySameIdentity -Expected $expectedSnapshot -Actual $snapshot ` + -Directory $false + if ([long]$snapshot.Length -gt $script:GraphKitAuthParityMaxPackageBytes) { + throw [InvalidOperationException]::new('The package snapshot exceeds the protected bound.') + } + $snapshotBytes = $native::ReadFile($State.RootPath, $script:GraphKitAuthParitySnapshotName) + $capturedSha256 = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($snapshotBytes)).ToLowerInvariant() + if ([long]$snapshotBytes.LongLength -ne [long]$expectedSnapshot.Length -or + $capturedSha256 -cne [string]$expectedSnapshot.Sha256 -or + $capturedSha256 -cne [string]$State.CandidateSha256) { + throw [InvalidOperationException]::new( + 'The captured package snapshot bytes changed before archive validation.') + } + $memory = [IO.MemoryStream]::new($snapshotBytes, $false) + try { + $archive = [IO.Compression.ZipArchive]::new( + $memory, [IO.Compression.ZipArchiveMode]::Read, $false) + try { + $plan = Get-GraphKitAuthParityArchivePlan -Archive $archive + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name AfterArchivePlan ` + -Arguments @($State, $plan) + $moduleEvidence = $native::CreateDirectoryOwnerOnly( + $State.RootPath, $script:GraphKitAuthParityModuleName) + if (-not $native::HasInitialOwnerOnlyDirectoryAccess($moduleEvidence)) { + throw [InvalidOperationException]::new('The module root was not created owner-only.') + } + $State.DirectoryEvidence['module'] = $moduleEvidence + $State.ExpectedDirectories.Add('module') + foreach ($directory in $plan.Directories) { + $segments = @($directory -split '/') + $parentRelative = 'module' + foreach ($segment in $segments) { + $relative = "$parentRelative/$segment" + if (-not $State.DirectoryEvidence.ContainsKey($relative)) { + $parentPath = Join-Path $State.RootPath ( + $parentRelative -replace '/', [IO.Path]::DirectorySeparatorChar) + $created = $native::CreateDirectoryOwnerOnly($parentPath, $segment) + if (-not $native::HasInitialOwnerOnlyDirectoryAccess($created)) { + throw [InvalidOperationException]::new( + 'An archive directory was not created owner-only.') + } + $State.DirectoryEvidence[$relative] = $created + $State.ExpectedDirectories.Add($relative) + } + $parentRelative = $relative + } + } + foreach ($record in $plan.Records) { + $bytes = Read-GraphKitAuthParityArchiveEntry -Entry $record.Entry + $relative = "module/$($record.Path)" + $written = $native::WriteFileCreateNew($State.RootPath, $relative, $bytes, $true) + if (-not $native::HasInitialOwnerOnlyAccess($written.DestinationInitial) -or + [long]$written.Destination.Length -ne [long]$record.Length) { + throw [InvalidOperationException]::new( + 'An archive file was not created with its exact protected bytes.') + } + $State.FileEvidence[$relative] = $written.Destination + $State.ExpectedFiles.Add($relative) + Protect-GraphKitAuthParityFile -State $State -Relative $relative -Hooks $Hooks + } + } + finally { $archive.Dispose() } + } + finally { + $memory.Dispose() + [Array]::Clear($snapshotBytes, 0, $snapshotBytes.Length) + $snapshotBytes = $null + } + + Protect-GraphKitAuthParityState -State $State -Hooks $Hooks + $State.ModuleRoot = Join-Path $State.RootPath $script:GraphKitAuthParityModuleName + $State.ExtractedManifestPath = Join-Path $State.ModuleRoot 'GraphKit.psd1' + $State.ExtractedModulePath = Join-Path $State.ModuleRoot 'GraphKit.psm1' +} + +function Remove-GraphKitAuthParityState { + param( + [Parameter(Mandatory)] $State, + [AllowNull()] $Hooks + ) + Assert-GraphKitAuthParityState -State $State -Purpose Cleanup + $native = $script:GraphKitAuthParityNativeType + if (-not [bool]$State.Sealed) { + Protect-GraphKitAuthParityState -State $State -Hooks $Hooks + Assert-GraphKitAuthParityState -State $State -Purpose Cleanup + } + $native::SetOwnerOnly($State.RootPath, $true, $true) + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name OnCleanupRoot ` + -Arguments @($State, 'AfterWritable', $native) + $writableRoot = $native::InspectDirectory($State.TempParentPath, $State.RootName) + Assert-GraphKitAuthParitySameIdentity -Expected $State.RootEvidence -Actual $writableRoot ` + -Directory $true + foreach ($relative in @($State.ExpectedDirectories | Sort-Object { + ($_ -split '/').Count + })) { + $native::SetOwnerOnly((Join-Path $State.RootPath ( + $relative -replace '/', [IO.Path]::DirectorySeparatorChar)), $true, $true) + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name OnCleanupDirectory ` + -Arguments @($State, $relative, 'AfterWritable', $native) + $writableDirectory = $native::InspectDirectory($State.RootPath, $relative) + Assert-GraphKitAuthParitySameIdentity -Expected $State.DirectoryEvidence[$relative] ` + -Actual $writableDirectory -Directory $true + } + foreach ($relative in $State.ExpectedFiles) { + $path = Join-Path $State.RootPath ( + $relative -replace '/', [IO.Path]::DirectorySeparatorChar) + $expected = $State.FileEvidence[$relative] + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name OnCleanupFile ` + -Arguments @($State, $relative, 'BeforeWritable', $native) + $actual = $native::InspectFile($State.RootPath, $relative) + Assert-GraphKitAuthParitySameIdentity -Expected $expected ` + -Actual $actual -Directory $false -RequireSealed -RequireContent + $native::SetOwnerOnly($path, $false, $true) + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name OnCleanupFile ` + -Arguments @($State, $relative, 'AfterWritable', $native) + $reopened = $native::InspectFile($State.RootPath, $relative) + Assert-GraphKitAuthParitySameIdentity -Expected $expected -Actual $reopened ` + -Directory $false -RequireContent + [IO.File]::Delete($path) + } + foreach ($relative in @($State.ExpectedDirectories | Sort-Object { + ($_ -split '/').Count + } -Descending)) { + $path = Join-Path $State.RootPath ( + $relative -replace '/', [IO.Path]::DirectorySeparatorChar) + if ([IO.Directory]::EnumerateFileSystemEntries($path).GetEnumerator().MoveNext()) { + throw [InvalidOperationException]::new('A protected parity directory was not empty at cleanup.') + } + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name OnCleanupDirectory ` + -Arguments @($State, $relative, 'BeforeDelete', $native) + $deleteDirectory = $native::InspectDirectory($State.RootPath, $relative) + Assert-GraphKitAuthParitySameIdentity -Expected $State.DirectoryEvidence[$relative] ` + -Actual $deleteDirectory -Directory $true + [IO.Directory]::Delete($path, $false) + } + if ([IO.Directory]::EnumerateFileSystemEntries($State.RootPath).GetEnumerator().MoveNext()) { + throw [InvalidOperationException]::new('The protected parity root was not empty at cleanup.') + } + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name OnCleanupRoot ` + -Arguments @($State, 'BeforeDelete', $native) + $deleteRoot = $native::InspectDirectory($State.TempParentPath, $State.RootName) + Assert-GraphKitAuthParitySameIdentity -Expected $State.RootEvidence -Actual $deleteRoot ` + -Directory $true + [IO.Directory]::Delete($State.RootPath, $false) +} + +function Get-GraphKitAuthParityDescriptorRoute { + param( + [Parameter(Mandatory)][string] $ManifestRoot, + [Parameter(Mandatory)][string] $Mode + ) + $route = New-GraphKitAuthParityRoute -Mode $Mode + $descriptorPath = Join-Path $ManifestRoot 'Data/Operations/ManagedDevice.List.psd1' + $descriptor = Import-PowerShellDataFile -Path $descriptorPath -ErrorAction Stop + if ([int]$descriptor.SchemaVersion -ne 1 -or + [string]$descriptor.Type -cne $route.OperationType -or + [string]$descriptor.Operation -cne $route.Operation -or + [string]$descriptor.IdentityRequirement -cne 'Verified' -or + [string]$descriptor.PagingStrategy -cne 'NextLink' -or + [string]$descriptor.Method -cne 'GET' -or + [string]$descriptor.ReplayPolicy -cne 'Safe' -or + $Mode -cnotin @($descriptor.SupportedAuthModes)) { + throw [InvalidOperationException]::new('The package does not declare the protected parity route.') + } + return $route +} + +function Invoke-GraphKitAuthParityCaptured { + param( + [Parameter(Mandatory)][scriptblock] $Action, + [Parameter(Mandatory)][int] $ExpectedCount + ) + $records = @(& $Action 2>&1 3>&1 4>&1 5>&1 6>&1) + $streamRecords = @($records | Where-Object { + $_ -is [Management.Automation.ErrorRecord] -or + $_ -is [Management.Automation.WarningRecord] -or + $_ -is [Management.Automation.VerboseRecord] -or + $_ -is [Management.Automation.DebugRecord] -or + $_ -is [Management.Automation.InformationRecord] + }) + if ($streamRecords.Count -ne 0) { + throw [InvalidOperationException]::new( + 'A protected parity command wrote to a diagnostic stream.') + } + $success = @($records | Where-Object { + $_ -isnot [Management.Automation.ErrorRecord] -and + $_ -isnot [Management.Automation.WarningRecord] -and + $_ -isnot [Management.Automation.VerboseRecord] -and + $_ -isnot [Management.Automation.DebugRecord] -and + $_ -isnot [Management.Automation.InformationRecord] + }) + if ($success.Count -ne $ExpectedCount -or + ($ExpectedCount -eq 1 -and $null -eq $success[0])) { + throw [InvalidOperationException]::new( + 'A protected parity command returned an invalid result count.') + } + return $success +} + +function Get-GraphKitAuthParityDiagnostics { + param( + [Parameter(Mandatory)][Management.Automation.PSModuleInfo] $Module, + [Parameter(Mandatory)] $State + ) + $ModuleRoot = $State.ModuleRoot + $defaultContext = [Runtime.Loader.AssemblyLoadContext]::Default + $defaultMsalBefore = @($defaultContext.Assemblies | Where-Object { + $_.GetName().Name -ceq 'Microsoft.Identity.Client' + }) + $contracts = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -ceq 'GraphKit.Auth.Contracts' + }) + $hostResult = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { + & $Module { $script:GraphKitAuthHost } + } + $authHost = $hostResult[0] + if ($contracts.Count -ne 1 -or $null -eq $authHost) { + throw [InvalidOperationException]::new('The GraphKit.Auth contracts or host is not singular.') + } + $contractAssembly = $contracts[0] + $contractContext = [Runtime.Loader.AssemblyLoadContext]::GetLoadContext($contractAssembly) + $contractPath = Join-Path $ModuleRoot 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + $contractLocation = [IO.Path]::GetFullPath($contractAssembly.Location) + $logicalContractLocation = [IO.Path]::GetFullPath($contractPath) + $expectedContractLocation = [string]$State.FileEvidence[ + 'module/Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll'].PhysicalPath + $hostType = $authHost.GetType() + $marker = $hostType.GetField( + 'ContractMarker', [Reflection.BindingFlags]'Public,Static') + $providerField = $hostType.GetField( + '_providerAssembly', [Reflection.BindingFlags]'Instance,NonPublic') + $providerAssembly = if ($null -eq $providerField) { + $null + } + else { $providerField.GetValue($authHost) } + $providerContext = if ($null -eq $providerAssembly) { + $null + } + else { + [Runtime.Loader.AssemblyLoadContext]::GetLoadContext($providerAssembly) + } + $msalPath = Join-Path $ModuleRoot 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' + if ($null -eq $providerContext -or + [object]::ReferenceEquals($providerContext, $defaultContext)) { + throw [InvalidOperationException]::new('The provider load context was rejected.') + } + Assert-GraphKitAuthParityState -State $State -Purpose Import + $msalRelative = 'module/Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' + $msalEvidence = $script:GraphKitAuthParityNativeType::InspectFile( + $State.RootPath, $msalRelative) + Assert-GraphKitAuthParitySameIdentity -Expected $State.FileEvidence[$msalRelative] ` + -Actual $msalEvidence -Directory $false -RequireSealed -RequireContent + $providerMsalBefore = @($providerContext.Assemblies | Where-Object { + $_.GetName().Name -ceq 'Microsoft.Identity.Client' + }) + if ($providerMsalBefore.Count -eq 0) { + $null = $providerContext.LoadFromAssemblyPath([IO.Path]::GetFullPath($msalPath)) + } + $providerMsal = @($providerContext.Assemblies | Where-Object { + $_.GetName().Name -ceq 'Microsoft.Identity.Client' + }) + $defaultMsalAfter = @($defaultContext.Assemblies | Where-Object { + $_.GetName().Name -ceq 'Microsoft.Identity.Client' + }) + $defaultMsalUnchanged = $defaultMsalAfter.Count -eq $defaultMsalBefore.Count + if ($defaultMsalUnchanged) { + foreach ($assembly in $defaultMsalBefore) { + if (-not @($defaultMsalAfter | Where-Object { + [object]::ReferenceEquals($_, $assembly) + }).Count) { + $defaultMsalUnchanged = $false + break + } + } + } + $locationComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { + [StringComparison]::Ordinal + } + $publicAbiHash = Get-GraphKitAuthParityPublicAbiSha256 -Assembly $contractAssembly + $expectedProviderLocation = [string]$State.FileEvidence[ + 'module/Assemblies/GraphKit.Auth/GraphKit.Auth.dll'].PhysicalPath + $logicalProviderLocation = [IO.Path]::GetFullPath( + (Join-Path $ModuleRoot 'Assemblies/GraphKit.Auth/GraphKit.Auth.dll')) + $expectedMsalLocation = [string]$State.FileEvidence[$msalRelative].PhysicalPath + $logicalMsalLocation = [IO.Path]::GetFullPath($msalPath) + $checks = [pscustomobject][ordered]@{ + abiMarkerExact = $null -ne $marker -and + [string]$marker.GetValue($null) -ceq 'GraphKit.Auth.Abi/1' + contractsDefault = (Test-GraphKitAuthParityContractsIdentity ` + -Name $contractAssembly.GetName()) -and + [object]::ReferenceEquals($contractContext, $defaultContext) -and + ([string]::Equals( + $contractLocation, $expectedContractLocation, $locationComparison) -or + [string]::Equals( + $contractLocation, $logicalContractLocation, $locationComparison)) + providerCollectibleNonDefault = $null -ne $providerAssembly -and + $providerAssembly.GetName().Name -ceq 'GraphKit.Auth' -and + $null -ne $providerContext -and + -not [object]::ReferenceEquals($providerContext, $defaultContext) -and + [bool]$providerContext.IsCollectible -and + ([string]::Equals( + [IO.Path]::GetFullPath($providerAssembly.Location), + $expectedProviderLocation, $locationComparison) -or + [string]::Equals( + [IO.Path]::GetFullPath($providerAssembly.Location), + $logicalProviderLocation, $locationComparison)) + msalVersionExact = $providerMsal.Count -eq 1 -and + $providerMsal[0].GetName().Version -eq [version]'4.82.1.0' -and + ([string]::Equals( + [IO.Path]::GetFullPath($providerMsal[0].Location), + $expectedMsalLocation, $locationComparison) -or + [string]::Equals( + [IO.Path]::GetFullPath($providerMsal[0].Location), + $logicalMsalLocation, $locationComparison)) + providerMsalSameContext = $providerMsal.Count -eq 1 -and + [object]::ReferenceEquals( + [Runtime.Loader.AssemblyLoadContext]::GetLoadContext($providerMsal[0]), + $providerContext) -and $defaultMsalUnchanged + publicAbiExact = $publicAbiHash -ceq $script:GraphKitAuthParityExpectedPublicAbiSha256 + } + $providerWeakReference = $authHost.LoadContextWeakReference + $null = Assert-GraphKitAuthParityProviderWeakReference ` + -WeakReference $providerWeakReference -ProviderContext $providerContext + return [pscustomobject]@{ + Checks = $checks + InterfaceType = $contractAssembly.GetType('GraphKit.Auth.IGraphTokenSource', $true, $false) + ContractsAssembly = $contractAssembly + ProviderWeakReference = $providerWeakReference + } +} + +function Test-GraphKitAuthParityAcquisitionFailure { + param( + [Parameter(Mandatory)][Exception] $Exception, + [Parameter(Mandatory)][Reflection.Assembly] $ContractsAssembly + ) + $current = $Exception + for ($depth = 0; $depth -lt 8 -and $null -ne $current; $depth++) { + if ($current.GetType().FullName -ceq 'GraphKit.Auth.GraphAuthException' -and + [object]::ReferenceEquals($current.GetType().Assembly, $ContractsAssembly) -and + [string]$current.Category -ceq 'Acquisition') { + return $true + } + $current = $current.InnerException + } + return $false +} + +function Get-GraphKitAuthParityMember { + param( + [AllowNull()] $Value, + [Parameter(Mandatory)][string] $Name + ) + if ($null -eq $Value) { + return [pscustomobject]@{ Exists = $false; Value = $null } + } + if ($Value -is [Collections.IDictionary]) { + $exists = $Value.Contains($Name) + return [pscustomobject]@{ + Exists = $exists + Value = $(if ($exists) { $Value[$Name] } else { $null }) + } + } + $property = $Value.PSObject.Properties[$Name] + return [pscustomobject]@{ + Exists = $null -ne $property + Value = $(if ($null -ne $property) { $property.Value } else { $null }) + } +} + +function Test-GraphKitAuthParityClientScope { + param( + [AllowNull()] $ContextClientId, + [AllowNull()][string] $SourceClientId, + [Parameter(Mandatory)][string] $AuthMode + ) + $contextText = if ($null -eq $ContextClientId) { '' } else { [string]$ContextClientId } + $sourceText = if ($null -eq $SourceClientId) { '' } else { [string]$SourceClientId } + if ($AuthMode -ceq 'BearerToken') { + return [string]::IsNullOrEmpty($contextText) -and + [string]::IsNullOrEmpty($sourceText) + } + if ([string]::IsNullOrEmpty($contextText) -or [string]::IsNullOrEmpty($sourceText)) { + return $AuthMode -ceq 'ManagedIdentity' -and + [string]::IsNullOrEmpty($contextText) -and + [string]::IsNullOrEmpty($sourceText) + } + $contextGuid = [guid]::Empty + $sourceGuid = [guid]::Empty + return [guid]::TryParse($contextText, [ref]$contextGuid) -and + [guid]::TryParse($sourceText, [ref]$sourceGuid) -and + $contextGuid -ne [guid]::Empty -and $sourceGuid -ne [guid]::Empty -and + $contextGuid -eq $sourceGuid +} + +function Assert-GraphKitAuthParityLiveContext { + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] $Route, + [Parameter(Mandatory)] $Diagnostics, + [Parameter(Mandatory)][string] $RequestedProfileId + ) + $tenantId = if ($null -ne $Context.PSObject.Properties['TenantId'] -and + $Context.TenantId -is [guid]) { [guid]$Context.TenantId } else { [guid]::Empty } + $source = if ($null -ne $Context.PSObject.Properties['TokenSource']) { + $Context.TokenSource + } + else { $null } + $cloud = if ($null -ne $Context.PSObject.Properties['Cloud']) { + [string]$Context.Cloud + } + else { '' } + $graphBaseUri = if ($null -ne $Context.PSObject.Properties['GraphBaseUri'] -and + $Context.GraphBaseUri -is [uri]) { [uri]$Context.GraphBaseUri } else { $null } + $contextClientId = if ($null -ne $Context.PSObject.Properties['ClientId']) { + $Context.ClientId + } + else { $null } + $sourceGeneration = if ($null -ne $source -and + $null -ne $source.PSObject.Properties['CredentialGeneration']) { + [string]$source.CredentialGeneration + } + else { '' } + $expectedCredentialFingerprint = if ([string]::IsNullOrWhiteSpace($sourceGeneration)) { + '' + } + else { + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($sourceGeneration))).ToLowerInvariant() + } + $credentialFingerprint = if ( + $null -ne $Context.PSObject.Properties['CredentialFingerprint']) { + [string]$Context.CredentialFingerprint + } + else { '' } + $audience = if ($null -ne $source -and + $null -ne $source.PSObject.Properties['Audience']) { + [string]$source.Audience + } + else { '' } + $baseText = if ($null -eq $graphBaseUri) { '' } else { + $graphBaseUri.AbsoluteUri.TrimEnd('/') + } + $audienceUri = $null + try { $audienceUri = [uri]$audience } catch { $audienceUri = $null } + $audienceText = if ($null -eq $audienceUri -or -not $audienceUri.IsAbsoluteUri) { + '' + } + else { $audienceUri.AbsoluteUri.TrimEnd('/') } + if ($Context.PSObject.TypeNames.Count -lt 1 -or + [string]$Context.PSObject.TypeNames[0] -cne 'GraphKit.Context' -or + $null -eq $Context.PSObject.Properties['ProfileId'] -or + [string]$Context.ProfileId -cne $RequestedProfileId -or + $tenantId -eq [guid]::Empty -or + -not $Diagnostics.InterfaceType.IsInstanceOfType($source) -or + [string]$source.AuthMode -cne [string]$Route.AuthMode -or + [bool]$source.CanRefresh -ne [bool]$Route.CanRefresh -or + [string]::IsNullOrWhiteSpace($sourceGeneration) -or + [string]::IsNullOrWhiteSpace($cloud) -or + $null -eq $graphBaseUri -or -not $graphBaseUri.IsAbsoluteUri -or + -not [string]::Equals($baseText, $audienceText, [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-GraphKitAuthParityClientScope -ContextClientId $contextClientId ` + -SourceClientId ([string]$source.ClientId) -AuthMode ([string]$Route.AuthMode)) -or + [string]::IsNullOrWhiteSpace($credentialFingerprint) -or + $credentialFingerprint -cne $expectedCredentialFingerprint) { + throw [InvalidOperationException]::new('The protected parity context or source was rejected.') + } +} + +function Assert-GraphKitAuthParityLiveResult { + param( + [Parameter(Mandatory)] $Result, + [Parameter(Mandatory)] $Context + ) + if ($Result.PSObject.TypeNames.Count -lt 1 -or + [string]$Result.PSObject.TypeNames[0] -cne 'GraphKit.OperationResult' -or + $null -eq $Result.PSObject.Properties['Outcome'] -or + [string]$Result.Outcome -cne 'Succeeded' -or + $null -eq $Result.PSObject.Properties['Certainty'] -or + [string]$Result.Certainty -cne 'Known' -or + $null -eq $Result.PSObject.Properties['Truncated'] -or + $Result.Truncated -isnot [bool] -or [bool]$Result.Truncated -or + $null -eq $Result.PSObject.Properties['Data'] -or + $null -eq $Result.PSObject.Properties['Provenance'] -or + $null -eq $Result.Provenance) { + throw [InvalidOperationException]::new('The protected parity read envelope was rejected.') + } + $provenance = $Result.Provenance + $identityStateMember = Get-GraphKitAuthParityMember $provenance IdentityState + $tenantMember = Get-GraphKitAuthParityMember $provenance TenantId + $actualTenantMember = Get-GraphKitAuthParityMember $provenance ActualTenantId + $fingerprintMember = Get-GraphKitAuthParityMember $provenance TokenFingerprint + $generationMember = Get-GraphKitAuthParityMember $provenance CredentialGeneration + $cloudMember = Get-GraphKitAuthParityMember $provenance Cloud + $identityState = $identityStateMember.Value + $tenantId = $tenantMember.Value + $actualTenantId = $actualTenantMember.Value + $fingerprint = [string]$fingerprintMember.Value + $generation = [string]$generationMember.Value + $cloud = [string]$cloudMember.Value + $sourceTenantId = $Context.TokenSource.VerifiedTenantId + $parsedTenant = [guid]::Empty + $parsedActual = [guid]::Empty + $parsedSource = [guid]::Empty + $sourceGeneration = [string]$Context.TokenSource.CredentialGeneration + $sourceFingerprintMember = Get-GraphKitAuthParityMember ` + $Context.TokenSource TokenFingerprint + $resultFingerprintMember = Get-GraphKitAuthParityMember $Result TokenFingerprint + if (-not $identityStateMember.Exists -or -not $tenantMember.Exists -or + -not $actualTenantMember.Exists -or -not $fingerprintMember.Exists -or + -not $generationMember.Exists -or -not $cloudMember.Exists -or + [string]$identityState -cne 'VerifiedForToken' -or + -not [guid]::TryParse([string]$tenantId, [ref]$parsedTenant) -or + -not [guid]::TryParse([string]$actualTenantId, [ref]$parsedActual) -or + -not [guid]::TryParse([string]$sourceTenantId, [ref]$parsedSource) -or + $parsedTenant -eq [guid]::Empty -or $parsedActual -eq [guid]::Empty -or + $parsedSource -eq [guid]::Empty -or + $parsedTenant -ne [guid]$Context.TenantId -or + $parsedActual -ne [guid]$Context.TenantId -or + $parsedSource -ne [guid]$Context.TenantId -or + [string]::IsNullOrWhiteSpace($fingerprint) -or + [string]::IsNullOrWhiteSpace($generation) -or + [string]::IsNullOrWhiteSpace($cloud) -or + $generation -cne $sourceGeneration -or + $cloud -cne [string]$Context.Cloud -or + ($sourceFingerprintMember.Exists -and + ([string]::IsNullOrWhiteSpace([string]$sourceFingerprintMember.Value) -or + [string]$sourceFingerprintMember.Value -cne $fingerprint)) -or + ($resultFingerprintMember.Exists -and + ([string]::IsNullOrWhiteSpace([string]$resultFingerprintMember.Value) -or + [string]$resultFingerprintMember.Value -cne $fingerprint))) { + throw [InvalidOperationException]::new('The protected parity tenant proof was rejected.') + } + return [long]@($Result.Data).Count +} + +function Invoke-GraphKitAuthParityLiveCore { + param( + [Parameter(Mandatory)] $Route, + [Parameter(Mandatory)] $Diagnostics, + [Parameter(Mandatory)][string] $ProfileId, + [AllowNull()][string] $StorePath, + [Parameter(Mandatory)][bool] $StorePathBound, + [Parameter(Mandatory)][scriptblock] $GetContextAction, + [Parameter(Mandatory)][scriptblock] $ReadAction + ) + $core = [pscustomobject][ordered]@{ + recordKind = 'GraphKit.Task8.LiveCoreTestResult/1' + authMode = [string]$Route.AuthMode + state = 'Failed' + failureStage = 'Context' + failureCode = 'ContextRejected' + contextMatched = $false + sourceMatched = $false + tenantProofVerified = $false + readAttempted = $false + readSucceeded = $false + rowCount = [long]0 + } + $context = $null + $readResult = $null + try { + try { + $context = Invoke-GraphKitAuthParityHook -Hooks ([pscustomobject]@{ + Action = $GetContextAction + }) -Name Action -Arguments @( + $ProfileId, $(if ($StorePathBound) { $StorePath } else { $null }), $Route) -PassThru + Assert-GraphKitAuthParityLiveContext -Context $context -Route $Route ` + -Diagnostics $Diagnostics -RequestedProfileId $ProfileId + $core.contextMatched = $true + $core.sourceMatched = $true + } + catch { return $core } + + $core.failureStage = 'Read' + $core.failureCode = 'ReadFailed' + $core.readAttempted = $true + try { + $readResult = Invoke-GraphKitAuthParityHook -Hooks ([pscustomobject]@{ + Action = $ReadAction + }) -Name Action -Arguments @($context, 'ManagedDevice', 'List', $true) ` + -PassThru -PreserveExceptionType + } + catch { + if (Test-GraphKitAuthParityAcquisitionFailure -Exception $_.Exception ` + -ContractsAssembly $Diagnostics.ContractsAssembly) { + $core.failureStage = 'Acquisition' + $core.failureCode = 'AcquisitionFailed' + } + return $core + } + try { + $core.rowCount = Assert-GraphKitAuthParityLiveResult ` + -Result $readResult -Context $context + } + catch { return $core } + $core.readSucceeded = $true + $core.tenantProofVerified = $true + $core.state = 'Passed' + $core.failureStage = 'None' + $core.failureCode = 'None' + return $core + } + finally { + $readResult = $null + $context = $null + } +} + +function ConvertTo-GraphKitAuthParityWorkerEvidence { + param([Parameter(Mandatory)] $Evidence) + return [pscustomobject][ordered]@{ + RelativePath = [string]$Evidence.RelativePath + PhysicalPath = [string]$Evidence.PhysicalPath + NativeIdentity = [string]$Evidence.NativeIdentity + Sha256 = [string]$Evidence.Sha256 + Length = [long]$Evidence.Length + LinkCount = [long]$Evidence.LinkCount + UnixMode = [int]$Evidence.UnixMode + OwnerUid = [uint32]$Evidence.OwnerUid + EffectiveUid = [uint32]$Evidence.EffectiveUid + PermissionEvidence = [string]$Evidence.PermissionEvidence + IsDirectory = [bool]$Evidence.IsDirectory + IsRegularFile = [bool]$Evidence.IsRegularFile + IsReparsePoint = [bool]$Evidence.IsReparsePoint + OwnerWritable = [bool]$Evidence.OwnerWritable + OwnerSid = [string]$Evidence.OwnerSid + CurrentIdentitySid = [string]$Evidence.CurrentIdentitySid + CurrentOwnerSid = [string]$Evidence.CurrentOwnerSid + AccessRulesProtected = [bool]$Evidence.AccessRulesProtected + HasInheritedAccessRules = [bool]$Evidence.HasInheritedAccessRules + OwnerOnlyAccess = [bool]$Evidence.OwnerOnlyAccess + ExactOwnerOnlyAccess = [bool]$Evidence.ExactOwnerOnlyAccess + ExactWritableOwnerOnlyDirectoryAccess = + [bool]$Evidence.ExactWritableOwnerOnlyDirectoryAccess + FileReadOnly = [bool]$Evidence.FileReadOnly + } +} + +function ConvertFrom-GraphKitAuthParityWorkerEvidence { + param([Parameter(Mandatory)] $Evidence) + $names = @( + 'RelativePath','PhysicalPath','NativeIdentity','Sha256','Length','LinkCount','UnixMode', + 'OwnerUid','EffectiveUid','PermissionEvidence','IsDirectory','IsRegularFile', + 'IsReparsePoint','OwnerWritable','OwnerSid','CurrentIdentitySid','CurrentOwnerSid', + 'AccessRulesProtected','HasInheritedAccessRules','OwnerOnlyAccess', + 'ExactOwnerOnlyAccess','ExactWritableOwnerOnlyDirectoryAccess','FileReadOnly') + if (-not (Test-GraphKitAuthParityExactProperties -Value $Evidence -Names $names)) { + throw [InvalidOperationException]::new('The protected parity worker evidence schema is invalid.') + } + foreach ($name in @( + 'RelativePath','PhysicalPath','NativeIdentity','Sha256','PermissionEvidence','OwnerSid', + 'CurrentIdentitySid','CurrentOwnerSid')) { + if ($null -eq $Evidence.$name -or $Evidence.$name.GetType() -ne [string]) { + throw [InvalidOperationException]::new( + 'The protected parity worker evidence contains an invalid string.') + } + } + foreach ($name in @( + 'IsDirectory','IsRegularFile','IsReparsePoint','OwnerWritable','AccessRulesProtected', + 'HasInheritedAccessRules','OwnerOnlyAccess','ExactOwnerOnlyAccess', + 'ExactWritableOwnerOnlyDirectoryAccess','FileReadOnly')) { + if ($null -eq $Evidence.$name -or $Evidence.$name.GetType() -ne [bool]) { + throw [InvalidOperationException]::new( + 'The protected parity worker evidence contains an invalid Boolean.') + } + } + if ($Evidence.Length.GetType() -notin @([long],[int]) -or + $Evidence.LinkCount.GetType() -notin @([long],[int]) -or + $Evidence.UnixMode.GetType() -notin @([long],[int]) -or + $Evidence.OwnerUid.GetType() -notin @([long],[int]) -or + $Evidence.EffectiveUid.GetType() -notin @([long],[int])) { + throw [InvalidOperationException]::new( + 'The protected parity worker evidence contains an invalid integer.') + } + return [pscustomobject][ordered]@{ + RelativePath = [string]$Evidence.RelativePath + PhysicalPath = [string]$Evidence.PhysicalPath + NativeIdentity = [string]$Evidence.NativeIdentity + Sha256 = [string]$Evidence.Sha256 + Length = [long]$Evidence.Length + LinkCount = [long]$Evidence.LinkCount + UnixMode = [int]$Evidence.UnixMode + OwnerUid = [uint32]$Evidence.OwnerUid + EffectiveUid = [uint32]$Evidence.EffectiveUid + PermissionEvidence = [string]$Evidence.PermissionEvidence + IsDirectory = [bool]$Evidence.IsDirectory + IsRegularFile = [bool]$Evidence.IsRegularFile + IsReparsePoint = [bool]$Evidence.IsReparsePoint + OwnerWritable = [bool]$Evidence.OwnerWritable + OwnerSid = [string]$Evidence.OwnerSid + CurrentIdentitySid = [string]$Evidence.CurrentIdentitySid + CurrentOwnerSid = [string]$Evidence.CurrentOwnerSid + AccessRulesProtected = [bool]$Evidence.AccessRulesProtected + HasInheritedAccessRules = [bool]$Evidence.HasInheritedAccessRules + OwnerOnlyAccess = [bool]$Evidence.OwnerOnlyAccess + ExactOwnerOnlyAccess = [bool]$Evidence.ExactOwnerOnlyAccess + ExactWritableOwnerOnlyDirectoryAccess = + [bool]$Evidence.ExactWritableOwnerOnlyDirectoryAccess + FileReadOnly = [bool]$Evidence.FileReadOnly + } +} + +function New-GraphKitAuthParityWorkerRequest { + param( + [Parameter(Mandatory)] $State, + [Parameter(Mandatory)][string] $Nonce, + [Parameter(Mandatory)][string] $Execution, + [Parameter(Mandatory)][string] $Mode, + [AllowEmptyString()][string] $ProfileId, + [AllowEmptyString()][string] $StorePath, + [Parameter(Mandatory)][bool] $StorePathBound + ) + $fileEvidence = foreach ($relative in $State.ExpectedFiles) { + [pscustomobject][ordered]@{ + relativePath = [string]$relative + evidence = ConvertTo-GraphKitAuthParityWorkerEvidence ` + -Evidence $State.FileEvidence[$relative] + } + } + $directoryEvidence = foreach ($relative in $State.ExpectedDirectories) { + [pscustomobject][ordered]@{ + relativePath = [string]$relative + evidence = ConvertTo-GraphKitAuthParityWorkerEvidence ` + -Evidence $State.DirectoryEvidence[$relative] + } + } + return [pscustomobject][ordered]@{ + recordKind = $script:GraphKitAuthParityWorkerKind + nonce = $Nonce + execution = $Execution + authMode = $Mode + packageSha256 = [string]$State.CandidateSha256 + moduleVersion = [string]$State.ModuleVersion + profileId = $(if ($Execution -ceq 'Live') { $ProfileId } else { '' }) + storePathBound = $StorePathBound + storePath = $(if ($StorePathBound) { $StorePath } else { '' }) + state = [pscustomobject][ordered]@{ + tempParentPath = [string]$State.TempParentPath + tempParentParent = [string]$State.TempParentParent + tempParentName = [string]$State.TempParentName + tempParentEvidence = ConvertTo-GraphKitAuthParityWorkerEvidence ` + -Evidence $State.TempParentEvidence + rootName = [string]$State.RootName + rootPath = [string]$State.RootPath + rootEvidence = ConvertTo-GraphKitAuthParityWorkerEvidence ` + -Evidence $State.RootEvidence + moduleRoot = [string]$State.ModuleRoot + extractedManifestPath = [string]$State.ExtractedManifestPath + extractedModulePath = [string]$State.ExtractedModulePath + sealed = [bool]$State.Sealed + expectedFiles = [string[]]@($State.ExpectedFiles) + expectedDirectories = [string[]]@($State.ExpectedDirectories) + fileEvidence = [object[]]@($fileEvidence) + directoryEvidence = [object[]]@($directoryEvidence) + } + } +} + +function Assert-GraphKitAuthParityJsonHasNoDuplicateProperties { + param([Parameter(Mandatory)][Text.Json.JsonElement] $Element) + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + $names = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($property in $Element.EnumerateObject()) { + if (-not $names.Add($property.Name)) { + throw [InvalidOperationException]::new( + 'The protected parity worker JSON contains a duplicate property.') + } + Assert-GraphKitAuthParityJsonHasNoDuplicateProperties -Element $property.Value + } + } + elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { + Assert-GraphKitAuthParityJsonHasNoDuplicateProperties -Element $item + } + } +} + +function ConvertFrom-GraphKitAuthParityWorkerJson { + param( + [Parameter(Mandatory)][string] $Json, + [Parameter(Mandatory)][long] $MaximumBytes + ) + $utf8 = [Text.UTF8Encoding]::new($false, $true) + if ($utf8.GetByteCount($Json) -gt $MaximumBytes) { + throw [InvalidOperationException]::new('The protected parity worker JSON exceeded its bound.') + } + $document = [Text.Json.JsonDocument]::Parse($Json) + try { + if ($document.RootElement.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw [InvalidOperationException]::new( + 'The protected parity worker JSON root is invalid.') + } + Assert-GraphKitAuthParityJsonHasNoDuplicateProperties -Element $document.RootElement + } + finally { $document.Dispose() } + return $Json | ConvertFrom-Json -Depth 32 -NoEnumerate -ErrorAction Stop +} + +function ConvertFrom-GraphKitAuthParityWorkerState { + param([Parameter(Mandatory)] $Request) + $topNames = @( + 'recordKind','nonce','execution','authMode','packageSha256','moduleVersion','profileId', + 'storePathBound','storePath','state') + $stateNames = @( + 'tempParentPath','tempParentParent','tempParentName','tempParentEvidence','rootName', + 'rootPath','rootEvidence','moduleRoot','extractedManifestPath','extractedModulePath', + 'sealed','expectedFiles','expectedDirectories','fileEvidence','directoryEvidence') + if (-not (Test-GraphKitAuthParityExactProperties -Value $Request -Names $topNames) -or + -not (Test-GraphKitAuthParityExactProperties -Value $Request.state -Names $stateNames)) { + throw [InvalidOperationException]::new('The protected parity worker request schema is invalid.') + } + foreach ($name in @( + 'recordKind','nonce','execution','authMode','packageSha256','moduleVersion','profileId', + 'storePath')) { + if ($null -eq $Request.$name -or $Request.$name.GetType() -ne [string]) { + throw [InvalidOperationException]::new( + 'The protected parity worker request contains an invalid string.') + } + } + if ($Request.storePathBound.GetType() -ne [bool] -or + $Request.recordKind -cne $script:GraphKitAuthParityWorkerKind -or + $Request.nonce -cnotmatch '^[0-9a-f]{64}$' -or + $Request.execution -cnotin @('DryRun','Live') -or + $Request.authMode -cnotin $script:GraphKitAuthParityModes -or + $Request.packageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $Request.moduleVersion -cnotmatch '^\d+\.\d+\.\d+-[0-9A-Za-z][0-9A-Za-z.-]*$' -or + ($Request.execution -ceq 'DryRun' -and + (-not [string]::IsNullOrEmpty($Request.profileId) -or + [bool]$Request.storePathBound -or + -not [string]::IsNullOrEmpty($Request.storePath))) -or + ($Request.execution -ceq 'Live' -and + $Request.profileId -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}$') -or + ([bool]$Request.storePathBound -ne + (-not [string]::IsNullOrEmpty([string]$Request.storePath))) -or + $Request.state.sealed.GetType() -ne [bool] -or -not [bool]$Request.state.sealed) { + throw [InvalidOperationException]::new('The protected parity worker request scalar is invalid.') + } + foreach ($collectionName in @( + 'expectedFiles','expectedDirectories','fileEvidence','directoryEvidence')) { + if ($Request.state.$collectionName -isnot [Array]) { + throw [InvalidOperationException]::new( + 'The protected parity worker request collection shape is invalid.') + } + } + foreach ($name in @( + 'tempParentPath','tempParentParent','tempParentName','rootName','rootPath','moduleRoot', + 'extractedManifestPath','extractedModulePath')) { + if ($null -eq $Request.state.$name -or + $Request.state.$name.GetType() -ne [string] -or + [string]::IsNullOrWhiteSpace([string]$Request.state.$name)) { + throw [InvalidOperationException]::new( + 'The protected parity worker state contains an invalid path component.') + } + } + $expectedFiles = [Collections.Generic.List[string]]::new() + $expectedDirectories = [Collections.Generic.List[string]]::new() + foreach ($value in @($Request.state.expectedFiles)) { + if ($value.GetType() -ne [string] -or [string]::IsNullOrWhiteSpace($value)) { + throw [InvalidOperationException]::new('The protected parity worker file set is invalid.') + } + $expectedFiles.Add([string]$value) + } + foreach ($value in @($Request.state.expectedDirectories)) { + if ($value.GetType() -ne [string] -or [string]::IsNullOrWhiteSpace($value)) { + throw [InvalidOperationException]::new( + 'The protected parity worker directory set is invalid.') + } + $expectedDirectories.Add([string]$value) + } + if ($expectedFiles.Count -eq 0 -or $expectedDirectories.Count -eq 0 -or + ([Collections.Generic.HashSet[string]]::new( + [string[]]$expectedFiles, [StringComparer]::Ordinal)).Count -ne $expectedFiles.Count -or + ([Collections.Generic.HashSet[string]]::new( + [string[]]$expectedDirectories, [StringComparer]::Ordinal)).Count -ne + $expectedDirectories.Count) { + throw [InvalidOperationException]::new('The protected parity worker expected set is invalid.') + } + $files = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal) + foreach ($entry in @($Request.state.fileEvidence)) { + if (-not (Test-GraphKitAuthParityExactProperties -Value $entry ` + -Names @('relativePath','evidence')) -or + $entry.relativePath.GetType() -ne [string] -or + -not $expectedFiles.Contains([string]$entry.relativePath)) { + throw [InvalidOperationException]::new('The protected parity worker file evidence is invalid.') + } + $evidence = ConvertFrom-GraphKitAuthParityWorkerEvidence -Evidence $entry.evidence + if ([string]$evidence.RelativePath -cne [string]$entry.relativePath) { + throw [InvalidOperationException]::new( + 'The protected parity worker file evidence path is invalid.') + } + $files.Add([string]$entry.relativePath, $evidence) + } + $directories = [Collections.Generic.Dictionary[string,object]]::new( + [StringComparer]::Ordinal) + foreach ($entry in @($Request.state.directoryEvidence)) { + if (-not (Test-GraphKitAuthParityExactProperties -Value $entry ` + -Names @('relativePath','evidence')) -or + $entry.relativePath.GetType() -ne [string] -or + -not $expectedDirectories.Contains([string]$entry.relativePath)) { + throw [InvalidOperationException]::new( + 'The protected parity worker directory evidence is invalid.') + } + $evidence = ConvertFrom-GraphKitAuthParityWorkerEvidence -Evidence $entry.evidence + $expectedLeaf = [IO.Path]::GetFileName( + ([string]$entry.relativePath -replace '/', [IO.Path]::DirectorySeparatorChar)) + if ([string]$evidence.RelativePath -cne $expectedLeaf) { + throw [InvalidOperationException]::new( + 'The protected parity worker directory evidence path is invalid.') + } + $directories.Add([string]$entry.relativePath, $evidence) + } + if ($files.Count -ne $expectedFiles.Count -or + $directories.Count -ne $expectedDirectories.Count) { + throw [InvalidOperationException]::new( + 'The protected parity worker evidence set is incomplete.') + } + $snapshotEvidence = $files[$script:GraphKitAuthParitySnapshotName] + if ($null -eq $snapshotEvidence -or + [string]$snapshotEvidence.Sha256 -cne [string]$Request.packageSha256) { + throw [InvalidOperationException]::new( + 'The protected parity worker package digest binding is invalid.') + } + $pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { [StringComparison]::Ordinal } + $tempParentEvidence = ConvertFrom-GraphKitAuthParityWorkerEvidence ` + -Evidence $Request.state.tempParentEvidence + $rootEvidence = ConvertFrom-GraphKitAuthParityWorkerEvidence ` + -Evidence $Request.state.rootEvidence + $derivedTempParentParent = [IO.Path]::GetFullPath( + [string]$Request.state.tempParentParent) + $derivedTempParent = [IO.Path]::GetFullPath((Join-Path ` + $derivedTempParentParent $Request.state.tempParentName)) + $derivedRoot = [IO.Path]::GetFullPath((Join-Path ` + $derivedTempParent $Request.state.rootName)) + $derivedModule = [IO.Path]::GetFullPath((Join-Path ` + $derivedRoot $script:GraphKitAuthParityModuleName)) + $derivedManifest = [IO.Path]::GetFullPath((Join-Path $derivedModule 'GraphKit.psd1')) + $derivedModuleScript = [IO.Path]::GetFullPath((Join-Path $derivedModule 'GraphKit.psm1')) + if ([IO.Path]::IsPathRooted([string]$Request.state.tempParentName) -or + [IO.Path]::GetFileName([string]$Request.state.tempParentName) -cne + [string]$Request.state.tempParentName -or + [string]$Request.state.rootName -cnotmatch '^graphkit-task8-[0-9a-f]{32}$' -or + [string]$tempParentEvidence.RelativePath -cne [string]$Request.state.tempParentName -or + [string]$rootEvidence.RelativePath -cne [string]$Request.state.rootName -or + -not [string]::Equals( + $derivedTempParentParent, [string]$Request.state.tempParentParent, + $pathComparison) -or + -not [string]::Equals( + $derivedTempParent, [string]$Request.state.tempParentPath, + $pathComparison) -or + -not [string]::Equals( + $derivedRoot, [string]$Request.state.rootPath, $pathComparison) -or + -not [string]::Equals( + $derivedModule, [string]$Request.state.moduleRoot, $pathComparison) -or + -not [string]::Equals( + $derivedManifest, [string]$Request.state.extractedManifestPath, + $pathComparison) -or + -not [string]::Equals( + $derivedModuleScript, [string]$Request.state.extractedModulePath, + $pathComparison)) { + throw [InvalidOperationException]::new( + 'The protected parity worker state path derivation was rejected.') + } + return [pscustomobject]@{ + Request = $Request + State = [pscustomobject]@{ + TempParentPath = $derivedTempParent + TempParentParent = $derivedTempParentParent + TempParentName = [string]$Request.state.tempParentName + TempParentEvidence = $tempParentEvidence + RootName = [string]$Request.state.rootName + RootPath = $derivedRoot + RootEvidence = $rootEvidence + RootPermissionEvidence = $null + CandidateSha256 = [string]$Request.packageSha256 + SnapshotPath = Join-Path $derivedRoot $script:GraphKitAuthParitySnapshotName + ModuleRoot = $derivedModule + ExtractedManifestPath = $derivedManifest + ExtractedModulePath = $derivedModuleScript + ImportedManifestPath = $null + ImportedModulePath = $null + ModuleVersion = [string]$Request.moduleVersion + Sealed = $true + FileEvidence = $files + FilePermissionEvidence = [Collections.Generic.Dictionary[string,object]]::new( + [StringComparer]::Ordinal) + DirectoryEvidence = $directories + DirectoryPermissionEvidence = [Collections.Generic.Dictionary[string,object]]::new( + [StringComparer]::Ordinal) + ExpectedFiles = $expectedFiles + ExpectedDirectories = $expectedDirectories + } + } +} + +function Test-GraphKitAuthParityWorkerResult { + param( + [Parameter(Mandatory)] $Result, + [Parameter(Mandatory)] $Request, + [Parameter(Mandatory)][string] $RequestSha256 + ) + $names = @( + 'recordKind','nonce','requestSha256','execution','authMode','packageSha256', + 'moduleVersion','state','failureStage','failureCode','exactImport','adapter', + 'contextMatched','sourceMatched','tenantProofVerified','readAttempted','readSucceeded', + 'rowCount','workerTeardownVerified') + if (-not (Test-GraphKitAuthParityExactProperties -Value $Result -Names $names) -or + $Result.recordKind.GetType() -ne [string] -or + $Result.recordKind -cne $script:GraphKitAuthParityWorkerResultKind -or + $Result.nonce.GetType() -ne [string] -or $Result.nonce -cne [string]$Request.nonce -or + $Result.requestSha256.GetType() -ne [string] -or + $Result.requestSha256 -cne $RequestSha256 -or + $Result.execution.GetType() -ne [string] -or + $Result.execution -cne [string]$Request.execution -or + $Result.authMode.GetType() -ne [string] -or + $Result.authMode -cne [string]$Request.authMode -or + $Result.packageSha256.GetType() -ne [string] -or + $Result.packageSha256 -cne [string]$Request.packageSha256 -or + $Result.moduleVersion.GetType() -ne [string] -or + $Result.moduleVersion -cne [string]$Request.moduleVersion -or + $Result.state.GetType() -ne [string] -or + $Result.state -cnotin @('Passed','Failed') -or + $Result.failureStage.GetType() -ne [string] -or + $Result.failureStage -cnotin $script:GraphKitAuthParityFailureStages -or + $Result.failureCode.GetType() -ne [string] -or + $Result.failureCode -cnotin $script:GraphKitAuthParityFailureCodes) { + throw [InvalidOperationException]::new('The protected parity worker result scalar is invalid.') + } + foreach ($name in @( + 'exactImport','contextMatched','sourceMatched','tenantProofVerified','readAttempted', + 'readSucceeded','workerTeardownVerified')) { + if ($Result.$name.GetType() -ne [bool]) { + throw [InvalidOperationException]::new( + 'The protected parity worker result contains an invalid Boolean.') + } + } + if ($Result.rowCount.GetType() -ne [long] -or [long]$Result.rowCount -lt 0 -or + -not (Test-GraphKitAuthParityExactProperties -Value $Result.adapter ` + -Names $script:GraphKitAuthParityAdapterChecks)) { + throw [InvalidOperationException]::new('The protected parity worker result shape is invalid.') + } + foreach ($property in $Result.adapter.PSObject.Properties) { + if ($property.Value.GetType() -ne [bool]) { + throw [InvalidOperationException]::new( + 'The protected parity worker adapter result is invalid.') + } + } + $failureMap = @{ + Import='ImportRejected'; Context='ContextRejected'; Acquisition='AcquisitionFailed' + Read='ReadFailed'; Diagnostics='DiagnosticsRejected'; Cleanup='CleanupFailed' + } + if (($Result.state -ceq 'Passed') -ne + ($Result.failureStage -ceq 'None' -and $Result.failureCode -ceq 'None') -or + ($Result.state -ceq 'Failed' -and + (-not $failureMap.ContainsKey([string]$Result.failureStage) -or + $failureMap[[string]$Result.failureStage] -cne [string]$Result.failureCode)) -or + ([bool]$Result.workerTeardownVerified -ne + ([string]$Result.failureStage -cne 'Cleanup')) -or + ($Result.state -ceq 'Passed' -and + (-not [bool]$Result.exactImport -or + @($Result.adapter.PSObject.Properties.Value | Where-Object { -not $_ }).Count -ne 0)) -or + ($Result.execution -ceq 'DryRun' -and + ($Result.contextMatched -or $Result.sourceMatched -or + $Result.tenantProofVerified -or $Result.readAttempted -or + $Result.readSucceeded -or [long]$Result.rowCount -ne 0)) -or + ($Result.execution -ceq 'Live' -and $Result.state -ceq 'Passed' -and + (-not $Result.contextMatched -or -not $Result.sourceMatched -or + -not $Result.tenantProofVerified -or -not $Result.readAttempted -or + -not $Result.readSucceeded))) { + throw [InvalidOperationException]::new('The protected parity worker result is inconsistent.') + } + foreach ($value in @( + $Result.recordKind,$Result.nonce,$Result.requestSha256,$Result.execution,$Result.authMode, + $Result.packageSha256,$Result.moduleVersion,$Result.state,$Result.failureStage, + $Result.failureCode)) { + if (Test-GraphKitAuthParityForbiddenString -Value $value) { + throw [InvalidOperationException]::new( + 'The protected parity worker result contains a forbidden string.') + } + } + return $true +} + +# This lifecycle boundary is not a hostile-process sandbox. On Windows the +# kill-on-close Job Object and active-process count prove this exact job empty. +# On Unix the trusted worker and descendants that remain in its new session and +# process group are bounded there; stdout/stderr EOF is an additional cleanup +# gate. A deliberate setsid/setpgid escape with closed IPC is outside the stated +# same-identity, non-adversarial boundary. Retained IPC prevents confirmation and +# preserves the sealed stage rather than authorizing cleanup. +function Initialize-GraphKitAuthParityProcessTreeNative { + if ($null -ne $script:GraphKitAuthParityProcessTreeType) { return } + $namespaceMarker = '__GRAPHKIT_AUTH_PARITY_PROCESS_TREE_NAMESPACE__' + $sourceTemplate = @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using Microsoft.Win32.SafeHandles; +using System.Runtime.InteropServices; + +namespace __GRAPHKIT_AUTH_PARITY_PROCESS_TREE_NAMESPACE__ +{ + public sealed class GraphKitAuthParityProcessTreeLease : IDisposable + { + public const string ContractMarker = "GraphKit.Task8.ProcessTree/1"; + private const uint JobObjectLimitKillOnJobClose = 0x00002000; + private const int JobObjectBasicAccountingInformationClass = 1; + private const int JobObjectExtendedLimitInformationClass = 9; + private const int SigTerm = 15; + private const int SigKill = 9; + private const int Esrch = 3; + private const int Eperm = 1; + + private SafeJobHandle _job; + private int _processId; + private bool _assigned; + private bool _ownershipEstablished; + private bool _emptyConfirmed; + private bool _disposed; + + private GraphKitAuthParityProcessTreeLease(SafeJobHandle job) + { + _job = job; + } + + public static GraphKitAuthParityProcessTreeLease Create() + { + if (!OperatingSystem.IsWindows()) + return new GraphKitAuthParityProcessTreeLease(null); + + SafeJobHandle job = CreateJobObjectW(IntPtr.Zero, null); + if (job == null || job.IsInvalid) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + try + { + var limits = new JobObjectExtendedLimitInformation(); + limits.BasicLimitInformation.LimitFlags = JobObjectLimitKillOnJobClose; + int size = Marshal.SizeOf(); + IntPtr memory = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, memory, false); + if (!SetInformationJobObject( + job, + JobObjectExtendedLimitInformationClass, + memory, + (uint)size)) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + finally + { + Marshal.FreeHGlobal(memory); + } + return new GraphKitAuthParityProcessTreeLease(job); + } + catch + { + job.Dispose(); + throw; + } + } + + public static void EnterUnixWorkerSession() + { + if (OperatingSystem.IsWindows()) return; + int pid = Environment.ProcessId; + int group = getpgid(0); + if (group < 0) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + int session = getsid(0); + if (session < 0) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + if (group == pid && session == pid) return; + + int createdSession = setsid(); + if (createdSession < 0) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + int establishedGroup = getpgid(0); + if (establishedGroup < 0) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + int establishedSession = getsid(0); + if (establishedSession < 0) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + if (createdSession != pid || establishedGroup != pid || establishedSession != pid) + throw new InvalidOperationException("The protected parity worker session was not exact."); + } + + public void Assign(Process process) + { + if (process == null) throw new ArgumentNullException(nameof(process)); + if (_disposed || _assigned) throw new InvalidOperationException("Process-tree lease state is invalid."); + _processId = process.Id; + if (OperatingSystem.IsWindows()) + { + if (_job == null || _job.IsInvalid || _job.IsClosed) + throw new InvalidOperationException("The protected parity job is unavailable."); + if (!AssignProcessToJobObject(_job, process.Handle)) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + if (!IsProcessInJob(process.Handle, _job, out bool assigned)) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + if (!assigned) + throw new InvalidOperationException("The protected parity worker is outside its job."); + _ownershipEstablished = true; + } + _assigned = true; + } + + public bool IsOwnershipEstablished() + { + if (!_assigned || _disposed || _processId <= 1) return false; + if (OperatingSystem.IsWindows()) return _ownershipEstablished; + if (_ownershipEstablished) return true; + int group = getpgid(_processId); + if (group < 0) + { + int error = Marshal.GetLastPInvokeError(); + if (error == Esrch) return false; + throw new Win32Exception(error); + } + int session = getsid(_processId); + if (session < 0) + { + int error = Marshal.GetLastPInvokeError(); + if (error == Esrch) return false; + throw new Win32Exception(error); + } + if (group == _processId && session == _processId) + _ownershipEstablished = true; + return _ownershipEstablished; + } + + public bool IsTreeEmpty() + { + if (!_assigned || !_ownershipEstablished || _disposed || _processId <= 1) + return false; + if (_emptyConfirmed) return true; + if (OperatingSystem.IsWindows()) + { + if (_job == null || _job.IsInvalid || _job.IsClosed) + throw new InvalidOperationException("The protected parity job is unavailable."); + if (!QueryInformationJobObject( + _job, + JobObjectBasicAccountingInformationClass, + out JobObjectBasicAccounting info, + (uint)Marshal.SizeOf(), + IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastPInvokeError()); + _emptyConfirmed = info.ActiveProcesses == 0; + return _emptyConfirmed; + } + int result = kill(-_processId, 0); + if (result == 0) return false; + int error = Marshal.GetLastPInvokeError(); + if (error == Esrch) + { + _emptyConfirmed = true; + return true; + } + if (error == Eperm) return false; + throw new Win32Exception(error); + } + + public bool RequestTerminate() + { + return RequestSignal(SigTerm); + } + + public bool RequestKill() + { + return RequestSignal(SigKill); + } + + private bool RequestSignal(int signal) + { + if (!_assigned || !_ownershipEstablished || _disposed || _processId <= 1) + throw new InvalidOperationException("Process-tree ownership is not established."); + if (IsTreeEmpty()) return false; + if (OperatingSystem.IsWindows()) + { + if (!TerminateJobObject(_job, 1)) + { + int error = Marshal.GetLastPInvokeError(); + if (IsTreeEmpty()) return false; + throw new Win32Exception(error); + } + return true; + } + int result = kill(-_processId, signal); + if (result == 0) return true; + int signalError = Marshal.GetLastPInvokeError(); + if (signalError == Esrch) + { + _emptyConfirmed = true; + return false; + } + if (signalError == Eperm) + throw new UnauthorizedAccessException("The protected parity process group refused termination."); + throw new Win32Exception(signalError); + } + + public void Dispose() + { + if (_disposed) return; + if (OperatingSystem.IsWindows()) + { + if (_job != null) _job.Dispose(); + } + else if (_assigned && _ownershipEstablished && !_emptyConfirmed && _processId > 1) + { + try { RequestKill(); } catch { } + } + _disposed = true; + } + + private sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid + { + private SafeJobHandle() : base(true) { } + protected override bool ReleaseHandle() { return CloseHandle(handle); } + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicLimitInformation + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectExtendedLimitInformation + { + public JobObjectBasicLimitInformation BasicLimitInformation; + public IoCounters IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicAccounting + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeJobHandle job, int infoClass, IntPtr info, uint infoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsProcessInJob(IntPtr process, SafeJobHandle job, out bool result); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeJobHandle job, int infoClass, out JobObjectBasicAccounting info, + uint infoLength, IntPtr returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeJobHandle job, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("libc", SetLastError = true)] + private static extern int setsid(); + + [DllImport("libc", SetLastError = true)] + private static extern int getpgid(int processId); + + [DllImport("libc", SetLastError = true)] + private static extern int getsid(int processId); + + [DllImport("libc", SetLastError = true)] + private static extern int kill(int processId, int signal); + } +} +'@ + if (($sourceTemplate.Split( + [string[]]@($namespaceMarker), [StringSplitOptions]::None).Length - 1) -ne 1) { + throw [InvalidOperationException]::new( + 'The protected parity process-tree namespace marker is invalid.') + } + $sourceHash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData( + [Text.UTF8Encoding]::new($false, $true).GetBytes($sourceTemplate))).ToLowerInvariant() + $namespace = "GraphKit.R8.Parity.H$sourceHash" + $expectedType = "$namespace.GraphKitAuthParityProcessTreeLease" + $existing = $expectedType -as [type] + if ($null -ne $existing) { + if ([string]$existing::ContractMarker -cne 'GraphKit.Task8.ProcessTree/1') { + throw [InvalidOperationException]::new( + 'A stale protected parity process-tree type is already loaded.') + } + $script:GraphKitAuthParityProcessTreeType = $existing + return + } + $source = $sourceTemplate.Replace($namespaceMarker, $namespace) + $types = @(Add-Type -TypeDefinition $source -PassThru -ErrorAction Stop) + $match = @($types | Where-Object FullName -CEQ $expectedType) + $loadedType = if ($match.Count -eq 1) { $match[0] } else { $null } + if ($null -eq $loadedType -or + [string]$loadedType::ContractMarker -cne 'GraphKit.Task8.ProcessTree/1') { + throw [InvalidOperationException]::new( + 'The protected parity process-tree helper did not load exactly once.') + } + $script:GraphKitAuthParityProcessTreeType = $loadedType +} + +function Add-GraphKitAuthParityBoundedBytes { + param( + [Parameter(Mandatory)][AllowEmptyCollection()] + [Collections.Generic.List[byte]] $Destination, + [Parameter(Mandatory)][byte[]] $Buffer, + [Parameter(Mandatory)][int] $Count, + [Parameter(Mandatory)][long] $MaximumBytes + ) + if ($Count -lt 0 -or [long]$Destination.Count + $Count -gt $MaximumBytes) { + throw [InvalidOperationException]::new( + 'A protected parity worker stream exceeded its byte bound.') + } + if ($Count -eq 0) { return } + $chunk = [byte[]]::new($Count) + [Array]::Copy($Buffer, 0, $chunk, 0, $Count) + $Destination.AddRange($chunk) +} + +function Invoke-GraphKitAuthParityWorkerProcess { + param( + [Parameter(Mandatory)][string] $WorkerPath, + [Parameter(Mandatory)][string] $RequestJson, + [Parameter(Mandatory)] $Request, + [Parameter(Mandatory)][int] $TimeoutSeconds, + [AllowNull()] $Hooks + ) + $utf8 = [Text.UTF8Encoding]::new($false, $true) + $requestBytes = $utf8.GetBytes($RequestJson) + if ($requestBytes.LongLength -gt $script:GraphKitAuthParityMaxWorkerRequestBytes) { + throw [InvalidOperationException]::new('The protected parity worker request exceeded its bound.') + } + $requestSha256 = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($requestBytes)).ToLowerInvariant() + $worker = [IO.Path]::GetFullPath($WorkerPath) + if (-not [IO.File]::Exists($worker) -or [string]::IsNullOrWhiteSpace([Environment]::ProcessPath)) { + throw [InvalidOperationException]::new('The protected parity worker executable is unavailable.') + } + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = [Environment]::ProcessPath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @('-NoLogo','-NoProfile','-NonInteractive','-File',$worker)) { + $null = $startInfo.ArgumentList.Add($argument) + } + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name ConfigureWorkerStartInfo ` + -Arguments @($startInfo, $worker) + + Initialize-GraphKitAuthParityProcessTreeNative + $treeType = $script:GraphKitAuthParityProcessTreeType + $treeLease = $treeType::Create() + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $started = $false + $ownershipEstablished = $false + $requestReleased = $false + $rootExitConfirmed = $false + $treeExitConfirmed = $false + $streamsDrained = $false + $treeEmpty = $false + $timedOut = $false + $terminationRequested = $false + $residualTreeDetected = $false + $workerProcessId = 0 + $stdoutBytes = [Collections.Generic.List[byte]]::new() + $stderrBytes = [Collections.Generic.List[byte]]::new() + $streamFailure = 'None' + $protocolFailure = 'None' + $workerFailurePoint = 'Start' + $fatalPostStartFailure = $false + $postStartHookInvoked = $false + $rootExitHookFired = $false + $treeExitHookFired = $false + $stdoutComplete = $false + $stderrComplete = $false + $writeComplete = $false + $writeFailed = $false + $overflow = $false + $stdoutCaptureDisabled = $false + $stderrCaptureDisabled = $false + $terminateSent = $false + $killSent = $false + $terminationStartedMilliseconds = 0L + $stdoutTask = $null + $stderrTask = $null + $writeTask = $null + $stdoutBuffer = [byte[]]::new(4096) + $stderrBuffer = [byte[]]::new(4096) + $clock = $null + $operationDeadlineMilliseconds = [long]$TimeoutSeconds * 1000L + # Termination is not complete until the root is reaped, the owned tree is empty, + # and both redirected streams reach EOF. Preserve enough proof time for those + # observations even when a short test deadline expires under scheduler pressure. + $teardownAllowanceMilliseconds = [Math]::Min( + 10000L, [Math]::Max(5000L, [long]($operationDeadlineMilliseconds / 4L))) + $hardDeadlineMilliseconds = + $operationDeadlineMilliseconds + $teardownAllowanceMilliseconds + $workerResult = $null + try { + try { + $clock = [Diagnostics.Stopwatch]::StartNew() + if (-not $process.Start()) { + throw [InvalidOperationException]::new('The protected parity worker did not start.') + } + $started = $true + $workerProcessId = $process.Id + + # The containment owner exists before Start. Assign the new root before + # releasing request bytes; the trusted worker performs no candidate work + # until its exact session/job ownership is observed by this parent. + $workerFailurePoint = 'TreeAssignment' + $treeLease.Assign($process) + $workerFailurePoint = 'CollectorSetup' + $stdoutTask = $process.StandardOutput.BaseStream.ReadAsync( + $stdoutBuffer, 0, $stdoutBuffer.Length) + $stderrTask = $process.StandardError.BaseStream.ReadAsync( + $stderrBuffer, 0, $stderrBuffer.Length) + } + catch { + if (-not $started) { throw } + $fatalPostStartFailure = $true + if ($null -eq $stdoutTask) { + try { + $stdoutTask = $process.StandardOutput.BaseStream.ReadAsync( + $stdoutBuffer, 0, $stdoutBuffer.Length) + } + catch { $streamFailure = 'StdoutRead' } + } + if ($null -eq $stderrTask) { + try { + $stderrTask = $process.StandardError.BaseStream.ReadAsync( + $stderrBuffer, 0, $stderrBuffer.Length) + } + catch { $streamFailure = 'StderrRead' } + } + try { + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name AfterWorkerProcessFailure ` + -Arguments @($workerFailurePoint) + } + catch {} + } + + # Once Start succeeds this state machine is total. Every collector, hook, + # decoder, and process-control failure becomes bounded metadata; none can + # escape and let the caller confuse a live tree with a process that never ran. + while ($started -and -not $treeExitConfirmed) { + try { + $workerFailurePoint = 'LifecyclePoll' + Invoke-GraphKitAuthParityHook -Hooks $Hooks ` + -Name BeforeWorkerLifecyclePoll -Arguments @([pscustomobject]@{ + WorkerProcessId = $workerProcessId + ElapsedMilliseconds = [long]$clock.ElapsedMilliseconds + }) + if (-not $stdoutComplete -and $null -ne $stdoutTask -and + $stdoutTask.IsCompleted) { + try { $count = $stdoutTask.GetAwaiter().GetResult() } + catch { $count = -1; $streamFailure = 'StdoutRead' } + if ($count -lt 0) { $overflow = $true } + elseif ($count -eq 0) { $stdoutComplete = $true } + else { + if (-not $stdoutCaptureDisabled) { try { + Add-GraphKitAuthParityBoundedBytes -Destination $stdoutBytes ` + -Buffer $stdoutBuffer -Count $count ` + -MaximumBytes $script:GraphKitAuthParityMaxWorkerStreamBytes + } + catch { + $overflow = $true + $stdoutCaptureDisabled = $true + $streamFailure = 'StdoutBound' + } } + $stdoutTask = $process.StandardOutput.BaseStream.ReadAsync( + $stdoutBuffer, 0, $stdoutBuffer.Length) + } + } + if (-not $stderrComplete -and $null -ne $stderrTask -and + $stderrTask.IsCompleted) { + try { $count = $stderrTask.GetAwaiter().GetResult() } + catch { $count = -1; $streamFailure = 'StderrRead' } + if ($count -lt 0) { $overflow = $true } + elseif ($count -eq 0) { $stderrComplete = $true } + else { + if (-not $stderrCaptureDisabled) { try { + Add-GraphKitAuthParityBoundedBytes -Destination $stderrBytes ` + -Buffer $stderrBuffer -Count $count ` + -MaximumBytes $script:GraphKitAuthParityMaxWorkerStreamBytes + } + catch { + $overflow = $true + $stderrCaptureDisabled = $true + $streamFailure = 'StderrBound' + } } + $stderrTask = $process.StandardError.BaseStream.ReadAsync( + $stderrBuffer, 0, $stderrBuffer.Length) + } + } + + if (-not $ownershipEstablished -and -not $rootExitConfirmed) { + $ownershipEstablished = $treeLease.IsOwnershipEstablished() + } + if ($ownershipEstablished -and -not $postStartHookInvoked -and + -not $fatalPostStartFailure) { + $postStartHookInvoked = $true + $workerFailurePoint = 'PostStartHook' + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name AfterWorkerStarted ` + -Arguments @($process) + } + if ($ownershipEstablished -and -not $requestReleased -and + $postStartHookInvoked -and -not $fatalPostStartFailure -and + -not $terminationRequested) { + $workerFailurePoint = 'RequestWrite' + try { + $writeTask = $process.StandardInput.BaseStream.WriteAsync( + $requestBytes, 0, $requestBytes.Length) + $requestReleased = $true + } + catch { + $writeFailed = $true + $writeComplete = $true + $fatalPostStartFailure = $true + } + } + if ($requestReleased -and -not $writeComplete -and + $null -ne $writeTask -and $writeTask.IsCompleted) { + try { $null = $writeTask.GetAwaiter().GetResult() } + catch { $writeFailed = $true } + $writeComplete = $true + try { $process.StandardInput.Close() } catch { $writeFailed = $true } + } + + if (-not $rootExitConfirmed -and $process.HasExited) { + $rootExitConfirmed = $process.WaitForExit(0) + if ($rootExitConfirmed -and -not $rootExitHookFired) { + $rootExitHookFired = $true + Invoke-GraphKitAuthParityHook -Hooks $Hooks ` + -Name AfterWorkerRootExit -Arguments @([pscustomobject]@{ + WorkerProcessId = $workerProcessId + OwnershipEstablished = $ownershipEstablished + RequestReleased = $requestReleased + }) + } + } + if ($rootExitConfirmed -and $ownershipEstablished) { + $treeEmpty = $treeLease.IsTreeEmpty() + if (-not $treeEmpty -and -not $terminationRequested) { + $residualTreeDetected = $true + } + } + $streamsDrained = $stdoutComplete -and $stderrComplete + + $mustTerminate = $fatalPostStartFailure -or $overflow -or $writeFailed -or + $residualTreeDetected -or + ($clock.ElapsedMilliseconds -ge $operationDeadlineMilliseconds -and + -not ($rootExitConfirmed -and $treeEmpty -and $streamsDrained)) + if ($mustTerminate -and -not $terminationRequested) { + $terminationRequested = $true + $timedOut = -not $fatalPostStartFailure -and -not $overflow -and + -not $writeFailed -and -not $residualTreeDetected + try { $process.StandardInput.Close() } catch {} + try { + Invoke-GraphKitAuthParityHook -Hooks $Hooks ` + -Name BeforeWorkerTreeTermination -Arguments @([pscustomobject]@{ + WorkerProcessId = $workerProcessId + OwnershipEstablished = $ownershipEstablished + RootExitConfirmed = $rootExitConfirmed + ResidualTreeDetected = $residualTreeDetected + TimedOut = $timedOut + }) + } + catch { $fatalPostStartFailure = $true } + if ($ownershipEstablished) { + $null = $treeLease.RequestTerminate() + $terminateSent = $true + $terminationStartedMilliseconds = $clock.ElapsedMilliseconds + } + else { + try { $process.Kill($true) } catch {} + } + } + + if ($terminationRequested -and $ownershipEstablished -and + -not $treeEmpty -and -not $killSent -and + ($clock.ElapsedMilliseconds - $terminationStartedMilliseconds) -ge 250L) { + $null = $treeLease.RequestKill() + $killSent = $true + } + + if (-not $rootExitConfirmed -and $process.HasExited) { + $rootExitConfirmed = $process.WaitForExit(0) + if ($rootExitConfirmed -and -not $rootExitHookFired) { + $rootExitHookFired = $true + Invoke-GraphKitAuthParityHook -Hooks $Hooks ` + -Name AfterWorkerRootExit -Arguments @([pscustomobject]@{ + WorkerProcessId = $workerProcessId + OwnershipEstablished = $ownershipEstablished + RequestReleased = $requestReleased + }) + } + } + if ($rootExitConfirmed -and $ownershipEstablished -and -not $treeEmpty) { + $treeEmpty = $treeLease.IsTreeEmpty() + } + $streamsDrained = $stdoutComplete -and $stderrComplete + if ($rootExitConfirmed -and $ownershipEstablished -and $treeEmpty -and + $streamsDrained) { + $treeExitConfirmed = $true + if (-not $treeExitHookFired) { + $treeExitHookFired = $true + Invoke-GraphKitAuthParityHook -Hooks $Hooks ` + -Name AfterWorkerTreeExit -Arguments @([pscustomobject]@{ + WorkerProcessId = $workerProcessId + TerminationRequested = $terminationRequested + ResidualTreeDetected = $residualTreeDetected + StreamsDrained = $streamsDrained + }) + } + break + } + if ($clock.ElapsedMilliseconds -ge $hardDeadlineMilliseconds -or + ($rootExitConfirmed -and -not $ownershipEstablished -and + ($streamsDrained -or + ($null -eq $stdoutTask -and $null -eq $stderrTask)))) { + break + } + } + catch { + if (-not $fatalPostStartFailure) { + $fatalPostStartFailure = $true + try { + Invoke-GraphKitAuthParityHook -Hooks $Hooks ` + -Name AfterWorkerProcessFailure -Arguments @($workerFailurePoint) + } + catch {} + } + } + if ($fatalPostStartFailure -and -not $treeExitConfirmed -and + -not $terminationRequested) { + $terminationRequested = $true + try { $process.StandardInput.Close() } catch {} + try { + Invoke-GraphKitAuthParityHook -Hooks $Hooks ` + -Name BeforeWorkerTreeTermination -Arguments @([pscustomobject]@{ + WorkerProcessId = $workerProcessId + OwnershipEstablished = $ownershipEstablished + RootExitConfirmed = $rootExitConfirmed + ResidualTreeDetected = $residualTreeDetected + TimedOut = $false + }) + } + catch {} + if ($ownershipEstablished) { + try { + $null = $treeLease.RequestKill() + $killSent = $true + } + catch {} + } + else { + try { $process.Kill($true) } catch {} + } + } + # This check intentionally sits outside every fallible poll/control + # operation. A persistently throwing native predicate cannot bypass + # the hard bound and spin this verifier forever. + if ($clock.ElapsedMilliseconds -ge $hardDeadlineMilliseconds) { break } + if (-not $treeExitConfirmed) { Start-Sleep -Milliseconds 10 } + } + + if (-not $treeExitConfirmed) { + try { $process.StandardInput.Close() } catch {} + } + if (-not $treeExitConfirmed) { + $protocolFailure = if (-not $ownershipEstablished) { 'Ownership' } + elseif (-not $rootExitConfirmed) { 'UnconfirmedRootExit' } + elseif (-not $treeEmpty) { 'UnconfirmedTree' } + elseif (-not $streamsDrained) { 'UnconfirmedStreams' } + else { $workerFailurePoint } + } + elseif ($fatalPostStartFailure) { $protocolFailure = $workerFailurePoint } + elseif ($streamFailure -cne 'None') { $protocolFailure = $streamFailure } + elseif ($timedOut) { $protocolFailure = 'Timeout' } + elseif ($writeFailed) { $protocolFailure = 'RequestWrite' } + elseif ($residualTreeDetected) { $protocolFailure = 'ResidualTree' } + else { + $workerFailurePoint = 'StreamDecode' + $stdout = $utf8.GetString($stdoutBytes.ToArray()) + $stderr = $utf8.GetString($stderrBytes.ToArray()) + Invoke-GraphKitAuthParityHook -Hooks $Hooks -Name InspectWorkerStreams ` + -Arguments @([pscustomobject]@{ + WorkerProcessId = $workerProcessId + ExitCode = $process.ExitCode + StdoutByteCount = $stdoutBytes.Count + StderrByteCount = $stderrBytes.Count + StreamsDrained = $streamsDrained + }) + $frame = [regex]::Match( + $stdout, + '\A(?\{[^\r\n]*\})(?:\r\n|\n)\z', + [Text.RegularExpressions.RegexOptions]::CultureInvariant) + if (-not $requestReleased) { $protocolFailure = 'RequestWithheld' } + elseif ($process.ExitCode -ne 0) { $protocolFailure = 'ExitCode' } + elseif (-not [string]::IsNullOrEmpty($stderr)) { $protocolFailure = 'Stderr' } + elseif (-not $frame.Success) { $protocolFailure = 'Frame' } + else { + try { + $workerFailurePoint = 'ProtocolValidation' + $workerResult = ConvertFrom-GraphKitAuthParityWorkerJson ` + -Json $frame.Groups['json'].Value ` + -MaximumBytes $script:GraphKitAuthParityMaxWorkerStreamBytes + $null = Test-GraphKitAuthParityWorkerResult -Result $workerResult ` + -Request $Request -RequestSha256 $requestSha256 + $protocolFailure = 'None' + } + catch { $protocolFailure = 'Validation'; $workerResult = $null } + } + } + } + catch { + if (-not $started) { throw } + # Protocol/hook work after a confirmed boundary cannot revoke the already + # established root/tree/EOF proof. It does invalidate the worker record. + $protocolFailure = $workerFailurePoint + } + finally { + try { $treeLease.Dispose() } catch {} + try { $process.Dispose() } catch {} + } + + $elapsedMilliseconds = if ($null -eq $clock) { 0L } + else { [long]$clock.ElapsedMilliseconds } + $protocolValid = $treeExitConfirmed -and $protocolFailure -ceq 'None' -and + $null -ne $workerResult + return [pscustomobject]@{ + Started = $started + OwnershipEstablished = $ownershipEstablished + RequestReleased = $requestReleased + RootExitConfirmed = $rootExitConfirmed + TreeExitConfirmed = $treeExitConfirmed + StreamsDrained = $streamsDrained + ConfirmedExit = $treeExitConfirmed + TimedOut = $timedOut + TerminationRequested = $terminationRequested + ForcedTermination = $terminationRequested + ProtocolValid = $protocolValid + WorkerProcessId = $workerProcessId + ElapsedMilliseconds = $elapsedMilliseconds + OperationDeadlineMilliseconds = $operationDeadlineMilliseconds + HardDeadlineMilliseconds = $hardDeadlineMilliseconds + StreamFailure = $streamFailure + ProtocolFailure = $protocolFailure + Result = $(if ($protocolValid) { $workerResult } else { $null }) + } +} + +$task8Hooks = if ($MyInvocation.InvocationName -ceq '.') { + Get-GraphKitAuthParityTestHooks +} +else { $null } +if ($MyInvocation.InvocationName -ceq '.' -and $null -eq $task8Hooks) { + return +} +if ($null -ne $task8Hooks -and + $null -ne $task8Hooks.PSObject.Properties['ExportFunctionsOnly'] -and + [bool]$task8Hooks.ExportFunctionsOnly) { + return +} + +$task8StartedUtc = Get-GraphKitAuthParityUtcText +$task8Execution = if ($PSCmdlet.ParameterSetName -ceq 'DryRun') { 'DryRun' } else { 'Live' } +$task8Record = New-GraphKitAuthParityModeRecord -Execution $task8Execution ` + -Mode $AuthMode -StartedUtc $task8StartedUtc +$task8State = $null +$task8StorePathBound = $false +$task8WorkerStarted = $false +$task8WorkerTreeExitConfirmed = $false +$task8WorkerTeardownFailed = $false +$task8PrimaryFailed = $false +$task8FailureStage = 'Artifact' +$task8FailureCode = 'ArtifactRejected' + +try { + if ([string]::IsNullOrWhiteSpace($PackagePath) -or + [IO.Path]::GetExtension($PackagePath) -cne '.nupkg' -or + $PackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $AuthMode -cnotin $script:GraphKitAuthParityModes -or + ($task8Execution -ceq 'Live' -and + ($ProfileId -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}$' -or + ($PSBoundParameters.ContainsKey('StorePath') -and + [string]::IsNullOrWhiteSpace($StorePath))))) { + throw [InvalidOperationException]::new('The protected parity invocation was rejected.') + } + + $task8FailureStage = 'Import' + $task8FailureCode = 'ImportRejected' + if (@(Get-Module -Name GraphKit -All).Count -ne 0) { + throw [InvalidOperationException]::new('A GraphKit module is already loaded.') + } + + $task8FailureStage = 'Artifact' + $task8FailureCode = 'ArtifactRejected' + Initialize-GraphKitAuthParityNative + $task8SourcePath = [IO.Path]::GetFullPath($PackagePath) + $task8SourceParent = [IO.Path]::GetDirectoryName($task8SourcePath) + $task8SourceName = [IO.Path]::GetFileName($task8SourcePath) + if ([string]::IsNullOrWhiteSpace($task8SourceParent) -or + [string]::IsNullOrWhiteSpace($task8SourceName)) { + throw [InvalidOperationException]::new('The package source path was rejected.') + } + $task8Native = $script:GraphKitAuthParityNativeType + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name BeforeSourceMetadata ` + -Arguments @($task8SourcePath) + $task8SourceEvidence = $task8Native::InspectFileMetadata( + $task8SourceParent, $task8SourceName, + [long]$script:GraphKitAuthParityMaxPackageBytes) + $null = Assert-GraphKitAuthParitySourceBound -Evidence $task8SourceEvidence + if ([long]$task8SourceEvidence.LinkCount -ne 1) { + throw [InvalidOperationException]::new('The package source is not link-count one.') + } + + $task8TempParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd( + [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + $task8TempParentParent = [IO.Path]::GetDirectoryName($task8TempParent) + $task8TempParentName = [IO.Path]::GetFileName($task8TempParent) + if ([string]::IsNullOrWhiteSpace($task8TempParentParent) -or + [string]::IsNullOrWhiteSpace($task8TempParentName)) { + throw [InvalidOperationException]::new('The protected temporary parent was rejected.') + } + $task8TempParentEvidence = $task8Native::InspectDirectory( + $task8TempParentParent, $task8TempParentName) + $task8RootName = 'graphkit-task8-' + [guid]::NewGuid().ToString('N') + $task8RootEvidence = $task8Native::CreateDirectoryOwnerOnly( + $task8TempParent, $task8RootName) + $task8RootPath = Join-Path $task8TempParent $task8RootName + $task8State = [pscustomobject]@{ + TempParentPath = $task8TempParent + TempParentParent = $task8TempParentParent + TempParentName = $task8TempParentName + TempParentEvidence = $task8TempParentEvidence + RootName = $task8RootName + RootPath = $task8RootPath + RootEvidence = $task8RootEvidence + RootPermissionEvidence = $null + CandidateSha256 = $PackageSha256 + SnapshotPath = Join-Path $task8RootPath $script:GraphKitAuthParitySnapshotName + ModuleRoot = $null + ExtractedManifestPath = $null + ExtractedModulePath = $null + ImportedManifestPath = $null + ImportedModulePath = $null + ModuleVersion = $null + Sealed = $false + FileEvidence = [Collections.Generic.Dictionary[string,object]]::new( + [StringComparer]::Ordinal) + FilePermissionEvidence = [Collections.Generic.Dictionary[string,object]]::new( + [StringComparer]::Ordinal) + DirectoryEvidence = [Collections.Generic.Dictionary[string,object]]::new( + [StringComparer]::Ordinal) + DirectoryPermissionEvidence = [Collections.Generic.Dictionary[string,object]]::new( + [StringComparer]::Ordinal) + ExpectedFiles = [Collections.Generic.List[string]]::new() + ExpectedDirectories = [Collections.Generic.List[string]]::new() + } + if (-not $task8Native::HasInitialOwnerOnlyDirectoryAccess($task8RootEvidence)) { + throw [InvalidOperationException]::new('The protected parity root was not created owner-only.') + } + $task8MarkerBytes = [Text.UTF8Encoding]::new($false).GetBytes( + 'GraphKit.Task8.ParityRunner/1') + $task8MarkerWrite = $task8Native::WriteFileCreateNew( + $task8RootPath, $script:GraphKitAuthParityMarkerName, $task8MarkerBytes, $true) + if (-not $task8Native::HasInitialOwnerOnlyAccess($task8MarkerWrite.DestinationInitial)) { + throw [InvalidOperationException]::new('The protected parity marker was not created owner-only.') + } + $task8State.FileEvidence[$script:GraphKitAuthParityMarkerName] = + $task8MarkerWrite.Destination + $task8State.ExpectedFiles.Add($script:GraphKitAuthParityMarkerName) + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name AfterRootCreated ` + -Arguments @($task8State) + + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name BeforeSourceHash ` + -Arguments @($task8SourcePath) + $task8Copy = $task8Native::CopyFileCreateNew( + $task8SourceParent, $task8SourceName, + $task8RootPath, $script:GraphKitAuthParitySnapshotName, $true, + [long]$script:GraphKitAuthParityMaxPackageBytes) + $task8State.FileEvidence[$script:GraphKitAuthParitySnapshotName] = + $task8Copy.Destination + $task8State.ExpectedFiles.Add($script:GraphKitAuthParitySnapshotName) + if (-not $task8Native::HasInitialOwnerOnlyAccess($task8Copy.DestinationInitial) -or + [string]$task8Copy.Source.NativeIdentity -cne [string]$task8SourceEvidence.NativeIdentity -or + [long]$task8Copy.Source.Length -ne [long]$task8SourceEvidence.Length -or + [long]$task8Copy.Source.LinkCount -ne 1 -or + [long]$task8Copy.Destination.LinkCount -ne 1 -or + [string]$task8Copy.Source.Sha256 -cne $PackageSha256 -or + [string]$task8Copy.Destination.Sha256 -cne $PackageSha256) { + throw [InvalidOperationException]::new('The package snapshot digest or identity was rejected.') + } + $task8Record.packageSha256 = $PackageSha256 + $task8Record.checks.packageDigestMatched = $true + $task8Record.checks.snapshotBound = $true + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name AfterSnapshot ` + -Arguments @($task8State) + + Expand-GraphKitAuthParitySnapshot -State $task8State -Hooks $task8Hooks + $task8Record.checks.archiveValidated = $true + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name AfterExtraction ` + -Arguments @($task8State) + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name BeforeImport ` + -Arguments @($task8State) + Assert-GraphKitAuthParityState -State $task8State -Purpose Import + $task8Record.checks.extractionSealed = $true + + $task8Route = Get-GraphKitAuthParityDescriptorRoute ` + -ManifestRoot $task8State.ModuleRoot -Mode $AuthMode + $task8Record.checks.routeMatched = $true + $task8Record.moduleVersion = Get-GraphKitAuthParityFullVersion ` + -ManifestPath $task8State.ExtractedManifestPath + $task8State.ModuleVersion = $task8Record.moduleVersion + + $task8FailureStage = 'Import' + $task8FailureCode = 'ImportRejected' + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name BeforeFinalImportRecheck ` + -Arguments @($task8State) + Assert-GraphKitAuthParityState -State $task8State -Purpose Import + $task8StorePathBound = $task8Execution -ceq 'Live' -and + $PSBoundParameters.ContainsKey('StorePath') + $task8WorkerNonce = [Convert]::ToHexString( + [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLowerInvariant() + $task8WorkerRequest = New-GraphKitAuthParityWorkerRequest -State $task8State ` + -Nonce $task8WorkerNonce -Execution $task8Execution -Mode $AuthMode ` + -ProfileId $(if ($task8Execution -ceq 'Live') { $ProfileId } else { '' }) ` + -StorePath $(if ($task8StorePathBound) { $StorePath } else { '' }) ` + -StorePathBound:$task8StorePathBound + $task8WorkerRequestOverride = Invoke-GraphKitAuthParityHook -Hooks $task8Hooks ` + -Name MutateWorkerRequest -Arguments @($task8WorkerRequest) -PassThru + if ($null -ne $task8WorkerRequestOverride) { + $task8WorkerRequest = $task8WorkerRequestOverride + } + $task8WorkerJson = $task8WorkerRequest | ConvertTo-Json -Compress -Depth 12 + $task8WorkerJsonOverride = Invoke-GraphKitAuthParityHook -Hooks $task8Hooks ` + -Name MutateWorkerRequestJson -Arguments @($task8WorkerJson) -PassThru + if ($null -ne $task8WorkerJsonOverride) { + if ($task8WorkerJsonOverride.GetType() -ne [string]) { + throw [InvalidOperationException]::new( + 'The protected parity worker request-frame seam was rejected.') + } + $task8WorkerJson = [string]$task8WorkerJsonOverride + } + $task8WorkerPath = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ` + 'private/Invoke-GraphKitAuthParityWorker.ps1')) + $task8FailureStage = 'Diagnostics' + $task8FailureCode = 'DiagnosticsRejected' + $task8WorkerTimeoutSeconds = if ($task8Execution -ceq 'Live') { 360 } else { 60 } + $task8WorkerTimeoutOverride = Invoke-GraphKitAuthParityHook -Hooks $task8Hooks ` + -Name SelectWorkerTimeoutSeconds -Arguments @($task8WorkerTimeoutSeconds) -PassThru + if ($null -ne $task8WorkerTimeoutOverride) { + if ($task8WorkerTimeoutOverride.GetType() -ne [int] -or + [int]$task8WorkerTimeoutOverride -lt 1 -or + [int]$task8WorkerTimeoutOverride -gt $task8WorkerTimeoutSeconds) { + throw [InvalidOperationException]::new( + 'The protected parity worker timeout seam was rejected.') + } + $task8WorkerTimeoutSeconds = [int]$task8WorkerTimeoutOverride + } + $task8WorkerRun = Invoke-GraphKitAuthParityWorkerProcess -WorkerPath $task8WorkerPath ` + -RequestJson $task8WorkerJson -Request $task8WorkerRequest ` + -TimeoutSeconds $task8WorkerTimeoutSeconds ` + -Hooks $task8Hooks + $task8WorkerStarted = [bool]$task8WorkerRun.Started + $task8WorkerTreeExitConfirmed = [bool]$task8WorkerRun.TreeExitConfirmed + if ($task8WorkerTreeExitConfirmed) { + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name AfterWorkerExit ` + -Arguments @($task8State, [int]$task8WorkerRun.WorkerProcessId, $task8WorkerRun) + } + if (-not $task8WorkerTreeExitConfirmed -or -not [bool]$task8WorkerRun.ProtocolValid) { + throw [InvalidOperationException]::new('The protected parity worker result was rejected.') + } + $task8WorkerResult = $task8WorkerRun.Result + $task8Record.checks.exactImport = [bool]$task8WorkerResult.exactImport + foreach ($property in $task8WorkerResult.adapter.PSObject.Properties) { + $task8Record.adapter.$($property.Name) = [bool]$property.Value + } + $task8Record.checks.contextMatched = [bool]$task8WorkerResult.contextMatched + $task8Record.checks.sourceMatched = [bool]$task8WorkerResult.sourceMatched + $task8Record.checks.tenantProofVerified = [bool]$task8WorkerResult.tenantProofVerified + $task8Record.read.attempted = [bool]$task8WorkerResult.readAttempted + $task8Record.read.succeeded = [bool]$task8WorkerResult.readSucceeded + $task8Record.read.rowCount = [long]$task8WorkerResult.rowCount + if (-not [bool]$task8WorkerResult.workerTeardownVerified) { + $task8WorkerTeardownFailed = $true + } + if ($task8WorkerResult.state -cne 'Passed') { + $task8FailureStage = [string]$task8WorkerResult.failureStage + $task8FailureCode = [string]$task8WorkerResult.failureCode + throw [InvalidOperationException]::new('The protected parity worker operation was rejected.') + } +} +catch { + $task8PrimaryFailed = $true + Set-GraphKitAuthParityFailure -Record $task8Record ` + -Stage $task8FailureStage -Code $task8FailureCode +} +finally { + $task8CleanupFailed = $task8WorkerTeardownFailed -or + ($task8WorkerStarted -and -not $task8WorkerTreeExitConfirmed) + if ($null -ne $task8State -and + (-not $task8WorkerStarted -or $task8WorkerTreeExitConfirmed)) { + try { + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name BeforeCleanup ` + -Arguments @($task8State) + } + catch { $task8CleanupFailed = $true } + try { Remove-GraphKitAuthParityState -State $task8State -Hooks $task8Hooks } + catch { $task8CleanupFailed = $true } + } + if ($task8CleanupFailed) { + Set-GraphKitAuthParityFailure -Record $task8Record ` + -Stage Cleanup -Code CleanupFailed + $task8PrimaryFailed = $true + } + else { + $task8Record.checks.cleanupVerified = $true + } +} + +if (-not $task8PrimaryFailed) { + Set-GraphKitAuthParityPassed -Record $task8Record +} +$task8Record.completedUtc = Get-GraphKitAuthParityUtcText +try { + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name MutateEvidence ` + -Arguments @($task8Record) + $null = Test-GraphKitAuthParityEvidence -Record $task8Record +} +catch { + $task8CandidateVersion = [string]$task8Record.moduleVersion + $task8SafeVersion = if ($task8CandidateVersion -match + '^\d+\.\d+\.\d+-[0-9A-Za-z][0-9A-Za-z.-]*$' -and + -not (Test-GraphKitAuthParityForbiddenString -Value $task8CandidateVersion)) { + $task8CandidateVersion + } + else { '0.0.0-rejected' } + $task8SafeDigest = if ($task8Record.packageSha256 -cmatch '^[0-9a-f]{64}$') { + [string]$task8Record.packageSha256 + } + else { '0' * 64 } + $task8Record = New-GraphKitAuthParityModeRecord -Execution $task8Execution ` + -Mode $AuthMode -StartedUtc $task8StartedUtc -ModuleVersion $task8SafeVersion ` + -Digest $task8SafeDigest + Set-GraphKitAuthParityFailure -Record $task8Record ` + -Stage Evidence -Code EvidenceRejected + $task8Record.completedUtc = Get-GraphKitAuthParityUtcText + $null = Test-GraphKitAuthParityEvidence -Record $task8Record +} + +$task8Json = $task8Record | ConvertTo-Json -Compress -Depth 5 +Write-Output $task8Json diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 new file mode 100644 index 0000000..5525d5f --- /dev/null +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -0,0 +1,420 @@ +<# + .SYNOPSIS + Captures and finalizes GraphKit's canonical tested-release proof. + + .DESCRIPTION + Capture runs before Pester. It invalidates prior result/proof files and records the + exact built-module file set plus package archive hash. Finalize runs only after + Pester: it requires one matching NUnit/Pester-object pair, applies the complete + release gate, rechecks the candidate and result bytes, and atomically writes + tested-release-proof.json. A failed or interrupted test attempt therefore leaves + no stale proof capable of authorizing publication. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('Capture', 'Finalize')] + [string] $Stage, + + [string] $RepositoryRoot +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +$minimumTests = 1482 +$allowedSkips = 0 +$allowedNotRun = 0 + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Split-Path $PSScriptRoot -Parent +} +$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath +$resultsDirectory = Join-Path $RepositoryRoot 'output/testResults' +$candidatePath = Join-Path $resultsDirectory 'candidate-release-input.json' +$proofPath = Join-Path $resultsDirectory 'tested-release-proof.json' + +function Get-GraphKitReleaseCandidateState { + param([Parameter(Mandatory)] [string] $Root) + + $versionScript = Join-Path $Root 'scripts/Get-GraphKitTrainVersion.ps1' + if (-not (Test-Path -LiteralPath $versionScript -PathType Leaf)) { + throw "Release proof requires '$versionScript'." + } + $sourceState = & $versionScript -RepositoryRoot $Root -AsObject + $fullVersion = [string] $sourceState.version + if ($fullVersion -notmatch '^0\.4\.0-r8\.g(?[0-9a-f]{12})(?:\.d(?[0-9a-f]{12}))?$' -or + [string] $sourceState.baseVersion -cne '0.4.0' -or + [string] $sourceState.train -cne 'r8') { + throw "Release proof received an invalid GraphKit train version '$fullVersion'." + } + $baseVersion = '0.4.0' + $sourceRevision = [string] $sourceState.revision + $sourceClean = [bool] $sourceState.clean + $sourceStateHash = [string] $sourceState.sourceStateSha256 + if ($sourceRevision -notmatch '^[0-9a-f]{40}$' -or $sourceStateHash -notmatch '^[0-9a-f]{64}$') { + throw "Release proof received incomplete source provenance for '$fullVersion'." + } + + $moduleRoot = Join-Path $Root 'output/module/GraphKit' + $versionDirectories = @( + Get-ChildItem -LiteralPath $moduleRoot -Directory -ErrorAction SilentlyContinue + ) + if ($versionDirectories.Count -ne 1) { + throw "Release proof requires exactly one built GraphKit version under '$moduleRoot'; found $($versionDirectories.Count). Run ./build.ps1 -Tasks pack." + } + $moduleDirectory = $versionDirectories[0].FullName + $version = $versionDirectories[0].Name + $manifestPath = Join-Path $moduleDirectory 'GraphKit.psd1' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "Built GraphKit manifest is missing at '$manifestPath'." + } + $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath + if ($version -cne $baseVersion -or [string] $manifest.ModuleVersion -cne $baseVersion) { + throw "Built manifest version '$($manifest.ModuleVersion)' does not match its version directory '$version'." + } + $expectedPrerelease = $fullVersion.Substring($baseVersion.Length + 1) + if ([string] $manifest.PrivateData.PSData.Prerelease -cne $expectedPrerelease) { + throw "Built manifest prerelease '$($manifest.PrivateData.PSData.Prerelease)' does not match release candidate '$fullVersion'." + } + + [string[]] $relativePaths = @( + Get-ChildItem -LiteralPath $moduleDirectory -Recurse -File -Force | + ForEach-Object { + $_.FullName.Substring($moduleDirectory.Length + 1) -replace '\\', '/' + } + ) + [System.Array]::Sort($relativePaths, [System.StringComparer]::Ordinal) + if ($relativePaths.Count -eq 0) { + throw "Built GraphKit module '$moduleDirectory' contains no files." + } + $files = @( + $relativePaths | ForEach-Object { + [pscustomobject] [ordered] @{ + path = $_ + sha256 = (Get-FileHash -LiteralPath (Join-Path $moduleDirectory $_) -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + ) + + $packagePath = Join-Path $Root "output/GraphKit.$fullVersion.nupkg" + if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { + throw "Release candidate package '$packagePath' is missing. Run ./build.ps1 -Tasks pack before test." + } + + [pscustomobject] [ordered] @{ + module = [pscustomobject] [ordered] @{ + name = 'GraphKit' + version = $fullVersion + baseVersion = $baseVersion + files = $files + } + source = [pscustomobject] [ordered] @{ + revision = $sourceRevision + clean = $sourceClean + stateSha256 = $sourceStateHash + } + package = [pscustomobject] [ordered] @{ + name = Split-Path -Leaf $packagePath + sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + } + } +} + +function Assert-GraphKitReleaseCandidateUnchanged { + param( + [Parameter(Mandatory)] [object] $Captured, + [Parameter(Mandatory)] [object] $Current + ) + + $capturedFiles = @($Captured.module.files) + $currentFiles = @($Current.module.files) + $moduleChanged = + [string] $Captured.module.name -cne [string] $Current.module.name -or + [string] $Captured.module.version -cne [string] $Current.module.version -or + [string] $Captured.module.baseVersion -cne [string] $Current.module.baseVersion -or + $capturedFiles.Count -ne $currentFiles.Count + if (-not $moduleChanged) { + for ($index = 0; $index -lt $capturedFiles.Count; $index++) { + if ([string] $capturedFiles[$index].path -cne [string] $currentFiles[$index].path -or + [string] $capturedFiles[$index].sha256 -cne [string] $currentFiles[$index].sha256) { + $moduleChanged = $true + break + } + } + } + if ($moduleChanged) { + throw 'The built module candidate changed after capture; no tested release proof was emitted.' + } + if ([string] $Captured.source.revision -cne [string] $Current.source.revision -or + [bool] $Captured.source.clean -ne [bool] $Current.source.clean -or + [string] $Captured.source.stateSha256 -cne [string] $Current.source.stateSha256) { + throw 'The source candidate changed after capture; no tested release proof was emitted.' + } + if ([string] $Captured.package.name -cne [string] $Current.package.name -or + [string] $Captured.package.sha256 -cne [string] $Current.package.sha256) { + throw 'The package candidate changed after capture; no tested release proof was emitted.' + } +} + +function Get-GraphKitReleaseResultPair { + param([Parameter(Mandatory)] [string] $Directory) + + $nunitFiles = @(Get-ChildItem -LiteralPath $Directory -Filter 'NUnitXml_*.xml' -File -ErrorAction SilentlyContinue) + $pesterObjectFiles = @(Get-ChildItem -LiteralPath $Directory -Filter 'PesterObject_*.xml' -File -ErrorAction SilentlyContinue) + if ($nunitFiles.Count -ne 1 -or $pesterObjectFiles.Count -ne 1) { + throw "Release proof requires exactly one NUnit/Pester-object result pair; found $($nunitFiles.Count) NUnit and $($pesterObjectFiles.Count) Pester object file(s)." + } + $nunitSuffix = $nunitFiles[0].Name.Substring('NUnitXml_'.Length) + $pesterObjectSuffix = $pesterObjectFiles[0].Name.Substring('PesterObject_'.Length) + if ($nunitSuffix -cne $pesterObjectSuffix) { + throw "NUnit and Pester-object result suffixes do not match: '$nunitSuffix' vs '$pesterObjectSuffix'." + } + [pscustomobject] [ordered] @{ + nunit = [pscustomobject] [ordered] @{ + name = $nunitFiles[0].Name + path = $nunitFiles[0].FullName + sha256 = (Get-FileHash -LiteralPath $nunitFiles[0].FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + pesterObject = [pscustomobject] [ordered] @{ + name = $pesterObjectFiles[0].Name + path = $pesterObjectFiles[0].FullName + sha256 = (Get-FileHash -LiteralPath $pesterObjectFiles[0].FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + } +} + +function Get-GraphKitReleaseResultSummary { + param([Parameter(Mandatory)] [object] $ResultPair) + + [xml] $resultDocument = Get-Content -LiteralPath $ResultPair.nunit.path -Raw + $resultRoot = $resultDocument.SelectSingleNode('/test-results') + $topSuite = if ($null -eq $resultRoot) { $null } else { $resultRoot.SelectSingleNode('test-suite') } + if ($null -eq $resultRoot -or $null -eq $topSuite) { + throw 'The NUnit result is structurally incomplete.' + } + function ConvertTo-ReleaseCount { + param([Parameter(Mandatory)] [string] $Name) + $raw = [string] $resultRoot.GetAttribute($Name) + $parsed = 0 + if (-not [int]::TryParse($raw, [ref] $parsed) -or $parsed -lt 0) { + throw "The NUnit '$Name' count is unreadable: '$raw'." + } + return $parsed + } + + $pesterResult = Import-Clixml -LiteralPath $ResultPair.pesterObject.path + foreach ($propertyName in @( + 'Result', + 'TotalCount', + 'PassedCount', + 'FailedCount', + 'SkippedCount', + 'NotRunCount', + 'InconclusiveCount', + 'FailedBlocksCount', + 'FailedContainersCount', + 'Executed' + )) { + if ($propertyName -notin @($pesterResult.PSObject.Properties.Name)) { + throw "The Pester object has no '$propertyName' property." + } + } + function ConvertTo-PesterReleaseCount { + param([Parameter(Mandatory)] [string] $Name) + $raw = $pesterResult.$Name + $parsed = 0 + if ($null -eq $raw -or -not [int]::TryParse([string] $raw, [ref] $parsed) -or $parsed -lt 0) { + throw "The Pester '$Name' count is unreadable: '$raw'." + } + return $parsed + } + function ConvertTo-PesterReleaseBoolean { + param([Parameter(Mandatory)] [string] $Name) + $raw = $pesterResult.$Name + $parsed = $false + if ($null -eq $raw -or -not [bool]::TryParse([string] $raw, [ref] $parsed)) { + throw "The Pester '$Name' value is unreadable: '$raw'." + } + return $parsed + } + + $pesterExecuted = ConvertTo-PesterReleaseBoolean -Name 'Executed' + $pesterPassed = ConvertTo-PesterReleaseCount -Name 'PassedCount' + + $summary = [pscustomobject] [ordered] @{ + overallResult = [string] $topSuite.GetAttribute('result') + pesterResult = [string] $pesterResult.Result + executed = $pesterExecuted + total = ConvertTo-ReleaseCount -Name 'total' + passed = $pesterPassed + failures = ConvertTo-ReleaseCount -Name 'failures' + errors = ConvertTo-ReleaseCount -Name 'errors' + skipped = ConvertTo-ReleaseCount -Name 'skipped' + inconclusive = ConvertTo-ReleaseCount -Name 'inconclusive' + notRun = ConvertTo-PesterReleaseCount -Name 'NotRunCount' + failedBlocks = ConvertTo-PesterReleaseCount -Name 'FailedBlocksCount' + failedContainers = ConvertTo-PesterReleaseCount -Name 'FailedContainersCount' + } + if ($summary.failedBlocks -gt 0) { + throw "$($summary.failedBlocks) failed block(s) were recorded; no tested release proof was emitted." + } + if ($summary.failedContainers -gt 0) { + throw "$($summary.failedContainers) failed container(s) / discovery error(s) were recorded; no tested release proof was emitted." + } + if (-not $summary.executed) { + throw 'The Pester run was not executed; no tested release proof was emitted.' + } + $pesterInconclusive = ConvertTo-PesterReleaseCount -Name 'InconclusiveCount' + if ($summary.inconclusive -gt 0 -or $pesterInconclusive -gt 0) { + throw "$([Math]::Max($summary.inconclusive, $pesterInconclusive)) inconclusive test(s) were recorded; no tested release proof was emitted." + } + if ((ConvertTo-PesterReleaseCount -Name 'TotalCount') -ne $summary.total -or + (ConvertTo-PesterReleaseCount -Name 'FailedCount') -ne $summary.failures -or + (ConvertTo-PesterReleaseCount -Name 'SkippedCount') -ne $summary.skipped -or + (ConvertTo-PesterReleaseCount -Name 'InconclusiveCount') -ne $summary.inconclusive) { + throw 'The NUnit and Pester-object result summaries disagree.' + } + $pesterOutcomeTotal = [long] $summary.passed + + [long] $summary.failures + + [long] $summary.skipped + + [long] $summary.inconclusive + + [long] $summary.notRun + if ($pesterOutcomeTotal -ne [long] $summary.total) { + throw "Pester count arithmetic is inconsistent: passed + failed + skipped + inconclusive + NotRun is $pesterOutcomeTotal, not total $($summary.total)." + } + return $summary +} + +if ($Stage -eq 'Capture') { + if (-not (Test-Path -LiteralPath $resultsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $resultsDirectory -Force | Out-Null + } + + # Invalidate every previous authorization record before a new test attempt begins. + Remove-Item -LiteralPath $candidatePath, $proofPath -Force -ErrorAction SilentlyContinue + Get-ChildItem -LiteralPath $resultsDirectory -File -ErrorAction SilentlyContinue | + Where-Object Name -Match '^(NUnitXml_|PesterObject_).*\.xml$' | + Remove-Item -Force + + $candidate = Get-GraphKitReleaseCandidateState -Root $RepositoryRoot + $capture = [pscustomobject] [ordered] @{ + schemaVersion = 3 + runId = [guid]::NewGuid().ToString('D') + module = $candidate.module + source = $candidate.source + package = $candidate.package + } + $stagedCandidatePath = "$candidatePath.tmp-$PID-$([guid]::NewGuid().ToString('N'))" + try { + $capture | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $stagedCandidatePath -NoNewline -Encoding utf8NoBOM + [System.IO.File]::Move($stagedCandidatePath, $candidatePath, $true) + } + finally { + Remove-Item -LiteralPath $stagedCandidatePath -Force -ErrorAction SilentlyContinue + } + Write-Host "CAPTURED RELEASE CANDIDATE: GraphKit $($candidate.module.version); $(@($candidate.module.files).Count) shipped file(s); package $($candidate.package.sha256)." + return +} + +if (-not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) { + throw "No pre-test candidate capture exists at '$candidatePath'. Run the test workflow from its Capture stage." +} +if (Test-Path -LiteralPath $proofPath -PathType Leaf) { + throw "A tested release proof already exists at '$proofPath'; Capture must invalidate it before Finalize." +} +try { + $captured = Get-Content -LiteralPath $candidatePath -Raw | ConvertFrom-Json -Depth 8 +} +catch { + throw "The pre-test candidate capture is unreadable: $($_.Exception.Message)" +} +$parsedRunId = [guid]::Empty +if ([int] $captured.schemaVersion -ne 3 -or + -not [guid]::TryParse([string] $captured.runId, [ref] $parsedRunId) -or + $parsedRunId -eq [guid]::Empty) { + throw 'The pre-test candidate capture has an invalid schema version or run id.' +} + +$currentCandidate = Get-GraphKitReleaseCandidateState -Root $RepositoryRoot +Assert-GraphKitReleaseCandidateUnchanged -Captured $captured -Current $currentCandidate +$resultPair = Get-GraphKitReleaseResultPair -Directory $resultsDirectory +$summary = Get-GraphKitReleaseResultSummary -ResultPair $resultPair + +$gatePath = Join-Path $RepositoryRoot 'tests/QA/Assert-GateResult.ps1' +$gateOutput = & pwsh -NoLogo -NoProfile -File $gatePath ` + -ResultPath $resultPair.nunit.path ` + -MinimumTests $minimumTests ` + -AllowedSkips $allowedSkips 2>&1 +if ($LASTEXITCODE -ne 0) { + $flatGateOutput = (($gateOutput | Out-String) -replace '\r?\n\s*\|\s*', ' ' -replace '\s+', ' ').Trim() + throw "The result pair did not pass the whole-result gate: $flatGateOutput" +} +if ($summary.pesterResult -cne 'Passed') { + throw "The Pester result is '$($summary.pesterResult)', not Passed." +} +if ($summary.notRun -gt $allowedNotRun) { + throw "$($summary.notRun) NotRun test block(s) exceed the tested release allowance of $allowedNotRun." +} + +# The gate consumes the result files. Re-hash them and the candidate afterwards to close +# both replacement windows before any publication authority is written. +$postGateResultPair = Get-GraphKitReleaseResultPair -Directory $resultsDirectory +if ([string] $postGateResultPair.nunit.name -cne [string] $resultPair.nunit.name -or + [string] $postGateResultPair.nunit.sha256 -cne [string] $resultPair.nunit.sha256 -or + [string] $postGateResultPair.pesterObject.name -cne [string] $resultPair.pesterObject.name -or + [string] $postGateResultPair.pesterObject.sha256 -cne [string] $resultPair.pesterObject.sha256) { + throw 'The NUnit/Pester-object result pair changed while the whole-result gate was running.' +} +$postGateCandidate = Get-GraphKitReleaseCandidateState -Root $RepositoryRoot +Assert-GraphKitReleaseCandidateUnchanged -Captured $captured -Current $postGateCandidate + +$releaseProof = [pscustomobject] [ordered] @{ + schemaVersion = 3 + runId = [string] $captured.runId + source = $captured.source + module = $captured.module + package = $captured.package + testRun = [pscustomobject] [ordered] @{ + nunit = [pscustomobject] [ordered] @{ + name = $resultPair.nunit.name + sha256 = $resultPair.nunit.sha256 + } + pesterObject = [pscustomobject] [ordered] @{ + name = $resultPair.pesterObject.name + sha256 = $resultPair.pesterObject.sha256 + } + policy = [pscustomobject] [ordered] @{ + minimumTests = $minimumTests + allowedSkips = $allowedSkips + allowedNotRun = $allowedNotRun + } + summary = $summary + } +} + +$stagedProofPath = "$proofPath.tmp-$PID-$([guid]::NewGuid().ToString('N'))" +try { + $releaseProof | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $stagedProofPath -NoNewline -Encoding utf8NoBOM + + $verifierPath = Join-Path $RepositoryRoot 'scripts/Test-GraphKitReleaseProof.ps1' + $packagePath = Join-Path $RepositoryRoot "output/$($captured.package.name)" + $verificationOutput = & pwsh -NoLogo -NoProfile -File $verifierPath ` + -PackagePath $packagePath ` + -ProofPath $stagedProofPath ` + -RepositoryRoot $RepositoryRoot 2>&1 + if ($LASTEXITCODE -ne 0) { + $flatVerificationOutput = (($verificationOutput | Out-String) -replace '\r?\n\s*\|\s*', ' ' -replace '\s+', ' ').Trim() + throw "The staged tested release proof failed canonical verification: $flatVerificationOutput" + } + + [System.IO.File]::Move($stagedProofPath, $proofPath, $true) + Remove-Item -LiteralPath $candidatePath -Force +} +finally { + Remove-Item -LiteralPath $stagedProofPath -Force -ErrorAction SilentlyContinue +} + +Write-Host "RECORDED TESTED RELEASE PROOF: GraphKit $($captured.module.version); $(@($captured.module.files).Count) shipped file(s); $($summary.total) tests; proof '$proofPath'." diff --git a/scripts/Publish-GraphKitPackage.ps1 b/scripts/Publish-GraphKitPackage.ps1 index d218ee2..7248d76 100644 --- a/scripts/Publish-GraphKitPackage.ps1 +++ b/scripts/Publish-GraphKitPackage.ps1 @@ -39,8 +39,12 @@ For FileSystem, the repository directory. For GitHubRelease, owner/repo. .PARAMETER TestResultPath - NUnit result file proving this build passed. Required unless -SkipTestProof is given, - which exists only for a channel dry run and says so loudly. + NUnit result file bound by the canonical tested-release proof. Required unless + -SkipTestProof is given together with -WhatIf for a read-only channel dry run. + + .PARAMETER ProofPath + Canonical tested-release proof. Defaults to + output/testResults/tested-release-proof.json. .PARAMETER PinPath Where to write the pin record. Defaults to ./output/graphkit.pin.json. @@ -64,6 +68,8 @@ param( [string] $TestResultPath, + [string] $ProofPath, + [switch] $SkipTestProof, [string] $PinPath, @@ -97,8 +103,15 @@ if ($moduleName -ne 'GraphKit') { } # --- Proof that these exact bits passed their tests ------------------------------------- +$verifiedSnapshotDirectory = $null +try { if ($SkipTestProof) { - Write-Warning 'PUBLISHING WITHOUT TEST PROOF. -SkipTestProof was given, so this package is NOT known to have passed its suite. Do not use this for a channel that anything installs from.' + if (-not $WhatIfPreference) { + throw '-SkipTestProof is only allowed with -WhatIf. A real private-channel publication always requires the canonical tested-release proof.' + } + Write-Warning 'DRY RUN WITHOUT TEST PROOF. -SkipTestProof is accepted only because -WhatIf prevents package, proof, and pin writes.' + $verifiedRelease = $null + $verifiedProofSnapshot = $null } else { if ([string]::IsNullOrWhiteSpace($TestResultPath)) { @@ -108,52 +121,53 @@ else { throw "Test result '$TestResultPath' does not exist." } - $gate = Join-Path $repoRoot 'tests/QA/Assert-GateResult.ps1' - & pwsh -NoProfile -File $gate -ResultPath $TestResultPath -MinimumTests 777 -AllowedSkips 0 | Write-Verbose - if ($LASTEXITCODE -ne 0) { - throw "The supplied test result did not pass the whole-result gate, so this package must not be published. Run: pwsh -File tests/QA/Assert-GateResult.ps1 -ResultPath '$TestResultPath' -MinimumTests 777" - } - - # The result must belong to this version, or it proves nothing about these bits. - [xml] $resultDoc = Get-Content -LiteralPath $TestResultPath -Raw - $resultName = [string] $resultDoc.SelectSingleNode('/test-results').GetAttribute('name') - if ($TestResultPath -notmatch [regex]::Escape($moduleVersion) -and $resultName -notmatch [regex]::Escape($moduleVersion)) { - throw "Test result '$TestResultPath' does not reference version $moduleVersion. Publishing a package against another build's result would make the proof meaningless." + # One verifier owns the release definition for both private-channel and PSGallery + # publication. It binds the exact package archive and result pair to every shipped + # module file; this publisher deliberately carries no second, weaker proof path. + $releaseProofPath = if ([string]::IsNullOrWhiteSpace($ProofPath)) { + Join-Path $repoRoot 'output/testResults/tested-release-proof.json' } - - # Matching version numbers are not proof that these bytes are the tested bytes: the - # 'pack' task begins with Clean, so a build/test/pack ordering silently rebuilds the - # module after the suite ran and ships something no test ever saw. Compare the psm1 - # inside the package against the built module the tests actually imported. This turns - # "publish only the already-tested artifact" from a procedural rule into a checked one. - $builtPsm1 = Join-Path $repoRoot "output/module/GraphKit/$moduleVersion/GraphKit.psm1" - if (-not (Test-Path -LiteralPath $builtPsm1 -PathType Leaf)) { - throw "The built module at '$builtPsm1' is gone, so this package cannot be tied back to the tested bits. Run ./build.ps1 -Tasks pack FIRST and ./build.ps1 -Tasks test SECOND - test does not clean, pack does." + else { + $ProofPath } - - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) + $verifier = Join-Path $repoRoot 'scripts/Test-GraphKitReleaseProof.ps1' + $verifiedSnapshotDirectory = [System.IO.Directory]::CreateTempSubdirectory('graphkit-verified-release-').FullName + $verifiedPackageCopyPath = Join-Path $verifiedSnapshotDirectory $package.Name + $verifiedProofCopyPath = Join-Path $verifiedSnapshotDirectory 'tested-release-proof.json' try { - $entry = $archive.Entries | Where-Object { $_.FullName -eq 'GraphKit.psm1' } | Select-Object -First 1 - if ($null -eq $entry) { throw "Package '$($package.Name)' contains no GraphKit.psm1." } - - $stream = $entry.Open() - try { - $sha = [System.Security.Cryptography.SHA256]::Create() - $packagedHash = [System.BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '') - } - finally { $stream.Dispose() } + $verifiedRelease = & $verifier ` + -PackagePath $package.FullName ` + -ProofPath $releaseProofPath ` + -TestResultPath $TestResultPath ` + -RepositoryRoot $repoRoot ` + -VerifiedPackageCopyPath $verifiedPackageCopyPath ` + -VerifiedProofCopyPath $verifiedProofCopyPath } - finally { $archive.Dispose() } - - $testedHash = (Get-FileHash -LiteralPath $builtPsm1 -Algorithm SHA256).Hash - if (-not [string]::Equals($packagedHash, $testedHash, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "The GraphKit.psm1 inside '$($package.Name)' ($packagedHash) is NOT the one the tests ran against ($testedHash). The module was rebuilt between testing and packaging, so this package is unverified. Run ./build.ps1 -Tasks pack, then ./build.ps1 -Tasks test, then publish." + catch { + Remove-Item -LiteralPath $verifiedSnapshotDirectory -Recurse -Force -ErrorAction SilentlyContinue + $verifiedSnapshotDirectory = $null + throw } - Write-Verbose "Packaged GraphKit.psm1 matches the tested build ($testedHash)." + $package = Get-Item -LiteralPath $verifiedRelease.VerifiedPackagePath + $verifiedProofSnapshot = Get-Item -LiteralPath $verifiedRelease.VerifiedProofPath + Write-Verbose "Canonical tested-release proof accepted $($verifiedRelease.ShippedFileCount) shipped file(s)." } -$hash = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash +$hash = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() +if (-not $SkipTestProof -and $hash -cne $verifiedRelease.PackageSha256) { + throw 'The verifier-owned package snapshot changed before publication.' +} +$proofAssetName = if ($SkipTestProof) { + $null +} +else { + "GraphKit.$moduleVersion.tested-release.$($verifiedRelease.ProofSha256).json" +} +if (-not $SkipTestProof) { + $contentAddressedProofPath = Join-Path $verifiedSnapshotDirectory $proofAssetName + Move-Item -LiteralPath $verifiedProofSnapshot.FullName -Destination $contentAddressedProofPath + $verifiedProofSnapshot = Get-Item -LiteralPath $contentAddressedProofPath +} Write-Host '' Write-Host " package : $($package.Name) ($($package.Length) bytes)" -ForegroundColor Cyan @@ -164,35 +178,71 @@ Write-Host '' # --- Publish ---------------------------------------------------------------------------- $publishedSource = $null +$publishedProofSource = if ($SkipTestProof) { 'NONE - WhatIf-only unverified dry run' } else { $null } switch ($Channel) { 'FileSystem' { $target = Join-Path $Destination $package.Name + $proofTarget = if ($SkipTestProof) { $null } else { Join-Path $Destination $proofAssetName } + $packageAlreadyPublished = $false - if ((Test-Path -LiteralPath $target -PathType Leaf) -and -not $Force) { - $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash - if ($existingHash -eq $hash) { - Write-Host ' Already published with identical bytes; nothing to do.' -ForegroundColor Green + if (Test-Path -LiteralPath $target -PathType Leaf) { + $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + if ($existingHash -ceq $hash) { + $packageAlreadyPublished = $true } - else { + elseif (-not $Force) { throw "Version $moduleVersion already exists in '$Destination' with DIFFERENT bytes (channel $existingHash vs local $hash). Replacing it would make every existing pin a lie. Publish a new version, or pass -Force if you are certain." } } + + if (-not $SkipTestProof) { + if (Test-Path -LiteralPath $proofTarget -PathType Leaf) { + $existingProofHash = (Get-FileHash -LiteralPath $proofTarget -Algorithm SHA256).Hash.ToLowerInvariant() + if ($existingProofHash -cne $verifiedRelease.ProofSha256) { + throw "Content-addressed proof '$proofTarget' exists with different bytes; refusing to replace it." + } + Write-Host ' Tested-release proof already exists with identical bytes; nothing to do.' -ForegroundColor Green + } + elseif ($PSCmdlet.ShouldProcess($proofTarget, 'Publish immutable tested-release proof')) { + if (-not (Test-Path -LiteralPath $Destination -PathType Container)) { + $null = New-Item -ItemType Directory -Path $Destination -Force + } + Copy-Item -LiteralPath $verifiedProofSnapshot.FullName -Destination $proofTarget + $publishedProofHash = (Get-FileHash -LiteralPath $proofTarget -Algorithm SHA256).Hash.ToLowerInvariant() + if ($publishedProofHash -cne $verifiedRelease.ProofSha256) { + Remove-Item -LiteralPath $proofTarget -Force -ErrorAction SilentlyContinue + throw "Published tested-release proof '$proofTarget' failed its content hash check." + } + Write-Host " Published tested-release proof to $proofTarget" -ForegroundColor Green + } + $publishedProofSource = [System.IO.Path]::GetFullPath($proofTarget) + + if (-not $WhatIfPreference -and -not (Test-Path -LiteralPath $proofTarget -PathType Leaf)) { + throw 'The tested-release proof was not published; refusing to make the package discoverable.' + } + } + + if ($packageAlreadyPublished) { + Write-Host ' Already published with identical bytes; nothing to do.' -ForegroundColor Green + } elseif ($PSCmdlet.ShouldProcess($target, 'Publish package to file-system channel')) { if (-not (Test-Path -LiteralPath $Destination -PathType Container)) { $null = New-Item -ItemType Directory -Path $Destination -Force } Copy-Item -LiteralPath $package.FullName -Destination $target -Force + $publishedPackageHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + if ($publishedPackageHash -cne $hash) { + Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue + throw "Published package '$target' failed its content hash check." + } Write-Host " Published to $target" -ForegroundColor Green } - $publishedSource = (Resolve-Path -LiteralPath $Destination).Path + $publishedSource = [System.IO.Path]::GetFullPath($Destination) } 'GitHubRelease' { - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw 'The gh CLI is required for the GitHubRelease channel and was not found on PATH.' - } if ($Destination -notmatch '^[^/]+/[^/]+$') { throw "For -Channel GitHubRelease, -Destination must be owner/repo; got '$Destination'." } @@ -201,7 +251,10 @@ switch ($Channel) { # This is an outward publication: it sends the package to GitHub. It only happens # under an explicit ShouldProcess decision, never as a side effect. - if ($PSCmdlet.ShouldProcess("$Destination release $tag", 'Upload package asset to GitHub release')) { + if ($PSCmdlet.ShouldProcess("$Destination release $tag", 'Upload proof and package assets to GitHub release')) { + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw 'The gh CLI is required for the GitHubRelease channel and was not found on PATH.' + } $exists = (& gh release view $tag --repo $Destination --json tagName 2>$null) if ($LASTEXITCODE -ne 0) { & gh release create $tag --repo $Destination --title "GraphKit $moduleVersion" --notes "GraphKit $moduleVersion. sha256 $hash" --prerelease=false @@ -211,12 +264,30 @@ switch ($Channel) { throw "Release $tag already exists in $Destination. Publish a new version rather than replacing one under an existing pin, or pass -Force." } - & gh release upload $tag $package.FullName --repo $Destination --clobber:$Force - if ($LASTEXITCODE -ne 0) { throw "gh release upload failed for $Destination $tag." } - Write-Host " Uploaded $($package.Name) to $Destination release $tag" -ForegroundColor Green + $proofUploadArguments = @( + 'release', 'upload', $tag, + $verifiedProofSnapshot.FullName, + '--repo', $Destination + ) + if ($Force) { $proofUploadArguments += '--clobber' } + & gh @proofUploadArguments + if ($LASTEXITCODE -ne 0) { throw "gh tested-release proof upload failed for $Destination $tag." } + + $packageUploadArguments = @( + 'release', 'upload', $tag, + $package.FullName, + '--repo', $Destination + ) + if ($Force) { $packageUploadArguments += '--clobber' } + & gh @packageUploadArguments + if ($LASTEXITCODE -ne 0) { throw "gh package upload failed for $Destination $tag." } + Write-Host " Uploaded $($package.Name) and $proofAssetName to $Destination release $tag" -ForegroundColor Green } $publishedSource = "https://github.com/$Destination/releases/tag/$tag" + if (-not $SkipTestProof) { + $publishedProofSource = "https://github.com/$Destination/releases/download/$tag/$proofAssetName" + } } } @@ -243,7 +314,9 @@ $pin = [ordered]@{ channel = $Channel source = $publishedSource packageName = $package.Name - testProof = if ($SkipTestProof) { 'NONE - published without test proof' } else { (Resolve-Path -LiteralPath $TestResultPath).Path } + testProof = $publishedProofSource + testProofSha256 = if ($SkipTestProof) { $null } else { $verifiedRelease.ProofSha256 } + testProofRunId = if ($SkipTestProof) { $null } else { $verifiedRelease.RunId } publishedUtc = [datetime]::UtcNow.ToString('o') } @@ -258,3 +331,9 @@ if ($PSCmdlet.ShouldProcess($PinPath, 'Write pin record')) { Write-Host '' [pscustomobject] $pin +} +finally { + if (-not [string]::IsNullOrWhiteSpace($verifiedSnapshotDirectory)) { + Remove-Item -LiteralPath $verifiedSnapshotDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/Publish-GraphKitToGallery.ps1 b/scripts/Publish-GraphKitToGallery.ps1 index 3a57af6..ddf7f4b 100644 --- a/scripts/Publish-GraphKitToGallery.ps1 +++ b/scripts/Publish-GraphKitToGallery.ps1 @@ -31,6 +31,14 @@ .PARAMETER WhatIfOnly Run every pre-flight check and stop, without prompting for a key or publishing. + .PARAMETER ProofPath + Canonical tested-release proof. Defaults to + output/testResults/tested-release-proof.json. + + .PARAMETER TestResultPath + Optional NUnit result path. When supplied, it must be the exact result named and + hashed by the canonical proof. + .EXAMPLE ./scripts/Publish-GraphKitToGallery.ps1 -WhatIfOnly @@ -45,6 +53,10 @@ param( [string] $PackagePath, + [string] $ProofPath, + + [string] $TestResultPath, + [switch] $WhatIfOnly ) @@ -52,12 +64,81 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 $repoRoot = Split-Path $PSScriptRoot -Parent -$manifestPath = Join-Path $repoRoot 'source/GraphKit.psd1' -$manifest = Import-PowerShellDataFile $manifestPath -$version = $manifest.ModuleVersion +$verifier = Join-Path $repoRoot 'scripts/Test-GraphKitReleaseProof.ps1' +$releaseProofVerified = $false +$verifiedRelease = $null +$verifiedSnapshotDirectory = $null + +function Invoke-GalleryReleaseProofVerification { + param([Parameter(Mandatory)] [string] $ResolvedPackagePath) + + if ([string]::IsNullOrWhiteSpace($verifiedSnapshotDirectory)) { + $script:verifiedSnapshotDirectory = [System.IO.Directory]::CreateTempSubdirectory('graphkit-gallery-verified-').FullName + } + $verificationParameters = @{ + PackagePath = $ResolvedPackagePath + RepositoryRoot = $repoRoot + VerifiedPackageCopyPath = Join-Path $verifiedSnapshotDirectory (Split-Path $ResolvedPackagePath -Leaf) + VerifiedProofCopyPath = Join-Path $verifiedSnapshotDirectory 'tested-release-proof.json' + } + if (-not [string]::IsNullOrWhiteSpace($ProofPath)) { + $verificationParameters.ProofPath = $ProofPath + } + if (-not [string]::IsNullOrWhiteSpace($TestResultPath)) { + $verificationParameters.TestResultPath = $TestResultPath + } + return & $verifier @verificationParameters +} -if ([string]::IsNullOrWhiteSpace($PackagePath)) { - $PackagePath = Join-Path $repoRoot "output/GraphKit.$version.nupkg" +try { +# An explicitly supplied package is verified before repository metadata is consulted. +# This keeps the irreversible publication boundary authoritative even for a relocated +# evidence bundle and ensures all later pre-flight checks inspect already-proven bytes. +$packagePathWasExplicit = -not [string]::IsNullOrWhiteSpace($PackagePath) +if (-not $packagePathWasExplicit) { + $packageCandidates = @(Get-ChildItem -LiteralPath (Join-Path $repoRoot 'output') -Filter 'GraphKit.*.nupkg' -File -ErrorAction SilentlyContinue) + if ($packageCandidates.Count -gt 1) { + throw "Multiple GraphKit package candidates exist; supply -PackagePath explicitly: $($packageCandidates.Name -join ', ')." + } + if ($packageCandidates.Count -eq 1) { + $PackagePath = $packageCandidates[0].FullName + } + else { + # There is no artifact to publish. Source is used only to produce an actionable + # missing-path preflight message; it never authorizes or describes present bytes. + $sourceManifest = Import-PowerShellDataFile (Join-Path $repoRoot 'source/GraphKit.psd1') + $PackagePath = Join-Path $repoRoot "output/GraphKit.$($sourceManifest.ModuleVersion).nupkg" + } +} +if (Test-Path -LiteralPath $PackagePath -PathType Leaf) { + $verifiedRelease = Invoke-GalleryReleaseProofVerification -ResolvedPackagePath $PackagePath + $PackagePath = $verifiedRelease.VerifiedPackagePath + $releaseProofVerified = $true +} + +$version = if ($releaseProofVerified) { + [string] $verifiedRelease.Version +} +else { + [string] (Import-PowerShellDataFile (Join-Path $repoRoot 'source/GraphKit.psd1')).ModuleVersion +} +$builtManifestPath = Join-Path $repoRoot "output/module/GraphKit/$version/GraphKit.psd1" +$manifestValidationPath = $builtManifestPath +if ($releaseProofVerified) { + # Keep every manifest-dependent preflight inside the verifier-owned snapshot + # boundary. The verified archive has already passed strict path/file-set checks. + $verifiedPackageContentDirectory = Join-Path $verifiedSnapshotDirectory 'verified-package-content' + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($PackagePath, $verifiedPackageContentDirectory) + $manifestValidationPath = Join-Path $verifiedPackageContentDirectory 'GraphKit.psd1' +} +$manifest = if (Test-Path -LiteralPath $manifestValidationPath -PathType Leaf) { + Import-PowerShellDataFile $manifestValidationPath +} +else { + # This fallback is diagnostic only: canonical proof verification cannot pass without + # the built manifest, and publication remains gated below. + Import-PowerShellDataFile (Join-Path $repoRoot 'source/GraphKit.psd1') } $failures = [System.Collections.Generic.List[string]]::new() @@ -81,11 +162,10 @@ if ($packageExists) { Test-Gate 'package version matches the manifest' ((Split-Path $PackagePath -Leaf) -eq "GraphKit.$version.nupkg") "manifest says $version" } -# --- manifest validity and gallery metadata ---------------------------------------------- -$builtManifest = Join-Path $repoRoot "output/module/GraphKit/$version/GraphKit.psd1" -if (Test-Path -LiteralPath $builtManifest) { +# --- proven built-manifest validity and gallery metadata --------------------------------- +if (Test-Path -LiteralPath $manifestValidationPath) { try { - $null = Test-ModuleManifest -Path $builtManifest -ErrorAction Stop + $null = Test-ModuleManifest -Path $manifestValidationPath -ErrorAction Stop Test-Gate 'Test-ModuleManifest passes' $true } catch { @@ -93,7 +173,7 @@ if (Test-Path -LiteralPath $builtManifest) { } } else { - Test-Gate 'built module present' $false $builtManifest + Test-Gate 'verified module manifest present' $false $manifestValidationPath } $psData = $manifest.PrivateData.PSData @@ -106,72 +186,40 @@ Test-Gate 'ReleaseNotes set' ($psData.ContainsKey('ReleaseNotes') -and -not [str # --- the scan that cannot be undone after the fact --------------------------------------- if ($packageExists) { - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) - try { - $findings = [System.Collections.Generic.List[string]]::new() - $patterns = @{ - 'GUID that is not a well-known Microsoft id' = '\b(?!00000000-0000-0000-0000-00000000000[01]\b)(?!00000003-0000-0000-c000-000000000000\b)(?!' + [regex]::Escape($manifest.GUID) + '\b)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b' - 'certificate thumbprint' = '\b[0-9A-Fa-f]{40}\b' - 'local user path' = '/Users/[A-Za-z0-9._-]+|C:\\Users\\[A-Za-z0-9._-]+' - 'internal project name' = '(?i)\bivy24\b|\bIntuneHealthAutomation\b' - } - - # Customer names are matched by HASH rather than by literal, because this script lives in - # a public repository: a regex spelling out a customer name would publish the name it - # exists to keep out. SHA-256 of the lowercased token, first 32 hex chars. To add one, - # hash it the same way and give it a non-identifying label - never the name itself. - $secretTokenHashes = @{ - '5cad5cdbf022740cbfc976f9836ac89d' = 'customer name (A)' - 'e03427b1afcd1e84a97ed1f2241466cb' = 'internal workspace tenant' - '9a08498936078c81ec926fedbce5e7c9' = 'customer name (A, short form)' - '6ca05670c4afd49e806f7cddbab83b00' = 'lab tenant id' - } - function Get-TokenDigest { - param([string] $Token) - $bytes = [System.Text.Encoding]::UTF8.GetBytes($Token.ToLowerInvariant()) - return [System.BitConverter]::ToString( - [System.Security.Cryptography.SHA256]::HashData($bytes) - ).Replace('-', '').ToLowerInvariant().Substring(0, 32) - } - - foreach ($entry in $archive.Entries) { - if ($entry.FullName -notmatch '\.(psm1|psd1|ps1|ps1xml|txt|nuspec|xml|md)$') { continue } - $reader = [System.IO.StreamReader]::new($entry.Open()) - try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() } - - foreach ($token in [regex]::Matches($content, '[A-Za-z0-9][A-Za-z0-9-]{3,}')) { - $digest = Get-TokenDigest -Token $token.Value - if ($secretTokenHashes.ContainsKey($digest)) { - # Report the label, never the matched value - this output is shown on screen - # and would otherwise reintroduce the name it just caught. - $findings.Add(('{0}: internal identifier - {1}' -f $entry.FullName, $secretTokenHashes[$digest])) - } - } - - foreach ($label in $patterns.Keys) { - foreach ($match in [regex]::Matches($content, $patterns[$label])) { - # Documentation placeholders like 11111111-2222-3333-4444-555555555555 are - # conventional in .EXAMPLE blocks and carry no information. A real - # identifier never has every segment built from one repeated character. - if ($label -like 'GUID*') { - # @() is required: Select-Object -Unique returns a scalar for a - # segment of identical characters, and a scalar has no .Count under - # Set-StrictMode. - $segments = @($match.Value -split '-') - $varied = @($segments | Where-Object { @($_.ToCharArray() | Select-Object -Unique).Count -gt 1 }) - if ($varied.Count -eq 0) { continue } - } - $findings.Add("$label in $($entry.FullName): $($match.Value)") - } - } - } + $privacyScannerPath = Join-Path $PSScriptRoot 'private/Test-GraphKitPackagePrivacy.ps1' + if (-not (Test-Path -LiteralPath $privacyScannerPath -PathType Leaf)) { + throw 'The fail-closed package privacy scanner is unavailable.' + } + . $privacyScannerPath + $authSourcePrivacyCommand = Get-Command -Name Test-GraphKitAuthSourcePrivacy ` + -CommandType Function -ErrorAction SilentlyContinue + if ($null -eq $authSourcePrivacyCommand -or + [IO.Path]::GetFullPath([string] $authSourcePrivacyCommand.ScriptBlock.File) -cne + [IO.Path]::GetFullPath($privacyScannerPath)) { + throw 'The fail-closed authored-source privacy scanner is unavailable.' + } + $privacyResult = Test-GraphKitPackagePrivacy -PackagePath $PackagePath -ModuleGuid ([guid] $manifest.GUID) + + Test-Gate 'package carries no identifiers that must stay private' $privacyResult.Passed "$(@($privacyResult.Findings).Count) finding(s)" + foreach ($finding in @($privacyResult.Findings | Select-Object -First 12)) { + $entryEvidence = $finding.EntrySha256.Substring(0, 12) + $valueEvidence = $finding.EvidenceSha256.Substring(0, 12) + Write-Host (" {0}: {1} [entry sha256:{2}; value redacted sha256:{3}]" -f ` + $finding.Encoding, $finding.Category, $entryEvidence, $valueEvidence) -ForegroundColor Yellow } - finally { $archive.Dispose() } - Test-Gate 'package carries no identifiers that must stay private' ($findings.Count -eq 0) "$($findings.Count) finding(s)" - foreach ($finding in ($findings | Select-Object -First 12)) { - Write-Host " $finding" -ForegroundColor Yellow + # Authored C# is a separate privacy surface: compile/link can omit constants, comments, + # and paths, so absence from the package DLLs is not evidence that public source is clean. + $authSourcePrivacyResult = Test-GraphKitAuthSourcePrivacy ` + -SourceRoot (Join-Path $repoRoot 'src/GraphKit.Auth') ` + -ModuleGuid ([guid] $manifest.GUID) + Test-Gate 'authored GraphKit.Auth source carries no identifiers that must stay private' ` + $authSourcePrivacyResult.Passed "$(@($authSourcePrivacyResult.Findings).Count) finding(s)" + foreach ($finding in @($authSourcePrivacyResult.Findings | Select-Object -First 12)) { + $entryEvidence = $finding.EntrySha256.Substring(0, 12) + $valueEvidence = $finding.EvidenceSha256.Substring(0, 12) + Write-Host (" {0}: {1} [source sha256:{2}; value redacted sha256:{3}]" -f ` + $finding.Encoding, $finding.Category, $entryEvidence, $valueEvidence) -ForegroundColor Yellow } } @@ -190,26 +238,24 @@ catch { Test-Gate 'gallery reachable' $false $_.Exception.Message } -# --- a passing test result for this exact version ---------------------------------------- -$resultFile = Get-ChildItem -Path (Join-Path $repoRoot 'output/testResults') -Filter "NUnit*$version*.xml" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 -if ($null -eq $resultFile) { - Test-Gate "test result for $version present" $false 'run ./build.ps1 -Tasks pack then -Tasks test' -} -else { - [xml] $doc = Get-Content -LiteralPath $resultFile.FullName -Raw - $root = $doc.SelectSingleNode('/test-results') - $failed = [int] $root.GetAttribute('failures') - $total = [int] $root.GetAttribute('total') - Test-Gate "tests green for $version" ($failed -eq 0) "$total tests, $failed failed" -} +# --- one canonical package/module/result proof ------------------------------------------- +Test-Gate 'canonical tested-release proof passes' $releaseProofVerified $( + if ($releaseProofVerified) { + "$($verifiedRelease.TestCount) tests; $($verifiedRelease.ShippedFileCount) shipped files" + } + else { + 'package is absent, so no proof could be checked' + } +) Write-Host '' if ($failures.Count -gt 0) { Write-Host " PRE-FLIGHT FAILED - $($failures.Count) gate(s):" -ForegroundColor Red $failures | ForEach-Object { Write-Host " $_" -ForegroundColor Red } Write-Host '' - exit 1 + # Throw rather than `exit 1`: this script is also invoked from a verifier/bootstrap + # script, where `exit` can terminate only the nested script and let the host report zero. + throw 'PowerShell Gallery preflight failed closed.' } Write-Host ' PRE-FLIGHT PASSED' -ForegroundColor Green Write-Host '' @@ -258,3 +304,9 @@ finally { $plainKey = $null [System.GC]::Collect() } +} +finally { + if (-not [string]::IsNullOrWhiteSpace($verifiedSnapshotDirectory)) { + Remove-Item -LiteralPath $verifiedSnapshotDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 new file mode 100644 index 0000000..6919d08 --- /dev/null +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -0,0 +1,895 @@ +<# + .SYNOPSIS + Verifies that a GraphKit package is the exact artifact bound to a passing test run. + + .DESCRIPTION + Validates the canonical output/testResults/tested-release-proof.json record. The + proof binds one module version, the exact package archive, the NUnit/Pester result + pair and its whole-result policy, and the SHA-256 of every shipped module file. + Both GraphKit publication paths call this script; neither maintains an independent + or weaker definition of "tested release". + + .PARAMETER PackagePath + The already-built GraphKit .nupkg to verify. + + .PARAMETER ProofPath + The canonical proof. Defaults to output/testResults/tested-release-proof.json. + + .PARAMETER TestResultPath + Optional operator-supplied NUnit result. When supplied, it must be the exact file + named and hashed by the proof; a separate same-version result is not accepted. + + .PARAMETER RepositoryRoot + Repository root containing output/ and tests/. Defaults to this script's parent. + The override permits offline verification of a relocated release evidence bundle. + + .PARAMETER VerifiedPackageCopyPath + Optional caller-owned destination for a snapshot of the exact verified package. + Publication scripts use this snapshot so a concurrent replacement of PackagePath + cannot change the bytes after verification. + + .PARAMETER VerifiedProofCopyPath + Optional caller-owned destination for a snapshot of the exact verified proof. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [string] $ProofPath, + + [string] $TestResultPath, + + [string] $RepositoryRoot, + + [string] $VerifiedPackageCopyPath, + + [string] $VerifiedProofCopyPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +$minimumTests = 1482 +$allowedSkips = 0 +$allowedNotRun = 0 + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Split-Path $PSScriptRoot -Parent +} +$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath + +if ([string]::IsNullOrWhiteSpace($ProofPath)) { + $ProofPath = Join-Path $RepositoryRoot 'output/testResults/tested-release-proof.json' +} + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "Package '$PackagePath' does not exist." +} +$package = Get-Item -LiteralPath $PackagePath +if ($package.Extension -cne '.nupkg') { + throw "Package '$PackagePath' is not a .nupkg." +} +if ($package.BaseName -notmatch '^(?.+?)\.(?\d+\.\d+\.\d+(?:-[A-Za-z0-9.\-]+)?)$') { + throw "Cannot parse a module name and version from '$($package.Name)'." +} +$moduleName = $Matches['name'] +$moduleVersion = $Matches['version'] +if ($moduleName -cne 'GraphKit') { + throw "Package '$($package.Name)' is '$moduleName', not GraphKit." +} + +if (-not (Test-Path -LiteralPath $ProofPath -PathType Leaf)) { + throw "No tested release proof found at '$ProofPath'. Run ./build.ps1 -Tasks pack then ./build.ps1 -Tasks test." +} +$initialProofHash = (Get-FileHash -LiteralPath $ProofPath -Algorithm SHA256).Hash.ToLowerInvariant() +try { + $proof = Get-Content -LiteralPath $ProofPath -Raw | ConvertFrom-Json -Depth 10 + $proofSchemaVersion = [int] $proof.schemaVersion + $proofRunId = [string] $proof.runId + $proofSourceRevision = [string] $proof.source.revision + $proofSourceClean = $proof.source.clean + $proofSourceStateHash = [string] $proof.source.stateSha256 + $proofModuleName = [string] $proof.module.name + $proofModuleVersion = [string] $proof.module.version + $proofModuleBaseVersion = [string] $proof.module.baseVersion + $proofModuleFiles = @($proof.module.files) + $proofPackageName = [string] $proof.package.name + $proofPackageHash = [string] $proof.package.sha256 + $proofNUnitName = [string] $proof.testRun.nunit.name + $proofNUnitHash = [string] $proof.testRun.nunit.sha256 + $proofPesterObjectName = [string] $proof.testRun.pesterObject.name + $proofPesterObjectHash = [string] $proof.testRun.pesterObject.sha256 + $proofMinimumTests = [int] $proof.testRun.policy.minimumTests + $proofAllowedSkips = [int] $proof.testRun.policy.allowedSkips + $proofAllowedNotRun = [int] $proof.testRun.policy.allowedNotRun + $proofSummary = $proof.testRun.summary +} +catch { + throw "The tested release proof '$ProofPath' is unreadable or incomplete: $($_.Exception.Message)" +} + +$parsedRunId = [guid]::Empty +if ($proofSchemaVersion -ne 3 -or + -not [guid]::TryParse($proofRunId, [ref] $parsedRunId) -or + $parsedRunId -eq [guid]::Empty) { + throw "The tested release proof '$ProofPath' has an unsupported schema version or invalid run id." +} +if ($proofSourceRevision -notmatch '^[0-9a-f]{40}$' -or $proofSourceClean -isnot [bool]) { + throw "The tested release proof '$ProofPath' has invalid source provenance." +} +if ($proofSourceStateHash -notmatch '^[0-9a-f]{64}$') { + throw "The tested release proof '$ProofPath' has no valid canonical source-state hash." +} +$expectedProofVersion = "0.4.0-r8.g$($proofSourceRevision.Substring(0, 12))" +if (-not $proofSourceClean) { + $expectedProofVersion += ".d$($proofSourceStateHash.Substring(0, 12))" +} +if ($proofModuleBaseVersion -cne '0.4.0') { + throw "The tested release proof '$ProofPath' requires the R8 base '0.4.0' and train 'r8'." +} +if ($proofModuleVersion -cne $expectedProofVersion) { + throw "The tested release proof '$ProofPath' does not bind its module version to source provenance." +} +if (-not $proofSourceClean) { + throw "The tested release proof '$ProofPath' represents dirty source state and is non-authoritative: it cannot produce VERIFIED TESTED RELEASE authority, snapshots, or publication input." +} +if ($proofModuleName -cne $moduleName -or $proofModuleVersion -cne $moduleVersion) { + throw "The tested release proof names '$proofModuleName' $proofModuleVersion, not '$moduleName' $moduleVersion." +} +if ($proofPackageName -cne $package.Name -or $proofPackageHash -notmatch '^[0-9a-fA-F]{64}$') { + throw "The tested release proof does not name a valid hash for package '$($package.Name)'." +} +$proofPackageHash = $proofPackageHash.ToLowerInvariant() + +if ($proofMinimumTests -ne $minimumTests -or + $proofAllowedSkips -ne $allowedSkips -or + $proofAllowedNotRun -ne $allowedNotRun) { + throw "The tested release proof carries a weakened or stale whole-result policy. Expected minimumTests=$minimumTests, allowedSkips=$allowedSkips, allowedNotRun=$allowedNotRun." +} + +$proofFileMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$proofNormalizedPathMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($fileRecord in $proofModuleFiles) { + try { + $relativePath = [string] $fileRecord.path + $relativeHash = [string] $fileRecord.sha256 + } + catch { + throw "The tested release proof contains an incomplete module-file record." + } + + $segments = @($relativePath -split '/') + if ([string]::IsNullOrWhiteSpace($relativePath) -or + [System.IO.Path]::IsPathRooted($relativePath) -or + $relativePath -match '^[A-Za-z]:' -or + $relativePath.IndexOf('\') -ge 0 -or + $segments -contains '' -or + $segments -contains '.' -or + $segments -contains '..' -or + $relativeHash -notmatch '^[0-9a-fA-F]{64}$') { + throw "The tested release proof contains an invalid module-file record for '$relativePath'." + } + if (-not $proofFileMap.TryAdd($relativePath, $relativeHash.ToLowerInvariant())) { + throw "The tested release proof contains a duplicate or case-colliding module-file record for '$relativePath'." + } + $normalizedPath = $relativePath.Normalize([Text.NormalizationForm]::FormC) + if ($relativePath -cne $normalizedPath -or + -not $proofNormalizedPathMap.TryAdd($normalizedPath, $relativePath)) { + throw "The tested release proof contains a Unicode-normalization or NFC-colliding module-file record for '$relativePath'." + } +} +if ($proofFileMap.Count -eq 0) { + throw 'The tested release proof records zero shipped module files.' +} + +$builtModuleDirectory = Join-Path $RepositoryRoot "output/module/GraphKit/$proofModuleBaseVersion" +if (-not (Test-Path -LiteralPath $builtModuleDirectory -PathType Container)) { + throw "The built module directory '$builtModuleDirectory' is missing." +} + +[string[]] $currentRelativePaths = @( + Get-ChildItem -LiteralPath $builtModuleDirectory -Recurse -File -Force | + ForEach-Object { + $_.FullName.Substring($builtModuleDirectory.Length + 1) -replace '\\', '/' + } +) +[System.Array]::Sort($currentRelativePaths, [System.StringComparer]::Ordinal) +$currentPathMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$currentNormalizedPathMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($relativePath in $currentRelativePaths) { + $normalizedPath = $relativePath.Normalize([Text.NormalizationForm]::FormC) + if ($relativePath -cne $normalizedPath -or + -not $currentPathMap.TryAdd($relativePath, $relativePath) -or + -not $currentNormalizedPathMap.TryAdd($normalizedPath, $relativePath)) { + throw "The built module contains case- or Unicode-normalization-colliding paths for '$relativePath'." + } +} + +$missingPaths = @($proofFileMap.Keys | Where-Object { -not $currentPathMap.ContainsKey([string] $_) }) +$extraPaths = @($currentRelativePaths | Where-Object { -not $proofFileMap.ContainsKey($_) }) +$caseChangedPaths = @( + $proofFileMap.Keys | Where-Object { + $currentPathMap.ContainsKey([string] $_) -and + -not [string]::Equals([string] $_, $currentPathMap[[string] $_], [System.StringComparison]::Ordinal) + } +) +if ($missingPaths.Count -gt 0 -or $extraPaths.Count -gt 0 -or $caseChangedPaths.Count -gt 0) { + $details = @( + $missingPaths | ForEach-Object { "missing:$_" } + $extraPaths | ForEach-Object { "extra:$_" } + $caseChangedPaths | ForEach-Object { "case:$($_)->$($currentPathMap[[string] $_])" } + ) -join ', ' + throw "The built module file set differs from the tested release proof ($details)." +} + +foreach ($relativePath in $proofFileMap.Keys) { + $currentPath = Join-Path $builtModuleDirectory $relativePath + $currentHash = (Get-FileHash -LiteralPath $currentPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($currentHash -cne $proofFileMap[$relativePath]) { + throw "'$relativePath' in the built module does not match the tested release proof." + } +} + +$builtManifestPath = Join-Path $builtModuleDirectory 'GraphKit.psd1' +if (-not (Test-Path -LiteralPath $builtManifestPath -PathType Leaf)) { + throw "The built module file set differs from the tested release proof (missing:GraphKit.psd1)." +} +$builtManifest = Import-PowerShellDataFile -LiteralPath $builtManifestPath +if ([string] $builtManifest.ModuleVersion -cne $proofModuleBaseVersion) { + throw "The built GraphKit.psd1 declares version '$($builtManifest.ModuleVersion)', not proof base version '$proofModuleBaseVersion'." +} +$expectedPrerelease = $moduleVersion.Substring($proofModuleBaseVersion.Length + 1) +if ([string] $builtManifest.PrivateData.PSData.Prerelease -cne $expectedPrerelease) { + throw "The built GraphKit.psd1 prerelease '$($builtManifest.PrivateData.PSData.Prerelease)' does not match proof version '$moduleVersion'." +} + +$graphKitAuthContractPath = 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' +$builtRequiredAssemblies = if ($builtManifest -is [Collections.IDictionary] -and + $builtManifest.Contains('RequiredAssemblies')) { + @($builtManifest['RequiredAssemblies'] | ForEach-Object { [string]$_ }) +} +else { + @() +} +$proofGraphKitAuthPaths = @($proofFileMap.Keys | Where-Object { $_.StartsWith('Assemblies/GraphKit.Auth/', [StringComparison]::Ordinal) }) +$verifiedGraphKitAuthStage = $null +if (($builtRequiredAssemblies -join '|') -ceq $graphKitAuthContractPath) { + $taskPath = Join-Path $RepositoryRoot '.build/GraphKitAuth.tasks.ps1' + $helperPath = Join-Path $RepositoryRoot 'scripts/private/GraphKit.AuthStageCapture.cs' + if (-not (Test-Path -LiteralPath $taskPath -PathType Leaf) -or + -not (Test-Path -LiteralPath $helperPath -PathType Leaf)) { + throw 'The built GraphKit.Auth prerequisite has no tracked sealed-stage verifier.' + } + . $taskPath -SkipTaskRegistration + $stageVersionRoot = Join-Path $RepositoryRoot "output/GraphKit.Auth/stage/$moduleVersion" + if (-not (Test-Path -LiteralPath $stageVersionRoot -PathType Container)) { + throw "The sealed GraphKit.Auth stage for '$moduleVersion' is missing." + } + $stageEntries = @([IO.Directory]::EnumerateFileSystemEntries($stageVersionRoot)) + if ($stageEntries.Count -ne 1 -or -not (Test-Path -LiteralPath $stageEntries[0] -PathType Container)) { + throw "The sealed GraphKit.Auth stage for '$moduleVersion' is not one exact digest envelope." + } + $verifiedGraphKitAuthStage = Test-GraphKitAuthSealedStage -StagePath $stageEntries[0] -FullVersion $moduleVersion + $stageModulePaths = @($verifiedGraphKitAuthStage.Manifest.files | ForEach-Object { + "Assemblies/GraphKit.Auth/$([IO.Path]::GetFileName([string]$_.path))" + }) + $proofGraphKitAuthSet = @($proofGraphKitAuthPaths | Sort-Object) -join '|' + $stageGraphKitAuthSet = @($stageModulePaths | Sort-Object) -join '|' + if ($proofGraphKitAuthSet -cne $stageGraphKitAuthSet) { + throw 'The tested release proof GraphKit.Auth subtree does not match the sealed five-file manifest.' + } + foreach ($stageFile in @($verifiedGraphKitAuthStage.Manifest.files)) { + $modulePath = "Assemblies/GraphKit.Auth/$([IO.Path]::GetFileName([string]$stageFile.path))" + if (-not $proofFileMap.ContainsKey($modulePath) -or + $proofFileMap[$modulePath] -cne [string]$stageFile.sha256) { + throw "The tested release proof '$modulePath' digest does not match the sealed GraphKit.Auth stage." + } + } +} +elseif ($proofGraphKitAuthPaths.Count -ne 0) { + throw 'The tested release proof contains GraphKit.Auth runtime bytes without the exact built contracts prerequisite.' +} + +$currentPackageHash = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() +if ($currentPackageHash -cne $proofPackageHash) { + throw "The '$($package.Name)' package archive changed after the passing test run." +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + $wrapperPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($wrapperPath in @( + "$moduleName.nuspec", + '[Content_Types].xml', + '_rels/.rels' + )) { + $null = $wrapperPaths.Add($wrapperPath) + } + + $archivePathMap = [System.Collections.Generic.Dictionary[string, System.IO.Compression.ZipArchiveEntry]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $archiveNormalizedPathMap = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + foreach ($entry in $archive.Entries) { + $entryPath = [string] $entry.FullName + $segments = @($entryPath -split '/') + if ([string]::IsNullOrWhiteSpace($entryPath) -or + [string]::IsNullOrEmpty($entry.Name) -or + $entryPath.EndsWith('/') -or + [System.IO.Path]::IsPathRooted($entryPath) -or + $entryPath -match '^[A-Za-z]:' -or + $entryPath.IndexOf('\') -ge 0 -or + $segments -contains '' -or + $segments -contains '.' -or + $segments -contains '..') { + throw "Package '$($package.Name)' contains an unsafe package entry path '$entryPath'." + } + if (-not $archivePathMap.TryAdd($entryPath, $entry)) { + throw "Package '$($package.Name)' contains a duplicate entry path or case-colliding path '$entryPath'." + } + $normalizedEntryPath = $entryPath.Normalize([Text.NormalizationForm]::FormC) + if ($entryPath -cne $normalizedEntryPath -or + -not $archiveNormalizedPathMap.TryAdd($normalizedEntryPath, $entryPath)) { + throw "Package '$($package.Name)' contains a Unicode-normalization or NFC-colliding package entry path '$entryPath'." + } + $externalAttributes = ([int64]$entry.ExternalAttributes) -band 0xffffffffL + $unixMode = ($externalAttributes -shr 16) -band 0xffff + $unixFileType = $unixMode -band 0xf000 + $windowsAttributes = $externalAttributes -band 0xffff + if (($windowsAttributes -band 0x0010) -ne 0 -or + ($windowsAttributes -band 0x0400) -ne 0 -or + ($unixFileType -ne 0 -and $unixFileType -ne 0x8000)) { + throw "Package '$($package.Name)' contains a link, reparse point, or non-regular ZIP entry '$entryPath'." + } + } + + foreach ($wrapperPath in $wrapperPaths) { + if (-not $archivePathMap.ContainsKey($wrapperPath) -or + -not [string]::Equals($wrapperPath, $archivePathMap[$wrapperPath].FullName, [System.StringComparison]::Ordinal)) { + throw "Package '$($package.Name)' wrapper file set differs from the canonical NuGet shape (missing or case-changed '$wrapperPath')." + } + } + + $coreProperties = @( + $archivePathMap.Keys | Where-Object { + $_ -match '^package/services/metadata/core-properties/(?:nuget|[0-9a-f]{32})\.psmdcp$' + } + ) + if ($coreProperties.Count -ne 1) { + throw "Package '$($package.Name)' wrapper file set differs from the canonical NuGet shape (expected exactly one core-properties .psmdcp entry)." + } + $null = $wrapperPaths.Add($coreProperties[0]) + + $archiveModulePaths = @($archivePathMap.Keys | Where-Object { -not $wrapperPaths.Contains($_) }) + $archiveMissing = @($proofFileMap.Keys | Where-Object { -not $archivePathMap.ContainsKey([string] $_) }) + $archiveExtra = @($archiveModulePaths | Where-Object { -not $proofFileMap.ContainsKey($_) }) + $archiveCaseChanged = @( + $proofFileMap.Keys | Where-Object { + $archivePathMap.ContainsKey([string] $_) -and + -not [string]::Equals([string] $_, $archivePathMap[[string] $_].FullName, [System.StringComparison]::Ordinal) + } + ) + if ($archiveMissing.Count -gt 0 -or $archiveExtra.Count -gt 0 -or $archiveCaseChanged.Count -gt 0) { + $details = @( + $archiveMissing | ForEach-Object { "missing:$_" } + $archiveExtra | ForEach-Object { "extra:$_" } + $archiveCaseChanged | ForEach-Object { "case:$($_)->$($archivePathMap[[string] $_].FullName)" } + ) -join ', ' + throw "The package module file set differs from the tested release proof ($details)." + } + + foreach ($relativePath in $proofFileMap.Keys) { + $stream = $archivePathMap[$relativePath].Open() + try { + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $archiveHash = [System.BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } + } + finally { + $stream.Dispose() + } + if ($archiveHash -cne $proofFileMap[$relativePath]) { + throw "'$relativePath' in '$($package.Name)' does not match the tested release proof." + } + } + + $reader = [System.IO.StreamReader]::new($archivePathMap["$moduleName.nuspec"].Open()) + try { + [xml] $nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + $namespace = [System.Xml.XmlNamespaceManager]::new($nuspec.NameTable) + $namespace.AddNamespace('n', [string] $nuspec.DocumentElement.NamespaceURI) + $metadataNode = $nuspec.SelectSingleNode('/n:package/n:metadata', $namespace) + if ($null -eq $metadataNode) { + throw "Package '$($package.Name)' has no canonical nuspec metadata node." + } + $requiredMetadataNames = @( + 'id', + 'version', + 'authors', + 'owners', + 'requireLicenseAcceptance', + 'licenseUrl', + 'description', + 'releaseNotes', + 'copyright', + 'tags' + ) + $supportedMetadataNames = @($requiredMetadataNames) + 'dependencies' + $declaredRequiredModules = [object[]]::new(0) + if ($builtManifest.ContainsKey('RequiredModules') -and $null -ne $builtManifest['RequiredModules']) { + $declaredRequiredModules = [object[]] @($builtManifest['RequiredModules']) + } + $dependenciesRequired = $declaredRequiredModules.Count -gt 0 + $metadataNameSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + if ($metadataNode.Attributes.Count -ne 0) { + throw 'Package nuspec contains unsupported nuspec metadata attributes.' + } + foreach ($metadataChild in @($metadataNode.ChildNodes | Where-Object NodeType -EQ ([System.Xml.XmlNodeType]::Element))) { + if ($metadataChild.NamespaceURI -cne $nuspec.DocumentElement.NamespaceURI -or + $metadataChild.LocalName -notin $supportedMetadataNames -or + -not $metadataNameSet.Add($metadataChild.LocalName)) { + throw "Package nuspec contains an unsupported nuspec metadata field '$($metadataChild.LocalName)' or duplicate field." + } + } + $missingRequiredMetadata = @( + $requiredMetadataNames | Where-Object { -not $metadataNameSet.Contains($_) }) + if ($missingRequiredMetadata.Count -ne 0 -or + ($dependenciesRequired -and -not $metadataNameSet.Contains('dependencies')) -or + $metadataNameSet.Count -gt $supportedMetadataNames.Count) { + throw 'Package nuspec does not contain the exact supported nuspec metadata field set.' + } + function Get-NuspecMetadataValue { + param([Parameter(Mandatory)] [string] $Name) + $nodes = @($metadataNode.SelectNodes("n:$Name", $namespace)) + if ($nodes.Count -ne 1) { + throw "Package nuspec must contain exactly one '$Name' metadata field." + } + return [string] $nodes[0].InnerText + } + function ConvertTo-CanonicalLineEndings { + param([AllowEmptyString()] [string] $Value) + return $Value.Replace("`r`n", "`n").Replace("`r", "`n") + } + + $psData = $builtManifest.PrivateData.PSData + $exportedFunctions = @($builtManifest.FunctionsToExport | ForEach-Object { [string] $_ }) + $expectedTags = [System.Collections.Generic.List[string]]::new() + foreach ($tag in @($psData.Tags)) { $expectedTags.Add([string] $tag) } + $expectedTags.Add('PSModule') + if ($exportedFunctions.Count -gt 0) { + $expectedTags.Add('PSIncludes_Function') + foreach ($functionName in $exportedFunctions) { $expectedTags.Add("PSFunction_$functionName") } + foreach ($functionName in $exportedFunctions) { $expectedTags.Add("PSCommand_$functionName") } + } + + $expectedMetadata = [ordered] @{ + id = $moduleName + version = $moduleVersion + authors = [string] $builtManifest.Author + owners = [string] $builtManifest.Author + requireLicenseAcceptance = 'false' + licenseUrl = [string] $psData.LicenseUri + description = [string] $builtManifest.Description + releaseNotes = [string] $psData.ReleaseNotes + copyright = [string] $builtManifest.Copyright + tags = $expectedTags -join ' ' + } + + # Publish-Module writes PowerShellGet's export-discovery tags. The R8 + # package task deliberately uses PSResourceGet's SemVer-capable archive + # writer instead, which emits its baseline PSModule tag plus only the + # manifest-declared tags (no export-discovery tags). Both forms are + # deterministic projections of the same proven manifest; accept only either + # exact projection so metadata tampering remains detectable. + $expectedPsResourceTags = (@('PSModule') + @($psData.Tags) -join ' ') + foreach ($fieldName in $expectedMetadata.Keys) { + $actualValue = Get-NuspecMetadataValue -Name $fieldName + $expectedValue = [string] $expectedMetadata[$fieldName] + if ($fieldName -eq 'tags') { + $actualValue = (@($actualValue -split '\s+' | Where-Object { $_ }) -join ' ') + if ($actualValue -cnotin @($expectedValue, $expectedPsResourceTags)) { + throw "Package metadata field '$fieldName' does not match the proven built manifest." + } + continue + } + else { + $actualValue = ConvertTo-CanonicalLineEndings -Value $actualValue + $expectedValue = ConvertTo-CanonicalLineEndings -Value $expectedValue + if ($fieldName -eq 'releaseNotes') { + $terminalLineEndings = [char[]] @("`r", "`n") + $actualValue = $actualValue.TrimEnd($terminalLineEndings) + $expectedValue = $expectedValue.TrimEnd($terminalLineEndings) + } + } + if ($actualValue -cne $expectedValue) { + throw "Package metadata field '$fieldName' does not match the proven built manifest." + } + } + + $expectedDependencies = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($requiredModule in $declaredRequiredModules) { + $requiredIsDictionary = $requiredModule -is [System.Collections.IDictionary] + $requiredName = if ($requiredModule -is [string]) { + [string] $requiredModule + } + elseif ($requiredIsDictionary) { + [string] $requiredModule['ModuleName'] + } + else { + [string] $requiredModule.ModuleName + } + $requiredPropertyNames = if ($requiredModule -is [string]) { + @() + } + elseif ($requiredIsDictionary) { + @($requiredModule.Keys) + } + else { + @($requiredModule.PSObject.Properties.Name) + } + $requiredVersion = if ($requiredModule -is [string]) { + '' + } + elseif ('RequiredVersion' -in $requiredPropertyNames -and -not [string]::IsNullOrWhiteSpace( + $(if ($requiredIsDictionary) { [string] $requiredModule['RequiredVersion'] } else { [string] $requiredModule.RequiredVersion }) + )) { + if ($requiredIsDictionary) { [string] $requiredModule['RequiredVersion'] } else { [string] $requiredModule.RequiredVersion } + } + elseif ('ModuleVersion' -in $requiredPropertyNames) { + if ($requiredIsDictionary) { [string] $requiredModule['ModuleVersion'] } else { [string] $requiredModule.ModuleVersion } + } + else { '' } + if ([string]::IsNullOrWhiteSpace($requiredName) -or [string]::IsNullOrWhiteSpace($requiredVersion) -or + -not $expectedDependencies.TryAdd($requiredName, $requiredVersion)) { + throw 'The built manifest RequiredModules shape cannot be represented as one exact nuspec dependency set.' + } + } + + $dependencyContainers = @($metadataNode.SelectNodes('n:dependencies', $namespace)) + if (($expectedDependencies.Count -gt 0 -and $dependencyContainers.Count -ne 1) -or + ($expectedDependencies.Count -eq 0 -and $dependencyContainers.Count -gt 1)) { + throw 'Package nuspec must contain exactly one dependencies element matching the built manifest.' + } + $actualDependencies = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $dependencyNodes = @() + if ($dependencyContainers.Count -eq 1) { + $dependencyNodes = @($dependencyContainers[0].ChildNodes | + Where-Object NodeType -EQ ([System.Xml.XmlNodeType]::Element)) + } + foreach ($dependencyNode in $dependencyNodes) { + $attributeNames = @($dependencyNode.Attributes | ForEach-Object Name | Sort-Object) + if ($dependencyNode.LocalName -cne 'dependency' -or + $dependencyNode.NamespaceURI -cne $nuspec.DocumentElement.NamespaceURI -or + ($attributeNames -join ',') -cne 'id,version' -or + @($dependencyNode.ChildNodes | Where-Object NodeType -EQ ([System.Xml.XmlNodeType]::Element)).Count -ne 0) { + throw 'Package nuspec contains an unsupported dependency shape.' + } + $dependencyId = [string] $dependencyNode.GetAttribute('id') + $dependencyVersion = [string] $dependencyNode.GetAttribute('version') + if ([string]::IsNullOrWhiteSpace($dependencyId) -or [string]::IsNullOrWhiteSpace($dependencyVersion) -or + -not $actualDependencies.TryAdd($dependencyId, $dependencyVersion)) { + throw 'Package nuspec contains an invalid or duplicate dependency.' + } + } + if ($actualDependencies.Count -ne $expectedDependencies.Count) { + throw 'Package nuspec dependencies do not match the proven built manifest.' + } + foreach ($dependencyId in $expectedDependencies.Keys) { + if (-not $actualDependencies.ContainsKey($dependencyId) -or + $actualDependencies[$dependencyId] -cne $expectedDependencies[$dependencyId]) { + throw 'Package nuspec dependencies do not match the proven built manifest.' + } + } +} +finally { + $archive.Dispose() +} + +$resultsDirectory = Join-Path $RepositoryRoot 'output/testResults' +foreach ($resultName in @($proofNUnitName, $proofPesterObjectName)) { + if ([string]::IsNullOrWhiteSpace($resultName) -or + $resultName.IndexOfAny([char[]] @('/', '\')) -ge 0 -or + [System.IO.Path]::GetFileName($resultName) -cne $resultName) { + throw "The tested release proof contains an unsafe result filename '$resultName'." + } +} +$nunitMatch = [regex]::Match($proofNUnitName, '^NUnitXml_(?.+\.xml)$') +$pesterObjectMatch = [regex]::Match($proofPesterObjectName, '^PesterObject_(?.+\.xml)$') +if (-not $nunitMatch.Success -or + -not $pesterObjectMatch.Success -or + $nunitMatch.Groups['suffix'].Value -cne $pesterObjectMatch.Groups['suffix'].Value) { + throw 'The tested release proof does not bind one matching NUnit/Pester-object result pair.' +} +if ($proofNUnitHash -notmatch '^[0-9a-fA-F]{64}$' -or + $proofPesterObjectHash -notmatch '^[0-9a-fA-F]{64}$') { + throw 'The tested release proof contains an invalid NUnit or Pester-object hash.' +} +$proofNUnitHash = $proofNUnitHash.ToLowerInvariant() +$proofPesterObjectHash = $proofPesterObjectHash.ToLowerInvariant() + +$boundNUnitPath = Join-Path $resultsDirectory $proofNUnitName +$boundPesterObjectPath = Join-Path $resultsDirectory $proofPesterObjectName +if (-not (Test-Path -LiteralPath $boundNUnitPath -PathType Leaf) -or + -not (Test-Path -LiteralPath $boundPesterObjectPath -PathType Leaf)) { + throw 'A result file bound by the tested release proof is missing.' +} +if (-not [string]::IsNullOrWhiteSpace($TestResultPath)) { + $suppliedResultPath = (Resolve-Path -LiteralPath $TestResultPath).ProviderPath + $resolvedBoundNUnitPath = (Resolve-Path -LiteralPath $boundNUnitPath).ProviderPath + $comparison = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if (-not [string]::Equals($suppliedResultPath, $resolvedBoundNUnitPath, $comparison)) { + throw "The supplied NUnit result is not the one bound by the tested release proof. Use '$boundNUnitPath'." + } +} + +function Assert-BoundFileHash { + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [string] $ExpectedHash, + [Parameter(Mandatory)] [string] $Label + ) + $actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -cne $ExpectedHash) { + throw "The $Label bound by the tested release proof changed after proof creation." + } +} +Assert-BoundFileHash -Path $boundNUnitPath -ExpectedHash $proofNUnitHash -Label 'NUnit result' +Assert-BoundFileHash -Path $boundPesterObjectPath -ExpectedHash $proofPesterObjectHash -Label 'Pester object' + +[xml] $resultDocument = Get-Content -LiteralPath $boundNUnitPath -Raw +$resultRoot = $resultDocument.SelectSingleNode('/test-results') +if ($null -eq $resultRoot) { + throw 'The NUnit result has no root.' +} +function Get-RequiredCount { + param([Parameter(Mandatory)] [System.Xml.XmlElement] $Element, [Parameter(Mandatory)] [string] $Name) + $value = [string] $Element.GetAttribute($Name) + $parsed = 0 + if (-not [int]::TryParse($value, [ref] $parsed) -or $parsed -lt 0) { + throw "The NUnit '$Name' count is unreadable: '$value'." + } + return $parsed +} +$topSuite = $resultRoot.SelectSingleNode('test-suite') +if ($null -eq $topSuite) { + throw 'The NUnit result has no top-level test suite.' +} +$actualSummary = [ordered] @{ + overallResult = [string] $topSuite.GetAttribute('result') + pesterResult = '' + executed = $false + total = Get-RequiredCount -Element $resultRoot -Name 'total' + passed = 0 + failures = Get-RequiredCount -Element $resultRoot -Name 'failures' + errors = Get-RequiredCount -Element $resultRoot -Name 'errors' + skipped = Get-RequiredCount -Element $resultRoot -Name 'skipped' + inconclusive = Get-RequiredCount -Element $resultRoot -Name 'inconclusive' + notRun = 0 + failedBlocks = 0 + failedContainers = 0 +} + +$pesterResult = Import-Clixml -LiteralPath $boundPesterObjectPath +foreach ($requiredProperty in @( + 'Result', + 'TotalCount', + 'PassedCount', + 'FailedCount', + 'SkippedCount', + 'NotRunCount', + 'InconclusiveCount', + 'FailedBlocksCount', + 'FailedContainersCount', + 'Executed' +)) { + if ($requiredProperty -notin @($pesterResult.PSObject.Properties.Name)) { + throw "The Pester object has no '$requiredProperty' property, so the full result cannot be verified." + } +} +function Get-RequiredPesterCount { + param([Parameter(Mandatory)] [string] $Name) + $raw = $pesterResult.$Name + $parsed = 0 + if ($null -eq $raw -or -not [int]::TryParse([string] $raw, [ref] $parsed) -or $parsed -lt 0) { + throw "The Pester '$Name' count is unreadable: '$raw'." + } + return $parsed +} +function Get-RequiredPesterBoolean { + param([Parameter(Mandatory)] [string] $Name) + $raw = $pesterResult.$Name + $parsed = $false + if ($null -eq $raw -or -not [bool]::TryParse([string] $raw, [ref] $parsed)) { + throw "The Pester '$Name' value is unreadable: '$raw'." + } + return $parsed +} +$actualSummary.pesterResult = [string] $pesterResult.Result +$actualSummary.executed = Get-RequiredPesterBoolean -Name 'Executed' +$pesterTotal = Get-RequiredPesterCount -Name 'TotalCount' +$actualSummary.passed = Get-RequiredPesterCount -Name 'PassedCount' +$pesterFailed = Get-RequiredPesterCount -Name 'FailedCount' +$pesterSkipped = Get-RequiredPesterCount -Name 'SkippedCount' +$actualSummary.notRun = Get-RequiredPesterCount -Name 'NotRunCount' +$pesterInconclusive = Get-RequiredPesterCount -Name 'InconclusiveCount' +$actualSummary.failedBlocks = Get-RequiredPesterCount -Name 'FailedBlocksCount' +$actualSummary.failedContainers = Get-RequiredPesterCount -Name 'FailedContainersCount' +if ($actualSummary.failedBlocks -gt 0) { + throw "$($actualSummary.failedBlocks) failed block(s) were recorded in the bound Pester result." +} +if ($actualSummary.failedContainers -gt 0) { + throw "$($actualSummary.failedContainers) failed container(s) / discovery error(s) were recorded in the bound Pester result." +} +if (-not $actualSummary.executed) { + throw 'The bound Pester result was not executed.' +} +if ($actualSummary.inconclusive -gt 0 -or $pesterInconclusive -gt 0) { + throw "$([Math]::Max($actualSummary.inconclusive, $pesterInconclusive)) inconclusive test(s) were recorded in the bound result." +} +if ($pesterTotal -ne $actualSummary.total -or + $pesterFailed -ne $actualSummary.failures -or + $pesterSkipped -ne $actualSummary.skipped -or + $pesterInconclusive -ne $actualSummary.inconclusive) { + throw 'The bound NUnit and Pester-object result summaries disagree.' +} +$pesterOutcomeTotal = [long] $actualSummary.passed + + [long] $actualSummary.failures + + [long] $actualSummary.skipped + + [long] $actualSummary.inconclusive + + [long] $actualSummary.notRun +if ($pesterOutcomeTotal -ne [long] $actualSummary.total) { + throw "Pester count arithmetic is inconsistent: passed + failed + skipped + inconclusive + NotRun is $pesterOutcomeTotal, not total $($actualSummary.total)." +} + +foreach ($summaryField in $actualSummary.Keys) { + $proofValue = if ($summaryField -in @('overallResult', 'pesterResult')) { + [string] $proofSummary.$summaryField + } + elseif ($summaryField -eq 'executed') { + [bool] $proofSummary.$summaryField + } + else { + [int] $proofSummary.$summaryField + } + if ($proofValue -cne $actualSummary[$summaryField]) { + throw "The tested release proof summary does not match the bound result for '$summaryField'." + } +} + +$gatePath = Join-Path $RepositoryRoot 'tests/QA/Assert-GateResult.ps1' +if (-not (Test-Path -LiteralPath $gatePath -PathType Leaf)) { + throw "The whole-result gate is missing at '$gatePath'." +} +$gateOutput = & pwsh -NoLogo -NoProfile -File $gatePath ` + -ResultPath $boundNUnitPath ` + -MinimumTests $minimumTests ` + -AllowedSkips $allowedSkips 2>&1 +$gateExitCode = $LASTEXITCODE +if ($gateExitCode -ne 0) { + $flatGateOutput = (($gateOutput | Out-String) -replace '\r?\n\s*\|\s*', ' ' -replace '\s+', ' ').Trim() + throw "The result bound by the tested release proof did not pass the whole-result gate: $flatGateOutput" +} +if ($actualSummary.pesterResult -cne 'Passed') { + throw "The bound Pester result is '$($actualSummary.pesterResult)', not Passed." +} +if ($actualSummary.notRun -gt $allowedNotRun) { + throw "$($actualSummary.notRun) NotRun test block(s) exceed the tested release allowance of $allowedNotRun." +} + +# Recheck the result bytes after parsing and gating so a concurrent replacement cannot be +# accepted as one byte sequence and retained as another. +Assert-BoundFileHash -Path $boundNUnitPath -ExpectedHash $proofNUnitHash -Label 'NUnit result' +Assert-BoundFileHash -Path $boundPesterObjectPath -ExpectedHash $proofPesterObjectHash -Label 'Pester object' + +$finalProofHash = (Get-FileHash -LiteralPath $ProofPath -Algorithm SHA256).Hash.ToLowerInvariant() +if ($finalProofHash -cne $initialProofHash) { + throw 'The tested release proof changed while it was being verified.' +} +$finalPackageHash = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() +if ($finalPackageHash -cne $proofPackageHash) { + throw "The '$($package.Name)' package archive changed while it was being verified." +} + +# Recheck the built payload after the external whole-result gate. The package snapshot is +# the publication input, but this second pass also keeps the proof's claim about the built +# module true at the instant verification completes. +[string[]] $finalBuiltPaths = @( + Get-ChildItem -LiteralPath $builtModuleDirectory -Recurse -File -Force | + ForEach-Object { $_.FullName.Substring($builtModuleDirectory.Length + 1) -replace '\\', '/' } +) +if ($finalBuiltPaths.Count -ne $proofFileMap.Count) { + throw 'The built module file set changed while the tested release proof was being verified.' +} +foreach ($relativePath in $finalBuiltPaths) { + if (-not $proofFileMap.ContainsKey($relativePath) -or + -not [string]::Equals($relativePath, $currentPathMap[$relativePath], [System.StringComparison]::Ordinal)) { + throw 'The built module file set changed while the tested release proof was being verified.' + } + $finalBuiltHash = (Get-FileHash -LiteralPath (Join-Path $builtModuleDirectory $relativePath) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($finalBuiltHash -cne $proofFileMap[$relativePath]) { + throw "'$relativePath' in the built module changed while the tested release proof was being verified." + } +} + +function Copy-VerifiedReleaseFile { + param( + [Parameter(Mandatory)] [string] $SourcePath, + [Parameter(Mandatory)] [string] $DestinationPath, + [Parameter(Mandatory)] [string] $ExpectedHash, + [Parameter(Mandatory)] [string] $Label + ) + + $sourceFullPath = (Resolve-Path -LiteralPath $SourcePath).ProviderPath + $destinationFullPath = [System.IO.Path]::GetFullPath($DestinationPath) + $comparison = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ([string]::Equals($sourceFullPath, $destinationFullPath, $comparison)) { + throw "The verified $Label snapshot destination must differ from its source path." + } + if (Test-Path -LiteralPath $destinationFullPath) { + throw "The verified $Label snapshot destination '$destinationFullPath' already exists." + } + $destinationDirectory = Split-Path $destinationFullPath -Parent + if (-not (Test-Path -LiteralPath $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + try { + [System.IO.File]::Copy($sourceFullPath, $destinationFullPath, $false) + $snapshotHash = (Get-FileHash -LiteralPath $destinationFullPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($snapshotHash -cne $ExpectedHash) { + throw "The verified $Label snapshot does not match the bytes that passed verification." + } + } + catch { + Remove-Item -LiteralPath $destinationFullPath -Force -ErrorAction SilentlyContinue + throw + } + return $destinationFullPath +} + +$verifiedPackagePath = $null +if (-not [string]::IsNullOrWhiteSpace($VerifiedPackageCopyPath)) { + $verifiedPackagePath = Copy-VerifiedReleaseFile ` + -SourcePath $package.FullName ` + -DestinationPath $VerifiedPackageCopyPath ` + -ExpectedHash $proofPackageHash ` + -Label 'package' +} +$verifiedProofPath = $null +if (-not [string]::IsNullOrWhiteSpace($VerifiedProofCopyPath)) { + $verifiedProofPath = Copy-VerifiedReleaseFile ` + -SourcePath $ProofPath ` + -DestinationPath $VerifiedProofCopyPath ` + -ExpectedHash $initialProofHash ` + -Label 'proof' +} + +Write-Host "VERIFIED TESTED RELEASE: $moduleName $moduleVersion; $($proofFileMap.Count) shipped file(s); package sha256 $proofPackageHash; $($actualSummary.total) tests; 0 failed; 0 errors; 0 skipped; 0 NotRun." + +[pscustomobject] [ordered] @{ + ModuleName = $moduleName + Version = $moduleVersion + BaseVersion = $proofModuleBaseVersion + PackageName = $package.Name + PackageSha256 = $proofPackageHash + ProofSha256 = $initialProofHash + RunId = $proofRunId + ShippedFileCount = $proofFileMap.Count + TestCount = $actualSummary.total + ProofPath = (Resolve-Path -LiteralPath $ProofPath).ProviderPath + NUnitResultPath = (Resolve-Path -LiteralPath $boundNUnitPath).ProviderPath + PesterObjectPath = (Resolve-Path -LiteralPath $boundPesterObjectPath).ProviderPath + VerifiedPackagePath = $verifiedPackagePath + VerifiedProofPath = $verifiedProofPath +} diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs new file mode 100644 index 0000000..f028d20 --- /dev/null +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -0,0 +1,1619 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; + +namespace __GRAPHKIT_AUTH_STAGE_CAPTURE_NAMESPACE__; + +public sealed class GraphKitAuthPathEvidence +{ + public string RelativePath { get; init; } = string.Empty; + public string PhysicalPath { get; init; } = string.Empty; + public string NativeIdentity { get; init; } = string.Empty; + public string Sha256 { get; init; } = string.Empty; + public long Length { get; init; } + public long LinkCount { get; init; } + public int UnixMode { get; init; } + public uint OwnerUid { get; init; } + public uint EffectiveUid { get; init; } + public string PermissionEvidence { get; init; } = string.Empty; + public bool IsDirectory { get; init; } + public bool IsRegularFile { get; init; } + public bool IsReparsePoint { get; init; } + public bool OwnerWritable { get; init; } + public string OwnerSid { get; init; } = string.Empty; + public string CurrentIdentitySid { get; init; } = string.Empty; + public string CurrentOwnerSid { get; init; } = string.Empty; + public bool AccessRulesProtected { get; init; } + public bool HasInheritedAccessRules { get; init; } + public bool OwnerOnlyAccess { get; init; } + public bool ExactOwnerOnlyAccess { get; init; } + public bool ExactWritableOwnerOnlyDirectoryAccess { get; init; } + public bool FileReadOnly { get; init; } +} + +public sealed class GraphKitAuthCopyEvidence +{ + public GraphKitAuthPathEvidence Source { get; init; } = new(); + public GraphKitAuthPathEvidence DestinationInitial { get; init; } = new(); + public GraphKitAuthPathEvidence Destination { get; init; } = new(); +} + +public sealed class GraphKitAuthWriteEvidence +{ + public GraphKitAuthPathEvidence DestinationInitial { get; init; } = new(); + public GraphKitAuthPathEvidence Destination { get; init; } = new(); +} + +public static class GraphKitAuthStageCapture +{ + private const uint GenericRead = 0x80000000; + private const uint GenericWrite = 0x40000000; + private const uint DeleteAccess = 0x00010000; + private const uint WriteDacAccess = 0x00040000; + private const uint WriteOwnerAccess = 0x00080000; + private const uint ShareRead = 0x00000001; + private const uint ShareWrite = 0x00000002; + private const uint ShareDelete = 0x00000004; + private const uint CreateNew = 1; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x00000080; + private const uint FileFlagOpenReparsePoint = 0x00200000; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagWriteThrough = 0x80000000; + private const uint FileAttributeReadOnly = 0x00000001; + private const uint FileAttributeReparsePoint = 0x00000400; + private const int SeFileObject = 1; + private const uint OwnerSecurityInformation = 0x00000001; + private const uint DaclSecurityInformation = 0x00000004; + private const int TokenOwner = 4; + private const int ErrorInsufficientBuffer = 122; + private const uint MaxTokenOwnerInformationLength = 65536; + private const int FileDispositionInfoClass = 4; + + public static GraphKitAuthPathEvidence InspectFile(string rootPath, string relativePath) + => Inspect(rootPath, relativePath, expectDirectory: false, hashContent: true); + + public static GraphKitAuthPathEvidence InspectFileMetadata( + string rootPath, + string relativePath, + long maximumLength) + { + if (maximumLength < 0) + { + throw new ArgumentOutOfRangeException(nameof(maximumLength)); + } + GraphKitAuthPathEvidence evidence = Inspect( + rootPath, relativePath, expectDirectory: false, hashContent: false); + if (evidence.Length > maximumLength) + { + throw new IOException($"Source '{relativePath}' exceeds its bounded inspection length."); + } + return evidence; + } + + public static GraphKitAuthPathEvidence InspectDirectory(string rootPath, string relativePath) + => Inspect(rootPath, relativePath, expectDirectory: true, hashContent: false); + + public static GraphKitAuthPathEvidence InspectDirectoryPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("A directory path is required.", nameof(path)); + } + string fullPath = Path.GetFullPath(path); + using SafeFileHandle handle = OpenReadNoFollow(fullPath, directory: true); + return EvidenceFromHandle( + handle, fullPath, string.Empty, expectDirectory: true, hashContent: false); + } + + public static bool HasInitialOwnerOnlyAccess(GraphKitAuthPathEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + return OperatingSystem.IsWindows() + ? evidence.OwnerOnlyAccess && + !string.IsNullOrWhiteSpace(evidence.OwnerSid) && + string.Equals(evidence.OwnerSid, evidence.CurrentOwnerSid, StringComparison.Ordinal) + : evidence.UnixMode == 0x180 && evidence.OwnerUid == evidence.EffectiveUid; + } + + public static bool HasInitialOwnerOnlyDirectoryAccess(GraphKitAuthPathEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + return OperatingSystem.IsWindows() + ? evidence.IsDirectory && + evidence.OwnerWritable && + evidence.AccessRulesProtected && + !evidence.HasInheritedAccessRules && + evidence.OwnerOnlyAccess && + evidence.ExactWritableOwnerOnlyDirectoryAccess && + !string.IsNullOrWhiteSpace(evidence.OwnerSid) && + string.Equals(evidence.OwnerSid, evidence.CurrentOwnerSid, StringComparison.Ordinal) + : evidence.IsDirectory && evidence.UnixMode == 0x1C0 && + evidence.OwnerUid == evidence.EffectiveUid; + } + + public static GraphKitAuthPathEvidence CreateDirectoryOwnerOnly( + string parentPath, + string childName) + { + string parent = Path.GetFullPath(parentPath); + string child = ResolveRelative(parent, childName); + EnsureAncestors(parent, childName); + using SafeFileHandle parentHandle = OpenReadNoFollow(parent, directory: true); + NativeFacts parentBefore = GetNativeFacts(parentHandle, parent); + if (!parentBefore.IsDirectory || parentBefore.IsReparsePoint) + { + throw new IOException("Owner-only directory creation requires one physical parent directory."); + } + + int error; + if (OperatingSystem.IsWindows()) + { + DirectorySecurity security = new(); + SecurityIdentifier currentIdentity = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + SecurityIdentifier owner = GetCurrentTokenOwnerSid(); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + currentIdentity, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + byte[] descriptor = security.GetSecurityDescriptorBinaryForm(); + GCHandle pinnedDescriptor = GCHandle.Alloc(descriptor, GCHandleType.Pinned); + try + { + SecurityAttributes attributes = new() + { + Length = Marshal.SizeOf(), + SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), + InheritHandle = 0 + }; + if (!CreateDirectoryW(ToExtendedWindowsPath(child), ref attributes)) + { + error = Marshal.GetLastWin32Error(); + if (error == 80 || error == 183) + { + throw new IOException( + $"Atomic owner-only directory destination collision: '{childName}' already exists."); + } + throw new IOException( + $"Could not atomically create owner-only directory '{childName}' (Win32 {error})."); + } + } + finally + { + pinnedDescriptor.Free(); + } + } + else + { + int result = mkdirat( + parentHandle.DangerousGetHandle().ToInt32(), + childName, + 0x000001C0); // 0700 + if (result != 0) + { + error = Marshal.GetLastWin32Error(); + if (error == 17) + { + throw new IOException( + $"Atomic owner-only directory destination collision: '{childName}' already exists."); + } + throw new IOException( + $"Could not atomically create owner-only directory '{childName}' (errno {error})."); + } + } + + GraphKitAuthPathEvidence initial = InspectDirectory(parent, childName); + using SafeFileHandle reopenedParent = OpenReadNoFollow(parent, directory: true); + NativeFacts parentAfter = GetNativeFacts(reopenedParent, parent); + if (!parentBefore.SameObject(parentAfter)) + { + throw new IOException("The owner-only directory parent changed during atomic creation."); + } + return initial; + } + + public static byte[] ReadFile(string rootPath, string relativePath) + { + string fullPath = ResolveRelative(rootPath, relativePath); + EnsureAncestors(rootPath, relativePath); + using SafeFileHandle handle = OpenReadNoFollow(fullPath, directory: false); + NativeFacts before = GetNativeFacts(handle, fullPath); + if (!before.IsRegularFile || before.IsReparsePoint || before.Length > int.MaxValue) + { + throw new IOException($"'{relativePath}' is not one readable regular file."); + } + byte[] content = new byte[checked((int)before.Length)]; + long offset = 0; + while (offset < before.Length) + { + int read = RandomAccess.Read(handle, content.AsSpan(checked((int)offset)), offset); + if (read == 0) + { + throw new EndOfStreamException($"'{relativePath}' ended during stable-handle capture."); + } + offset += read; + } + NativeFacts after = GetNativeFacts(handle, fullPath); + if (!before.SameObject(after) || before.Length != after.Length || before.LinkCount != after.LinkCount) + { + throw new IOException($"'{relativePath}' changed during stable-handle capture."); + } + return content; + } + + public static GraphKitAuthCopyEvidence CopyFileCreateNew( + string sourceRoot, + string sourceRelativePath, + string destinationRoot, + string destinationRelativePath, + bool requireInitialOwnerOnly = false, + long maximumLength = long.MaxValue) + => CopyFileCreateNew( + sourceRoot, + sourceRelativePath, + destinationRoot, + destinationRelativePath, + requireInitialOwnerOnly, + maximumLength, + simulatePostCreateFailure: false); + + public static GraphKitAuthCopyEvidence CopyFileCreateNew( + string sourceRoot, + string sourceRelativePath, + string destinationRoot, + string destinationRelativePath, + bool requireInitialOwnerOnly, + long maximumLength, + bool simulatePostCreateFailure) + { + string sourcePath = ResolveRelative(sourceRoot, sourceRelativePath); + string destinationPath = ResolveRelative(destinationRoot, destinationRelativePath); + EnsureAncestors(sourceRoot, sourceRelativePath); + EnsureAncestors(destinationRoot, destinationRelativePath); + + using SafeFileHandle sourceHandle = OpenReadNoFollow(sourcePath, directory: false); + NativeFacts sourceBefore = GetNativeFacts(sourceHandle, sourcePath); + if (!sourceBefore.IsRegularFile || sourceBefore.IsReparsePoint) + { + throw new IOException($"Source '{sourceRelativePath}' is not one regular no-follow file."); + } + if (maximumLength < 0 || sourceBefore.Length > maximumLength) + { + throw new IOException($"Source '{sourceRelativePath}' exceeds its bounded capture length."); + } + + using FileStream destinationStream = OpenDestinationCreateNew( + destinationPath, requireInitialOwnerOnly); + SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; + try + { + if (simulatePostCreateFailure) + { + RandomAccess.Write(destinationHandle, new byte[] { 0xA5 }, 0); + RandomAccess.FlushToDisk(destinationHandle); + throw new IOException("Injected post-create copy failure after a partial write."); + } + GraphKitAuthPathEvidence destinationInitial = EvidenceFromHandle( + destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); + if (requireInitialOwnerOnly && !HasInitialOwnerOnlyAccess(destinationInitial)) + { + throw new IOException($"New destination '{destinationRelativePath}' did not begin with owner-only access."); + } + SetOwnerOnlyWritableFile(destinationStream, destinationPath); + byte[] buffer = new byte[131072]; + long offset = 0; + while (offset < sourceBefore.Length) + { + int requested = (int)Math.Min(buffer.Length, sourceBefore.Length - offset); + int read = RandomAccess.Read(sourceHandle, buffer.AsSpan(0, requested), offset); + if (read == 0) + { + throw new EndOfStreamException($"Source '{sourceRelativePath}' ended during capture."); + } + if (offset > maximumLength - read) + { + throw new IOException($"Source '{sourceRelativePath}' exceeded its bounded capture length."); + } + RandomAccess.Write(destinationHandle, buffer.AsSpan(0, read), offset); + offset += read; + } + RandomAccess.FlushToDisk(destinationHandle); + + NativeFacts sourceAfter = GetNativeFacts(sourceHandle, sourcePath); + if (!sourceBefore.SameObject(sourceAfter) || sourceBefore.Length != sourceAfter.Length) + { + throw new IOException($"Source '{sourceRelativePath}' changed while it was being captured."); + } + + GraphKitAuthPathEvidence sourceEvidence = EvidenceFromHandle( + sourceHandle, sourcePath, sourceRelativePath, expectDirectory: false); + GraphKitAuthPathEvidence destinationEvidence = EvidenceFromHandle( + destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); + if (!string.Equals(sourceEvidence.Sha256, destinationEvidence.Sha256, StringComparison.Ordinal) || + sourceEvidence.Length != destinationEvidence.Length) + { + throw new IOException($"Captured destination '{destinationRelativePath}' does not match its source."); + } + + return new GraphKitAuthCopyEvidence + { + Source = sourceEvidence, + DestinationInitial = destinationInitial, + Destination = destinationEvidence + }; + } + catch (Exception primaryFailure) + { + HandleCreateNewFailure( + destinationHandle, + destinationRelativePath, + operation: "Copying", + primaryFailure: primaryFailure); + throw; + } + } + + private static void HandleCreateNewFailure( + SafeFileHandle destinationHandle, + string destinationRelativePath, + string operation, + Exception primaryFailure) + { + try + { + if (OperatingSystem.IsWindows()) + { + MarkExactWindowsHandleForDeletion(destinationHandle, destinationRelativePath); + return; + } + + RandomAccess.SetLength(destinationHandle, 0); + RandomAccess.FlushToDisk(destinationHandle); + } + catch (Exception cleanupFailure) + { + throw new IOException( + $"{operation} '{destinationRelativePath}' failed and exact live-handle cleanup also failed; " + + $"no path deletion was attempted. Cleanup failure: {cleanupFailure.Message} " + + $"Original failure: {primaryFailure.Message}", + new AggregateException(primaryFailure, cleanupFailure)); + } + + if (!OperatingSystem.IsWindows()) + { + throw new IOException( + $"{operation} '{destinationRelativePath}' failed. Unix has no portable exact-handle " + + "path-deletion primitive, so GraphKit.Auth truncated and flushed only its exact " + + "create-new object and did not delete any path. Inspect and explicitly recover the " + + $"zero-byte collision. Original failure: {primaryFailure.Message}", + primaryFailure); + } + } + + private static void MarkExactWindowsHandleForDeletion( + SafeFileHandle destinationHandle, + string destinationRelativePath) + { + if (!GetFileInformationByHandle(destinationHandle, out ByHandleFileInformation info)) + { + throw new IOException( + $"Could not inspect the exact create-new destination '{destinationRelativePath}' " + + $"before handle-bound deletion (Win32 {Marshal.GetLastWin32Error()})."); + } + bool directory = (info.FileAttributes & 0x10) != 0; + bool reparse = (info.FileAttributes & FileAttributeReparsePoint) != 0; + if (directory || reparse || info.NumberOfLinks != 1) + { + throw new IOException( + $"The exact create-new destination '{destinationRelativePath}' changed type or link count; " + + "handle-bound deletion was refused."); + } + + FileDispositionInfo disposition = new() { DeleteFile = 1 }; + if (!SetFileInformationByHandle( + destinationHandle, + FileDispositionInfoClass, + ref disposition, + (uint)Marshal.SizeOf())) + { + throw new IOException( + $"Could not mark the exact create-new destination '{destinationRelativePath}' for " + + $"handle-bound deletion (Win32 {Marshal.GetLastWin32Error()})."); + } + } + + public static GraphKitAuthWriteEvidence WriteFileCreateNew( + string destinationRoot, + string destinationRelativePath, + byte[] content, + bool requireInitialOwnerOnly = false) + => WriteFileCreateNew( + destinationRoot, + destinationRelativePath, + content, + requireInitialOwnerOnly, + simulatePostCreateFailure: false); + + public static GraphKitAuthWriteEvidence WriteFileCreateNew( + string destinationRoot, + string destinationRelativePath, + byte[] content, + bool requireInitialOwnerOnly, + bool simulatePostCreateFailure) + { + ArgumentNullException.ThrowIfNull(content); + string destinationPath = ResolveRelative(destinationRoot, destinationRelativePath); + EnsureAncestors(destinationRoot, destinationRelativePath); + + using FileStream destinationStream = OpenDestinationCreateNew( + destinationPath, requireInitialOwnerOnly); + SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; + try + { + if (simulatePostCreateFailure) + { + RandomAccess.Write(destinationHandle, new byte[] { 0xA5 }, 0); + RandomAccess.FlushToDisk(destinationHandle); + throw new IOException("Injected post-create write failure after a partial write."); + } + GraphKitAuthPathEvidence destinationInitial = EvidenceFromHandle( + destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); + if (requireInitialOwnerOnly && !HasInitialOwnerOnlyAccess(destinationInitial)) + { + throw new IOException($"New destination '{destinationRelativePath}' did not begin with owner-only access."); + } + SetOwnerOnlyWritableFile(destinationStream, destinationPath); + RandomAccess.Write(destinationHandle, content, 0); + RandomAccess.FlushToDisk(destinationHandle); + GraphKitAuthPathEvidence destination = EvidenceFromHandle( + destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); + string expectedHash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(); + if (destination.Length != content.LongLength || + !string.Equals(destination.Sha256, expectedHash, StringComparison.Ordinal)) + { + throw new IOException($"Written destination '{destinationRelativePath}' does not match its supplied bytes."); + } + return new GraphKitAuthWriteEvidence + { + DestinationInitial = destinationInitial, + Destination = destination + }; + } + catch (Exception primaryFailure) + { + HandleCreateNewFailure( + destinationHandle, + destinationRelativePath, + operation: "Writing", + primaryFailure: primaryFailure); + throw; + } + } + + public static void SetOwnerOnly(string absolutePath, bool directory, bool writable) + { + string path = Path.GetFullPath(absolutePath); + if (OperatingSystem.IsWindows()) + { + SetOwnerOnlyWindows(path, directory, writable); + return; + } + + UnixFileMode mode = directory + ? (writable + ? UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + : UnixFileMode.UserRead | UnixFileMode.UserExecute) + : (writable + ? UnixFileMode.UserRead | UnixFileMode.UserWrite + : UnixFileMode.UserRead); + File.SetUnixFileMode(path, mode); + } + + private static void SetOwnerOnlyWritableFile( + FileStream stream, + string absolutePath) + { + if (OperatingSystem.IsWindows()) + { + FileSecurity security = (FileSecurity)CreateOwnerOnlyWindowsSecurity( + directory: false, writable: true, setOwner: true); + FileSystemAclExtensions.SetAccessControl(stream, security); + return; + } + + SafeFileHandle handle = stream.SafeFileHandle; + const uint mode = 0x180u; // 0600 + if (fchmod(handle.DangerousGetHandle().ToInt32(), mode) != 0) + { + throw new IOException( + $"Could not set exact-handle owner-only access on '{absolutePath}' " + + $"(errno {Marshal.GetLastWin32Error()})."); + } + } + + public static void MoveDirectoryCreateNew(string sourcePath, string destinationPath) + => MoveDirectoryCreateNew(sourcePath, destinationPath, simulateLinuxRenameUnavailable: false); + + public static void MoveDirectoryCreateNew( + string sourcePath, + string destinationPath, + bool simulateLinuxRenameUnavailable) + { + string source = Path.GetFullPath(sourcePath); + string destination = Path.GetFullPath(destinationPath); + string sourceParent = Path.GetDirectoryName(source); + string destinationParent = Path.GetDirectoryName(destination); + if (string.IsNullOrWhiteSpace(sourceParent) || string.IsNullOrWhiteSpace(destinationParent)) + { + throw new IOException("The atomic directory move requires physical parent directories."); + } + if (!string.Equals(Path.GetPathRoot(source), Path.GetPathRoot(destination), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + throw new IOException("The atomic directory move must remain on one filesystem root."); + } + + using (SafeFileHandle sourceHandle = OpenReadNoFollow(source, directory: true)) + using (SafeFileHandle sourceParentHandle = OpenReadNoFollow(sourceParent, directory: true)) + using (SafeFileHandle destinationParentHandle = OpenReadNoFollow(destinationParent, directory: true)) + { + NativeFacts sourceFacts = GetNativeFacts(sourceHandle, source); + NativeFacts sourceParentFacts = GetNativeFacts(sourceParentHandle, sourceParent); + NativeFacts destinationParentFacts = GetNativeFacts(destinationParentHandle, destinationParent); + if (!sourceFacts.IsDirectory || sourceFacts.IsReparsePoint || + !sourceParentFacts.IsDirectory || sourceParentFacts.IsReparsePoint || + !destinationParentFacts.IsDirectory || destinationParentFacts.IsReparsePoint) + { + throw new IOException("The atomic directory move requires physical no-follow directories."); + } + } + + int error; + if (OperatingSystem.IsWindows()) + { + if (MoveFileExW( + ToExtendedWindowsPath(source), + ToExtendedWindowsPath(destination), + 0)) + { + return; + } + error = Marshal.GetLastWin32Error(); + if (error == 80 || error == 183) + { + throw new IOException( + $"GraphKit.Auth atomic destination collision: '{destination}' already exists; " + + "source and destination were not changed."); + } + throw new IOException($"Could not atomically install '{destination}' without replacement (Win32 {error})."); + } + + if (OperatingSystem.IsMacOS()) + { + int macResult = renamex_np(source, destination, 0x00000004); + if (macResult != 0) + { + error = Marshal.GetLastWin32Error(); + if (error == 17) + { + throw new IOException( + $"GraphKit.Auth atomic destination collision: '{destination}' already exists; " + + "source and destination were not changed."); + } + throw new IOException( + $"Could not atomically install '{destination}' with macOS renamex_np RENAME_EXCL " + + $"(errno {error}); no fallback was attempted."); + } + return; + } + + int result; + try + { + if (simulateLinuxRenameUnavailable) + { + throw new EntryPointNotFoundException("Injected renameat2 unavailability."); + } + result = renameat2(-100, source, -100, destination, 0x00000001); + } + catch (EntryPointNotFoundException exception) + { + throw new IOException( + "Linux renameat2 RENAME_NOREPLACE is unavailable; no fallback was attempted and the destination was not mutated.", + exception); + } + catch (DllNotFoundException exception) + { + throw new IOException( + "Linux renameat2 RENAME_NOREPLACE is unavailable because libc could not be loaded; " + + "no fallback was attempted and the destination was not mutated.", + exception); + } + if (result != 0) + { + error = Marshal.GetLastWin32Error(); + if (error == 17) + { + throw new IOException( + $"GraphKit.Auth atomic destination collision: '{destination}' already exists; " + + "source and destination were not changed."); + } + if (error == 38 || error == 22) // ENOSYS or EINVAL: unavailable runtime/filesystem primitive. + { + throw new IOException( + $"Linux renameat2 RENAME_NOREPLACE is unavailable or unsupported (errno {error}); " + + "no fallback was attempted and the destination was not mutated."); + } + throw new IOException( + $"Could not atomically install '{destination}' with Linux renameat2 RENAME_NOREPLACE " + + $"(errno {error}); no fallback was attempted."); + } + } + + private static GraphKitAuthPathEvidence Inspect( + string rootPath, + string relativePath, + bool expectDirectory, + bool hashContent) + { + string fullPath = ResolveRelative(rootPath, relativePath); + EnsureAncestors(rootPath, relativePath); + using SafeFileHandle handle = OpenReadNoFollow(fullPath, expectDirectory); + return EvidenceFromHandle( + handle, fullPath, relativePath, expectDirectory, hashContent); + } + + private static GraphKitAuthPathEvidence EvidenceFromHandle( + SafeFileHandle handle, + string fullPath, + string relativePath, + bool expectDirectory, + bool hashContent = true) + { + NativeFacts before = GetNativeFacts(handle, fullPath); + if (before.IsDirectory != expectDirectory || + (!expectDirectory && !before.IsRegularFile) || + before.IsReparsePoint) + { + throw new IOException($"'{relativePath}' is not the required no-follow {(expectDirectory ? "directory" : "regular file")}."); + } + + string hash = string.Empty; + if (!expectDirectory && hashContent) + { + hash = HashHandle(handle, before.Length); + } + + NativeFacts after = GetNativeFacts(handle, fullPath); + if (!before.SameObject(after) || + (!expectDirectory && (before.Length != after.Length || before.LinkCount != after.LinkCount))) + { + throw new IOException($"'{relativePath}' changed while its stable handle was inspected."); + } + + return new GraphKitAuthPathEvidence + { + RelativePath = relativePath.Replace('\\', '/'), + PhysicalPath = after.PhysicalPath, + NativeIdentity = after.Identity, + Sha256 = hash, + Length = after.Length, + LinkCount = after.LinkCount, + UnixMode = after.UnixMode, + OwnerUid = after.OwnerUid, + EffectiveUid = after.EffectiveUid, + PermissionEvidence = after.PermissionEvidence, + IsDirectory = after.IsDirectory, + IsRegularFile = after.IsRegularFile, + IsReparsePoint = after.IsReparsePoint, + OwnerWritable = after.OwnerWritable, + OwnerSid = after.OwnerSid, + CurrentIdentitySid = after.CurrentIdentitySid, + CurrentOwnerSid = after.CurrentOwnerSid, + AccessRulesProtected = after.AccessRulesProtected, + HasInheritedAccessRules = after.HasInheritedAccessRules, + OwnerOnlyAccess = after.OwnerOnlyAccess, + ExactOwnerOnlyAccess = after.ExactOwnerOnlyAccess, + ExactWritableOwnerOnlyDirectoryAccess = after.ExactWritableOwnerOnlyDirectoryAccess, + FileReadOnly = after.FileReadOnly + }; + } + + private static string HashHandle(SafeFileHandle handle, long length) + { + using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + byte[] buffer = new byte[131072]; + long offset = 0; + while (offset < length) + { + int requested = (int)Math.Min(buffer.Length, length - offset); + int read = RandomAccess.Read(handle, buffer.AsSpan(0, requested), offset); + if (read == 0) + { + throw new EndOfStreamException("A file ended while its stable handle was being hashed."); + } + hash.AppendData(buffer, 0, read); + offset += read; + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static string ResolveRelative(string rootPath, string relativePath) + { + if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(relativePath)) + { + throw new ArgumentException("Root and relative paths are required."); + } + if (Path.IsPathRooted(relativePath) || relativePath.Contains('\\')) + { + throw new IOException($"Relative path '{relativePath}' is unsafe."); + } + string[] segments = relativePath.Split('/'); + foreach (string segment in segments) + { + if (string.IsNullOrWhiteSpace(segment) || segment is "." or ".." || + !segment.IsNormalized(NormalizationForm.FormC)) + { + throw new IOException($"Relative path '{relativePath}' is unsafe or not NFC-normalized."); + } + } + + string root = Path.GetFullPath(rootPath); + string combined = Path.GetFullPath(Path.Combine(root, Path.Combine(segments))); + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string prefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (!combined.StartsWith(prefix, comparison)) + { + throw new IOException($"Relative path '{relativePath}' escapes its root."); + } + return combined; + } + + private static void EnsureAncestors(string rootPath, string relativePath) + { + string root = Path.GetFullPath(rootPath); + using (SafeFileHandle rootHandle = OpenReadNoFollow(root, directory: true)) + { + NativeFacts rootFacts = GetNativeFacts(rootHandle, root); + if (!rootFacts.IsDirectory || rootFacts.IsReparsePoint) + { + throw new IOException($"Root '{root}' is not a physical no-follow directory."); + } + } + + string[] segments = relativePath.Split('/'); + string current = root; + for (int index = 0; index < segments.Length - 1; index++) + { + current = Path.Combine(current, segments[index]); + using SafeFileHandle handle = OpenReadNoFollow(current, directory: true); + NativeFacts facts = GetNativeFacts(handle, current); + if (!facts.IsDirectory || facts.IsReparsePoint) + { + throw new IOException($"Ancestor '{segments[index]}' is not one physical directory."); + } + } + } + + private static SafeFileHandle OpenReadNoFollow(string fullPath, bool directory) + { + if (OperatingSystem.IsWindows()) + { + SafeFileHandle handle = CreateFileW( + ToExtendedWindowsPath(fullPath), + GenericRead, + ShareRead | ShareWrite | ShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagOpenReparsePoint | (directory ? FileFlagBackupSemantics : 0), + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new IOException($"Could not open '{fullPath}' without following a reparse point (Win32 {error})."); + } + return handle; + } + + int noFollow = OperatingSystem.IsMacOS() ? 0x00000100 : 0x00020000; + int directoryFlag = OperatingSystem.IsMacOS() ? 0x00100000 : 0x00010000; + int closeOnExec = OperatingSystem.IsMacOS() ? 0x01000000 : 0x00080000; + int fd = open(fullPath, noFollow | closeOnExec | (directory ? directoryFlag : 0)); + if (fd < 0) + { + throw new IOException($"Could not open '{fullPath}' without following a link (errno {Marshal.GetLastWin32Error()})."); + } + return new SafeFileHandle((IntPtr)fd, ownsHandle: true); + } + + private static FileStream OpenDestinationCreateNew( + string destinationPath, + bool requireInitialOwnerOnly) + { + if (OperatingSystem.IsWindows()) + { + SafeFileHandle handle; + if (requireInitialOwnerOnly) + { + FileSecurity security = new(); + SecurityIdentifier currentIdentity = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + SecurityIdentifier owner = GetCurrentTokenOwnerSid(); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + currentIdentity, + FileSystemRights.FullControl, + InheritanceFlags.None, + PropagationFlags.None, + AccessControlType.Allow)); + byte[] descriptor = security.GetSecurityDescriptorBinaryForm(); + GCHandle pinnedDescriptor = GCHandle.Alloc(descriptor, GCHandleType.Pinned); + try + { + SecurityAttributes attributes = new() + { + Length = Marshal.SizeOf(), + SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), + InheritHandle = 0 + }; + handle = CreateFileWithSecurityW( + ToExtendedWindowsPath(destinationPath), + GenericRead | GenericWrite | DeleteAccess | WriteDacAccess | WriteOwnerAccess, + ShareRead, + ref attributes, + CreateNew, + FileAttributeNormal | FileFlagWriteThrough, + IntPtr.Zero); + } + finally + { + pinnedDescriptor.Free(); + } + } + else + { + handle = CreateFileW( + ToExtendedWindowsPath(destinationPath), + GenericRead | GenericWrite | DeleteAccess | WriteDacAccess | WriteOwnerAccess, + ShareRead, + IntPtr.Zero, + CreateNew, + FileAttributeNormal | FileFlagWriteThrough, + IntPtr.Zero); + } + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + if (error == 80 || error == 183) + { + throw new IOException( + requireInitialOwnerOnly + ? "Atomic owner-only file destination collision." + : "Atomic file destination collision."); + } + throw new IOException( + requireInitialOwnerOnly + ? $"Could not atomically create owner-only destination file (Win32 {error})." + : $"Could not atomically create destination file (Win32 {error})."); + } + try + { + return new FileStream(handle, FileAccess.ReadWrite, bufferSize: 4096, isAsync: false); + } + catch (Exception primaryFailure) + { + try + { + MarkExactWindowsHandleForDeletion( + handle, + Path.GetFileName(destinationPath)); + } + catch (Exception cleanupFailure) + { + throw new IOException( + "Wrapping the exact Windows create-new handle failed and handle-bound " + + $"cleanup also failed; no path deletion was attempted. Cleanup failure: " + + $"{cleanupFailure.Message} Original failure: {primaryFailure.Message}", + new AggregateException(primaryFailure, cleanupFailure)); + } + finally + { + handle.Dispose(); + } + throw; + } + } + + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.ReadWrite, + Share = FileShare.Read, + Options = FileOptions.WriteThrough + }; + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + return new FileStream(destinationPath, options); + } + + private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) + { + if (OperatingSystem.IsWindows()) + { + if (!GetFileInformationByHandle(handle, out ByHandleFileInformation info)) + { + throw new IOException($"Could not inspect '{path}' (Win32 {Marshal.GetLastWin32Error()})."); + } + uint type = info.FileAttributes; + bool directory = (type & 0x10) != 0; + bool reparse = (type & FileAttributeReparsePoint) != 0; + long windowsLength = ((long)info.FileSizeHigh << 32) | info.FileSizeLow; + string identity = $"{info.VolumeSerialNumber:x8}:{info.FileIndexHigh:x8}{info.FileIndexLow:x8}"; + string physical = GetWindowsPhysicalPath(handle); + WindowsPermissionFacts permissions = GetWindowsPermissionFacts( + handle, directory, info.FileAttributes); + return new NativeFacts(identity, physical, windowsLength, info.NumberOfLinks, 0, 0, 0, directory, + !directory && !reparse, reparse, permissions.OwnerWritable, permissions.Sddl, + permissions.OwnerSid, permissions.CurrentIdentitySid, permissions.CurrentOwnerSid, + permissions.AccessRulesProtected, + permissions.HasInheritedAccessRules, permissions.OwnerOnlyAccess, + permissions.ExactOwnerOnlyAccess, + permissions.ExactWritableOwnerOnlyDirectoryAccess, + permissions.FileReadOnly); + } + + byte[] stat = new byte[256]; + if (InvokeUnixFStat(handle.DangerousGetHandle().ToInt32(), stat, path) != 0) + { + throw new IOException($"Could not fstat '{path}' (errno {Marshal.GetLastWin32Error()})."); + } + + ulong device; + ulong inode; + ulong links; + uint mode; + uint ownerUid; + long length; + if (OperatingSystem.IsMacOS()) + { + device = BitConverter.ToUInt32(stat, 0); + mode = BitConverter.ToUInt16(stat, 4); + links = BitConverter.ToUInt16(stat, 6); + inode = BitConverter.ToUInt64(stat, 8); + ownerUid = BitConverter.ToUInt32(stat, 16); + length = BitConverter.ToInt64(stat, 96); + } + else if (OperatingSystem.IsLinux() && + RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + // glibc's generic 64-bit Linux stat ABI (used by AArch64) places + // mode/nlink immediately after the 64-bit device and inode fields. + device = BitConverter.ToUInt64(stat, 0); + inode = BitConverter.ToUInt64(stat, 8); + mode = BitConverter.ToUInt32(stat, 16); + links = BitConverter.ToUInt32(stat, 20); + ownerUid = BitConverter.ToUInt32(stat, 24); + length = BitConverter.ToInt64(stat, 48); + } + else if (OperatingSystem.IsLinux() && + RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + device = BitConverter.ToUInt64(stat, 0); + inode = BitConverter.ToUInt64(stat, 8); + links = BitConverter.ToUInt64(stat, 16); + mode = BitConverter.ToUInt32(stat, 24); + ownerUid = BitConverter.ToUInt32(stat, 28); + length = BitConverter.ToInt64(stat, 48); + } + else + { + throw new PlatformNotSupportedException( + $"GraphKit.Auth stage capture does not define a native stat layout for '{RuntimeInformation.OSDescription}' on '{RuntimeInformation.ProcessArchitecture}'."); + } + uint fileType = mode & 0xF000; + bool isDirectory = fileType == 0x4000; + bool isRegular = fileType == 0x8000; + bool isLink = fileType == 0xA000; + int unixMode = (int)(mode & 0x0FFF); + uint effectiveUid = geteuid(); + string unixIdentity = $"{device:x}:{inode:x}"; + string physicalPath = GetUnixPhysicalPath(path, unixIdentity, isDirectory); + return new NativeFacts( + unixIdentity, + physicalPath, + length, + checked((long)links), + unixMode, + ownerUid, + effectiveUid, + isDirectory, + isRegular, + isLink, + (unixMode & 0x80) != 0, + Convert.ToString(unixMode, 8).PadLeft(4, '0'), + string.Empty, string.Empty, string.Empty, false, false, false, false, false, false); + } + + private static string GetUnixPhysicalPath(string path, string expectedIdentity, bool directory) + { + IntPtr resolvedPointer = realpath(path, IntPtr.Zero); + if (resolvedPointer == IntPtr.Zero) + { + throw new IOException($"Could not resolve physical path '{path}' (errno {Marshal.GetLastWin32Error()})."); + } + string resolved; + try + { + resolved = Marshal.PtrToStringUTF8(resolvedPointer) + ?? throw new IOException($"Could not decode physical path '{path}'."); + } + finally + { + free(resolvedPointer); + } + + using SafeFileHandle rebound = OpenReadNoFollow(path, directory); + byte[] stat = new byte[256]; + if (InvokeUnixFStat(rebound.DangerousGetHandle().ToInt32(), stat, path) != 0) + { + throw new IOException($"Could not rebind physical path '{path}' (errno {Marshal.GetLastWin32Error()})."); + } + ulong device = OperatingSystem.IsMacOS() ? BitConverter.ToUInt32(stat, 0) : BitConverter.ToUInt64(stat, 0); + ulong inode = BitConverter.ToUInt64(stat, 8); + string reboundIdentity = $"{device:x}:{inode:x}"; + if (!string.Equals(expectedIdentity, reboundIdentity, StringComparison.Ordinal)) + { + throw new IOException($"Path '{path}' changed while its physical identity was resolved."); + } + return resolved; + } + + private static int InvokeUnixFStat(int descriptor, byte[] stat, string path) + { + try + { + if (OperatingSystem.IsMacOS()) + { + return RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => fstat(descriptor, stat), + Architecture.X64 => fstat_inode64(descriptor, stat), + _ => throw new PlatformNotSupportedException( + $"GraphKit.Auth stage capture does not define a macOS fstat ABI for '{RuntimeInformation.ProcessArchitecture}'.") + }; + } + if (!OperatingSystem.IsLinux()) + { + throw new PlatformNotSupportedException( + $"GraphKit.Auth stage capture cannot inspect Unix metadata on '{RuntimeInformation.OSDescription}'."); + } + try + { + return fstat(descriptor, stat); + } + catch (EntryPointNotFoundException modernException) + { + int compatibilityVersion = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => 1, + Architecture.Arm64 => 0, + _ => throw new PlatformNotSupportedException( + $"GraphKit.Auth stage capture does not define a glibc fstat compatibility ABI for '{RuntimeInformation.ProcessArchitecture}'.", + modernException) + }; + try + { + return fxstat(compatibilityVersion, descriptor, stat); + } + catch (EntryPointNotFoundException compatibilityException) + { + throw new PlatformNotSupportedException( + "GraphKit.Auth stage capture requires either the libc fstat or __fxstat entry point.", + new AggregateException(modernException, compatibilityException)); + } + } + } + catch (EntryPointNotFoundException exception) + { + throw new PlatformNotSupportedException( + $"GraphKit.Auth stage capture requires the libc fstat entry point to inspect '{path}'.", + exception); + } + } + + private static string GetWindowsPhysicalPath(SafeFileHandle handle) + { + var builder = new StringBuilder(32768); + uint length = GetFinalPathNameByHandleW(handle, builder, (uint)builder.Capacity, 0); + if (length == 0 || length >= builder.Capacity) + { + throw new IOException($"Could not resolve the opened Windows path (Win32 {Marshal.GetLastWin32Error()})."); + } + return NormalizeWindowsPhysicalPath(builder.ToString()); + } + + private static string NormalizeWindowsPhysicalPath(string value) + { + const string extendedUncPrefix = @"\\?\UNC\"; + if (value.StartsWith(extendedUncPrefix, StringComparison.Ordinal)) + { + return @"\\" + value.Substring(extendedUncPrefix.Length); + } + return value.StartsWith(@"\\?\", StringComparison.Ordinal) ? value.Substring(4) : value; + } + + private static string ToExtendedWindowsPath(string value) + { + if (value.StartsWith(@"\\.\", StringComparison.Ordinal)) + { + throw new IOException("A Windows device path is not permitted for native access."); + } + if (value.StartsWith(@"\\?\UNC\", StringComparison.Ordinal)) + { + return value.Length > 8 + ? value + : throw new IOException("A fully qualified Windows path is required for native access."); + } + if (value.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + string extendedValue = value.Substring(4); + return IsWindowsDriveRooted(extendedValue) + ? value + : throw new IOException("A Windows device path is not permitted for native access."); + } + if (value.StartsWith(@"\\", StringComparison.Ordinal)) + { + return @"\\?\UNC\" + value.Substring(2); + } + if (IsWindowsDriveRooted(value)) + { + return @"\\?\" + value; + } + throw new IOException("A fully qualified Windows path is required for native access."); + } + + private static bool IsWindowsDriveRooted(string value) => + value.Length >= 3 && char.IsAsciiLetter(value[0]) && + value[1] == ':' && value[2] == '\\'; + + private static SecurityIdentifier GetCurrentTokenOwnerSid() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + bool initialResult = GetTokenInformation( + identity.Token, + TokenOwner, + IntPtr.Zero, + 0, + out uint requiredLength); + int initialError = Marshal.GetLastWin32Error(); + if (initialResult || initialError != ErrorInsufficientBuffer || requiredLength == 0 || + requiredLength > MaxTokenOwnerInformationLength) + { + throw new IOException( + $"Could not determine the current Windows token owner size (Win32 {initialError})."); + } + + IntPtr buffer = Marshal.AllocHGlobal(checked((int)requiredLength)); + try + { + if (!GetTokenInformation( + identity.Token, + TokenOwner, + buffer, + requiredLength, + out uint returnedLength)) + { + throw new IOException( + $"Could not read the current Windows token owner (Win32 {Marshal.GetLastWin32Error()})."); + } + if (returnedLength > requiredLength) + { + throw new IOException("The current Windows token owner exceeded its bounded buffer."); + } + TokenOwnerInformation owner = Marshal.PtrToStructure(buffer); + if (owner.Owner == IntPtr.Zero) + { + throw new IOException("The current Windows token has no default owner SID."); + } + return new SecurityIdentifier(owner.Owner); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static WindowsPermissionFacts GetWindowsPermissionFacts( + SafeFileHandle handle, + bool directory, + uint fileAttributes) + { + uint status = GetSecurityInfo( + handle, + SeFileObject, + OwnerSecurityInformation | DaclSecurityInformation, + IntPtr.Zero, + IntPtr.Zero, + IntPtr.Zero, + IntPtr.Zero, + out IntPtr descriptorPointer); + using SafeLocalMemoryHandle descriptor = new(descriptorPointer); + if (status != 0) + { + throw new IOException( + $"Could not inspect the opened Windows object's owner and DACL (Win32 {status})."); + } + uint descriptorLength = GetSecurityDescriptorLength(descriptor.DangerousGetHandle()); + if (descriptorLength == 0) + { + throw new IOException("The opened Windows object returned an invalid security descriptor."); + } + byte[] descriptorBytes = new byte[checked((int)descriptorLength)]; + Marshal.Copy(descriptor.DangerousGetHandle(), descriptorBytes, 0, descriptorBytes.Length); + FileSystemSecurity security = directory ? new DirectorySecurity() : new FileSecurity(); + security.SetSecurityDescriptorBinaryForm( + descriptorBytes, + AccessControlSections.Access | AccessControlSections.Owner); + SecurityIdentifier currentIdentity = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + SecurityIdentifier currentTokenOwner = GetCurrentTokenOwnerSid(); + SecurityIdentifier owner = (SecurityIdentifier)security.GetOwner(typeof(SecurityIdentifier)); + AuthorizationRuleCollection rules = security.GetAccessRules(true, true, typeof(SecurityIdentifier)); + FileSystemRights writeMask = FileSystemRights.WriteData | FileSystemRights.AppendData | + FileSystemRights.WriteExtendedAttributes | FileSystemRights.WriteAttributes | + FileSystemRights.DeleteSubdirectoriesAndFiles | FileSystemRights.Delete | + FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership; + FileSystemRights expectedRights = + FileSystemRights.ReadAndExecute | FileSystemRights.Synchronize; + bool ownerWritable = false; + bool hasInheritedAccessRules = false; + bool ownerOnlyAccess = owner.Equals(currentTokenOwner) && rules.Count >= 1; + bool exactOwnerOnlyAccess = security.AreAccessRulesProtected && + owner.Equals(currentTokenOwner) && rules.Count == 1; + bool exactWritableOwnerOnlyDirectoryAccess = directory && + security.AreAccessRulesProtected && owner.Equals(currentTokenOwner) && rules.Count == 1; + foreach (FileSystemAccessRule rule in rules) + { + hasInheritedAccessRules |= rule.IsInherited; + ownerOnlyAccess &= rule.IdentityReference.Equals(currentIdentity) && + rule.AccessControlType == AccessControlType.Allow; + if (rule.AccessControlType == AccessControlType.Allow && (rule.FileSystemRights & writeMask) != 0) + { + ownerWritable = true; + } + exactOwnerOnlyAccess &= rule.IdentityReference.Equals(currentIdentity) && + !rule.IsInherited && + rule.AccessControlType == AccessControlType.Allow && + rule.FileSystemRights == expectedRights && + rule.InheritanceFlags == InheritanceFlags.None && + rule.PropagationFlags == PropagationFlags.None; + exactWritableOwnerOnlyDirectoryAccess &= rule.IdentityReference.Equals(currentIdentity) && + !rule.IsInherited && + rule.AccessControlType == AccessControlType.Allow && + rule.FileSystemRights == FileSystemRights.FullControl && + rule.InheritanceFlags == (InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit) && + rule.PropagationFlags == PropagationFlags.None; + } + bool fileReadOnly = directory || + (fileAttributes & FileAttributeReadOnly) == FileAttributeReadOnly; + return new WindowsPermissionFacts( + security.GetSecurityDescriptorSddlForm(AccessControlSections.Access | AccessControlSections.Owner), + ownerWritable, + owner.Value, + currentIdentity.Value, + currentTokenOwner.Value, + security.AreAccessRulesProtected, + hasInheritedAccessRules, + ownerOnlyAccess, + exactOwnerOnlyAccess, + exactWritableOwnerOnlyDirectoryAccess, + fileReadOnly); + } + + private static void SetOwnerOnlyWindows(string path, bool directory, bool writable) + { + FileSystemSecurity currentSecurity = directory + ? FileSystemAclExtensions.GetAccessControl( + new DirectoryInfo(path), AccessControlSections.Owner) + : FileSystemAclExtensions.GetAccessControl( + new FileInfo(path), AccessControlSections.Owner); + SecurityIdentifier currentOwner = (SecurityIdentifier)currentSecurity.GetOwner( + typeof(SecurityIdentifier)); + SecurityIdentifier currentTokenOwner = GetCurrentTokenOwnerSid(); + if (!currentOwner.Equals(currentTokenOwner)) + { + throw new IOException( + $"Owner-only access refused for '{path}' because its owner is not the current Windows token owner."); + } + FileSystemSecurity security = CreateOwnerOnlyWindowsSecurity( + directory, writable, setOwner: false); + FileAttributes attributes = directory ? default : File.GetAttributes(path); + if (!directory && !writable && + (attributes & FileAttributes.ReadOnly) == 0) + { + File.SetAttributes( + path, + (attributes & ~FileAttributes.Normal) | FileAttributes.ReadOnly); + } + if (directory) + FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), (DirectorySecurity)security); + else + FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); + if (!directory && writable && + (attributes & FileAttributes.ReadOnly) != 0) + { + FileAttributes writableAttributes = attributes & ~FileAttributes.ReadOnly; + File.SetAttributes( + path, + writableAttributes == 0 ? FileAttributes.Normal : writableAttributes); + } + } + + private static FileSystemSecurity CreateOwnerOnlyWindowsSecurity( + bool directory, + bool writable, + bool setOwner) + { + SecurityIdentifier currentIdentity = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + SecurityIdentifier owner = GetCurrentTokenOwnerSid(); + FileSystemSecurity security = directory ? new DirectorySecurity() : new FileSecurity(); + if (setOwner) + { + security.SetOwner(owner); + } + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + FileSystemRights rights = writable + ? FileSystemRights.FullControl + : FileSystemRights.ReadAndExecute; + InheritanceFlags inheritance = directory && writable ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None; + security.AddAccessRule(new FileSystemAccessRule(currentIdentity, rights, inheritance, + PropagationFlags.None, AccessControlType.Allow)); + return security; + } + + private sealed class WindowsPermissionFacts + { + internal WindowsPermissionFacts(string sddl, bool ownerWritable, string ownerSid, + string currentIdentitySid, string currentOwnerSid, bool accessRulesProtected, + bool hasInheritedAccessRules, + bool ownerOnlyAccess, bool exactOwnerOnlyAccess, + bool exactWritableOwnerOnlyDirectoryAccess, bool fileReadOnly) + { + Sddl = sddl; + OwnerWritable = ownerWritable; + OwnerSid = ownerSid; + CurrentIdentitySid = currentIdentitySid; + CurrentOwnerSid = currentOwnerSid; + AccessRulesProtected = accessRulesProtected; + HasInheritedAccessRules = hasInheritedAccessRules; + OwnerOnlyAccess = ownerOnlyAccess; + ExactOwnerOnlyAccess = exactOwnerOnlyAccess; + ExactWritableOwnerOnlyDirectoryAccess = exactWritableOwnerOnlyDirectoryAccess; + FileReadOnly = fileReadOnly; + } + internal string Sddl { get; } + internal bool OwnerWritable { get; } + internal string OwnerSid { get; } + internal string CurrentIdentitySid { get; } + internal string CurrentOwnerSid { get; } + internal bool AccessRulesProtected { get; } + internal bool HasInheritedAccessRules { get; } + internal bool OwnerOnlyAccess { get; } + internal bool ExactOwnerOnlyAccess { get; } + internal bool ExactWritableOwnerOnlyDirectoryAccess { get; } + internal bool FileReadOnly { get; } + } + + private sealed class SafeLocalMemoryHandle : SafeHandleZeroOrMinusOneIsInvalid + { + internal SafeLocalMemoryHandle(IntPtr handle) : base(ownsHandle: true) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() => LocalFree(handle) == IntPtr.Zero; + } + + private sealed class NativeFacts + { + internal NativeFacts(string identity, string physicalPath, long length, long linkCount, + int unixMode, uint ownerUid, uint effectiveUid, bool isDirectory, + bool isRegularFile, bool isReparsePoint, + bool ownerWritable, string permissionEvidence, string ownerSid, + string currentIdentitySid, string currentOwnerSid, bool accessRulesProtected, + bool hasInheritedAccessRules, + bool ownerOnlyAccess, bool exactOwnerOnlyAccess, + bool exactWritableOwnerOnlyDirectoryAccess, bool fileReadOnly) + { + Identity = identity; + PhysicalPath = physicalPath; + Length = length; + LinkCount = linkCount; + UnixMode = unixMode; + OwnerUid = ownerUid; + EffectiveUid = effectiveUid; + IsDirectory = isDirectory; + IsRegularFile = isRegularFile; + IsReparsePoint = isReparsePoint; + OwnerWritable = ownerWritable; + PermissionEvidence = permissionEvidence; + OwnerSid = ownerSid; + CurrentIdentitySid = currentIdentitySid; + CurrentOwnerSid = currentOwnerSid; + AccessRulesProtected = accessRulesProtected; + HasInheritedAccessRules = hasInheritedAccessRules; + OwnerOnlyAccess = ownerOnlyAccess; + ExactOwnerOnlyAccess = exactOwnerOnlyAccess; + ExactWritableOwnerOnlyDirectoryAccess = exactWritableOwnerOnlyDirectoryAccess; + FileReadOnly = fileReadOnly; + } + internal string Identity { get; } + internal string PhysicalPath { get; } + internal long Length { get; } + internal long LinkCount { get; } + internal int UnixMode { get; } + internal uint OwnerUid { get; } + internal uint EffectiveUid { get; } + internal bool IsDirectory { get; } + internal bool IsRegularFile { get; } + internal bool IsReparsePoint { get; } + internal bool OwnerWritable { get; } + internal string PermissionEvidence { get; } + internal string OwnerSid { get; } + internal string CurrentIdentitySid { get; } + internal string CurrentOwnerSid { get; } + internal bool AccessRulesProtected { get; } + internal bool HasInheritedAccessRules { get; } + internal bool OwnerOnlyAccess { get; } + internal bool ExactOwnerOnlyAccess { get; } + internal bool ExactWritableOwnerOnlyDirectoryAccess { get; } + internal bool FileReadOnly { get; } + internal bool SameObject(NativeFacts other) => + string.Equals(Identity, other.Identity, StringComparison.Ordinal) && + string.Equals(PhysicalPath, other.PhysicalPath, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime { public uint Low; public uint High; } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct FileDispositionInfo + { + public byte DeleteFile; + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + public int Length; + public IntPtr SecurityDescriptor; + public int InheritHandle; + } + + [StructLayout(LayoutKind.Sequential)] + private struct TokenOwnerInformation + { + public IntPtr Owner; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public FileTime CreationTime; + public FileTime LastAccessTime; + public FileTime LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string fileName, uint desiredAccess, uint shareMode, + IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateFileW")] + private static extern SafeFileHandle CreateFileWithSecurityW(string fileName, uint desiredAccess, + uint shareMode, ref SecurityAttributes securityAttributes, uint creationDisposition, + uint flagsAndAttributes, IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateDirectoryW(string path, ref SecurityAttributes securityAttributes); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle file, out ByHandleFileInformation information); + + [DllImport("advapi32.dll")] + private static extern uint GetSecurityInfo( + SafeFileHandle handle, + int objectType, + uint securityInfo, + IntPtr ownerSid, + IntPtr groupSid, + IntPtr dacl, + IntPtr sacl, + out IntPtr securityDescriptor); + + [DllImport("advapi32.dll")] + private static extern uint GetSecurityDescriptorLength(IntPtr securityDescriptor); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation( + IntPtr token, + int informationClass, + IntPtr information, + uint informationLength, + out uint returnLength); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetFileInformationByHandle( + SafeFileHandle file, + int fileInformationClass, + ref FileDispositionInfo fileInformation, + uint bufferSize); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle file, StringBuilder path, + uint pathLength, uint flags); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool MoveFileExW(string existingFileName, string newFileName, uint flags); + + [DllImport("libc", SetLastError = true)] + private static extern int open(string path, int flags); + + [DllImport("libc", SetLastError = true)] + private static extern int fchmod(int descriptor, uint mode); + + [DllImport("libc", SetLastError = true)] + private static extern int fstat(int descriptor, [Out] byte[] stat); + + [DllImport("libc")] + private static extern uint geteuid(); + + [DllImport("libc", EntryPoint = "__fxstat", SetLastError = true)] + private static extern int fxstat(int version, int descriptor, [Out] byte[] stat); + + [DllImport("libc", EntryPoint = "fstat$INODE64", SetLastError = true)] + private static extern int fstat_inode64(int descriptor, [Out] byte[] stat); + + [DllImport("libc", SetLastError = true)] + private static extern int mkdirat(int directory, string path, uint mode); + + [DllImport("libc", SetLastError = true)] + private static extern IntPtr realpath(string path, IntPtr resolvedPath); + + [DllImport("libc", SetLastError = true)] + private static extern int renamex_np(string from, string to, uint flags); + + [DllImport("libc", SetLastError = true)] + private static extern int renameat2(int oldDirectory, string oldPath, int newDirectory, string newPath, uint flags); + + [DllImport("libc")] + private static extern void free(IntPtr pointer); +} diff --git a/scripts/private/GraphKit.SourceCapture.cs b/scripts/private/GraphKit.SourceCapture.cs new file mode 100644 index 0000000..ccb51ea --- /dev/null +++ b/scripts/private/GraphKit.SourceCapture.cs @@ -0,0 +1,918 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Win32.SafeHandles; + +#nullable enable + +namespace __GRAPHKIT_SOURCE_CAPTURE_NAMESPACE__ +{ + internal enum SourceEntryKind + { + Regular, + Directory, + Other + } + + internal readonly struct SourceMetadata + { + internal SourceMetadata(SourceEntryKind kind, string mode, bool hasExecutableMode, string identity, long length) + { + Kind = kind; + Mode = mode; + HasExecutableMode = hasExecutableMode; + Identity = identity; + Length = length; + } + + internal SourceEntryKind Kind { get; } + internal string Mode { get; } + internal bool HasExecutableMode { get; } + internal string Identity { get; } + internal long Length { get; } + } + + public sealed class CapturedSourceFile + { + internal CapturedSourceFile(string mode, bool hasExecutableMode, string identity, long length, byte[] content) + { + Mode = mode; + HasExecutableMode = hasExecutableMode; + Identity = identity; + Length = length; + Content = content; + } + + public string Mode { get; } + public bool HasExecutableMode { get; } + public string Identity { get; } + public long Length { get; } + public byte[] Content { get; } + } + + public static class SourceCapture + { + private const long MaxSourceEntryBytes = 16L * 1024L * 1024L; + + public static string ResolveEffectiveGitMode(string? capturedMode, bool hasExecutableMode, string? indexMode) + { + if (!hasExecutableMode) + { + if (indexMode == "100644" || indexMode == "100755") + { + return indexMode; + } + return "100644"; + } + + if (string.IsNullOrEmpty(capturedMode)) + { + throw new ArgumentException("A handle-derived Unix mode is required.", nameof(capturedMode)); + } + int mode; + try + { + mode = Convert.ToInt32(capturedMode, 8); + } + catch (Exception exception) when (exception is FormatException || exception is OverflowException) + { + throw new ArgumentException("The handle-derived Unix mode is invalid.", nameof(capturedMode), exception); + } + return (mode & 0x40) != 0 ? "100755" : "100644"; + } + + public static CapturedSourceFile Capture(string repositoryRoot, string relativePath) + { + if (string.IsNullOrWhiteSpace(repositoryRoot)) + { + throw new ArgumentException("A repository root is required.", nameof(repositoryRoot)); + } + + string[] segments = ValidateRelativePath(relativePath); + string root = Path.GetFullPath(repositoryRoot); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + ValidateWindowsRelativePathForProof(relativePath); + } + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? CaptureWindows(root, segments) + : CaptureUnix(root, segments); + } + + private static string[] ValidateRelativePath(string relativePath) + { + if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath) || relativePath.IndexOf('\0') >= 0) + { + throw new ArgumentException("The source path must be a non-empty relative Git path.", nameof(relativePath)); + } + + string[] segments = relativePath.Split('/'); + foreach (string segment in segments) + { + if (segment.Length == 0 || segment == "." || segment == "..") + { + throw new ArgumentException("The source path contains an unsafe segment.", nameof(relativePath)); + } + } + + return segments; + } + + public static void ValidateWindowsRelativePathForProof(string relativePath) + { + if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath) || relativePath.IndexOf('\0') >= 0) + { + throw new ArgumentException("The Windows proof path must be a non-empty relative Git path.", nameof(relativePath)); + } + if (relativePath.IndexOf('\\') >= 0 || relativePath.IndexOf(':') >= 0) + { + throw new ArgumentException("A Windows Git source path cannot use backslashes, a drive, or an alternate data stream.", nameof(relativePath)); + } + + foreach (string segment in relativePath.Split('/')) + { + if (segment.Length == 0 || segment == "." || segment == ".." || segment.EndsWith(' ') || segment.EndsWith('.')) + { + throw new ArgumentException("The Windows Git source path contains an unsafe or aliased segment.", nameof(relativePath)); + } + foreach (char character in segment) + { + if (character < 32) + { + throw new ArgumentException("The Windows Git source path contains a control character.", nameof(relativePath)); + } + } + + string stem = segment.Split('.')[0]; + if (Regex.IsMatch(stem, @"^(CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + throw new ArgumentException($"The Windows Git source path segment '{segment}' is a reserved device name.", nameof(relativePath)); + } + if (Regex.IsMatch(segment, @"^[^ .]{1,6}~[1-9][0-9]*(?:\.[^ .]{1,3})?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + throw new ArgumentException($"The Windows Git source path segment '{segment}' is ambiguous with an 8.3 short-name alias.", nameof(relativePath)); + } + } + } + + private static CapturedSourceFile CaptureUnix(string root, string[] segments) + { + int directoryFlags = UnixNative.DirectoryOpenFlags; + int fileFlags = UnixNative.FileOpenFlags; + using SafeFileHandle rootHandle = UnixNative.OpenOwned(root, directoryFlags, "repository root"); + SourceMetadata rootMetadata = UnixNative.GetMetadata(rootHandle); + if (rootMetadata.Kind != SourceEntryKind.Directory) + { + throw new IOException("The repository root is not a directory."); + } + + SafeFileHandle parent = rootHandle; + SafeFileHandle? ownedParent = null; + try + { + for (int index = 0; index < segments.Length - 1; index++) + { + SafeFileHandle next; + try + { + next = UnixNative.OpenAtOwned(parent, segments[index], directoryFlags, $"source path segment '{segments[index]}'"); + } + catch (Exception exception) when (exception is Win32Exception || exception is IOException) + { + throw new IOException($"Source path segment '{segments[index]}' is a symbolic link, missing, or not a directory.", exception); + } + + try + { + SourceMetadata metadata = UnixNative.GetMetadata(next); + if (metadata.Kind != SourceEntryKind.Directory) + { + throw new IOException($"Source path segment '{segments[index]}' is a symbolic link or not a directory."); + } + } + catch + { + next.Dispose(); + throw; + } + + ownedParent?.Dispose(); + ownedParent = next; + parent = next; + } + + SafeFileHandle finalHandle; + try + { + finalHandle = UnixNative.OpenAtOwned(parent, segments[^1], fileFlags, $"source entry '{segments[^1]}'"); + } + catch (Win32Exception exception) when (exception.NativeErrorCode == UnixNative.NoSuchFileOrDirectory) + { + throw new FileNotFoundException("The source entry disappeared before it could be opened.", exception); + } + catch (Win32Exception exception) + { + throw new IOException($"Source entry '{segments[^1]}' is a symbolic link or cannot be opened without following links.", exception); + } + + using (finalHandle) + { + return CaptureVerifiedHandle(finalHandle, UnixNative.GetMetadata, segments[^1]); + } + } + finally + { + ownedParent?.Dispose(); + } + } + + private static CapturedSourceFile CaptureWindows(string root, string[] segments) + { + using SafeFileHandle rootHandle = WindowsNative.OpenRoot(root); + SourceMetadata rootMetadata = WindowsNative.GetMetadata(rootHandle); + if (rootMetadata.Kind != SourceEntryKind.Directory) + { + throw new IOException("The repository root is not a directory."); + } + if (WindowsNative.IsReparsePoint(rootHandle)) + { + throw new IOException("The repository root is an unsupported reparse point."); + } + + SafeFileHandle parent = rootHandle; + SafeFileHandle? ownedParent = null; + try + { + for (int index = 0; index < segments.Length - 1; index++) + { + SafeFileHandle next = WindowsNative.OpenRelative(parent, segments[index], true); + try + { + if (WindowsNative.IsReparsePoint(next)) + { + throw new IOException($"Source path segment '{segments[index]}' is an unsupported reparse point."); + } + if (WindowsNative.GetMetadata(next).Kind != SourceEntryKind.Directory) + { + throw new IOException($"Source path segment '{segments[index]}' is not a directory."); + } + } + catch + { + next.Dispose(); + throw; + } + + ownedParent?.Dispose(); + ownedParent = next; + parent = next; + } + + SafeFileHandle finalHandle; + try + { + finalHandle = WindowsNative.OpenRelative(parent, segments[^1], false); + } + catch (Win32Exception exception) when (exception.NativeErrorCode == WindowsNative.ErrorFileNotFound || exception.NativeErrorCode == WindowsNative.ErrorPathNotFound) + { + throw new FileNotFoundException("The source entry disappeared before it could be opened.", exception); + } + + using (finalHandle) + { + if (WindowsNative.IsReparsePoint(finalHandle)) + { + throw new IOException($"Source entry '{segments[^1]}' is an unsupported reparse point."); + } + return CaptureVerifiedHandle(finalHandle, WindowsNative.GetMetadata, segments[^1]); + } + } + finally + { + ownedParent?.Dispose(); + } + } + + private static CapturedSourceFile CaptureVerifiedHandle( + SafeFileHandle handle, + Func getMetadata, + string displayName) + { + SourceMetadata before = getMetadata(handle); + if (before.Kind != SourceEntryKind.Regular) + { + throw new IOException($"Source entry '{displayName}' is an unsupported special/non-regular file."); + } + if (before.Length < 0 || before.Length > MaxSourceEntryBytes) + { + throw new IOException($"Source entry '{displayName}' exceeds the 16 MiB per-entry GraphKit package-source limit; keep generated or binary assets out of package-producing source."); + } + + byte[] content = ReadExactly(handle, before.Length); + SourceMetadata after = getMetadata(handle); + EnsureSameMetadata(before, after, displayName); + byte[] confirmation = ReadExactly(handle, after.Length); + SourceMetadata confirmed = getMetadata(handle); + EnsureSameMetadata(after, confirmed, displayName); + if (!BytesEqual(content, confirmation)) + { + throw new IOException($"Source entry '{displayName}' content changed during handle confirmation."); + } + + return new CapturedSourceFile(before.Mode, before.HasExecutableMode, before.Identity, before.Length, content); + } + + private static byte[] ReadExactly(SafeFileHandle handle, long length) + { + byte[] content = new byte[(int)length]; + int offset = 0; + while (offset < content.Length) + { + int read = RandomAccess.Read(handle, content.AsSpan(offset), offset); + if (read == 0) + { + throw new EndOfStreamException("The source entry ended before its handle-reported length."); + } + offset += read; + } + Span extra = stackalloc byte[1]; + if (RandomAccess.Read(handle, extra, length) != 0) + { + throw new IOException("The source entry grew beyond its handle-reported length."); + } + return content; + } + + private static void EnsureSameMetadata(SourceMetadata expected, SourceMetadata actual, string displayName) + { + if (expected.Kind != actual.Kind || + !string.Equals(expected.Mode, actual.Mode, StringComparison.Ordinal) || + expected.HasExecutableMode != actual.HasExecutableMode || + !string.Equals(expected.Identity, actual.Identity, StringComparison.Ordinal) || + expected.Length != actual.Length) + { + throw new IOException($"Source entry '{displayName}' handle metadata changed during capture."); + } + } + + private static bool BytesEqual(byte[] left, byte[] right) + { + if (left.Length != right.Length) + { + return false; + } + for (int index = 0; index < left.Length; index++) + { + if (left[index] != right[index]) + { + return false; + } + } + return true; + } + } + + internal static class UnixNative + { + private const int LinuxOpenNonBlock = 0x800; + private const int LinuxOpenDirectory = 0x10000; + private const int LinuxOpenNoFollow = 0x20000; + private const int LinuxOpenCloseOnExec = 0x80000; + private const int DarwinOpenNonBlock = 0x4; + private const int DarwinOpenNoFollow = 0x100; + private const int DarwinOpenDirectory = 0x100000; + private const int DarwinOpenCloseOnExec = 0x1000000; + private const int AtEmptyPath = 0x1000; + private const int AtSymlinkNoFollow = 0x100; + private const uint StatxType = 0x0001; + private const uint StatxMode = 0x0002; + private const uint StatxInode = 0x0100; + private const uint StatxSize = 0x0200; + private const uint RequiredStatxMask = StatxType | StatxMode | StatxInode | StatxSize; + private const int FileTypeMask = 0xF000; + private const int RegularFile = 0x8000; + private const int Directory = 0x4000; + + internal const int NoSuchFileOrDirectory = 2; + + internal static int DirectoryOpenFlags => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? DarwinOpenNonBlock | DarwinOpenNoFollow | DarwinOpenDirectory | DarwinOpenCloseOnExec + : LinuxOpenNonBlock | LinuxOpenNoFollow | LinuxOpenDirectory | LinuxOpenCloseOnExec; + + internal static int FileOpenFlags => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? DarwinOpenNonBlock | DarwinOpenNoFollow | DarwinOpenCloseOnExec + : LinuxOpenNonBlock | LinuxOpenNoFollow | LinuxOpenCloseOnExec; + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int Open(string path, int flags); + + [DllImport("libc", EntryPoint = "openat", SetLastError = true)] + private static extern int OpenAt(int directoryHandle, string path, int flags); + + [DllImport("libc", EntryPoint = "fstat", SetLastError = true)] + private static extern int DarwinFStat(int handle, out DarwinStat metadata); + + [DllImport("libc", EntryPoint = "fstat$INODE64", SetLastError = true)] + private static extern int DarwinFStatInode64(int handle, out DarwinStat metadata); + + [DllImport("libc", EntryPoint = "statx", SetLastError = true)] + private static extern int LinuxStatx(int directoryHandle, string path, int flags, uint mask, out Statx metadata); + + internal static SafeFileHandle OpenOwned(string path, int flags, string description) + { + int raw = Open(path, flags); + return OwnDescriptor(raw, description); + } + + internal static SafeFileHandle OpenAtOwned(SafeFileHandle parent, string path, int flags, string description) + { + bool addedReference = false; + try + { + parent.DangerousAddRef(ref addedReference); + int raw = OpenAt(parent.DangerousGetHandle().ToInt32(), path, flags); + return OwnDescriptor(raw, description); + } + finally + { + if (addedReference) + { + parent.DangerousRelease(); + } + } + } + + private static SafeFileHandle OwnDescriptor(int raw, string description) + { + if (raw < 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), $"Cannot open {description} without following links."); + } + + try + { + var owned = new SafeFileHandle((IntPtr)raw, true); + raw = -1; + return owned; + } + finally + { + if (raw >= 0) + { + new SafeFileHandle((IntPtr)raw, true).Dispose(); + } + } + } + + internal static SourceMetadata GetMetadata(SafeFileHandle handle) + { + bool addedReference = false; + try + { + handle.DangerousAddRef(ref addedReference); + int descriptor = handle.DangerousGetHandle().ToInt32(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + int status; + Statx metadata; + try + { + status = LinuxStatx( + descriptor, + string.Empty, + AtEmptyPath | AtSymlinkNoFollow, + RequiredStatxMask, + out metadata); + } + catch (EntryPointNotFoundException exception) + { + throw new PlatformNotSupportedException( + "Root-anchored Linux source capture requires the libc statx entry point.", + exception); + } + if (status != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "statx failed for an opened source handle."); + } + if ((metadata.Mask & RequiredStatxMask) != RequiredStatxMask) + { + throw new IOException("statx did not return the required source identity fields."); + } + + int mode = metadata.Mode; + return new SourceMetadata( + GetKind(mode), + ToGitMode(mode), + true, + $"linux:{metadata.DeviceMajor:x8}:{metadata.DeviceMinor:x8}:{metadata.Inode:x16}", + checked((long)metadata.Size)); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + int status; + DarwinStat metadata; + try + { + status = RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => DarwinFStat(descriptor, out metadata), + Architecture.X64 => DarwinFStatInode64(descriptor, out metadata), + _ => throw new PlatformNotSupportedException( + $"Root-anchored macOS source capture does not define an fstat ABI for '{RuntimeInformation.ProcessArchitecture}'.") + }; + } + catch (EntryPointNotFoundException exception) + { + throw new PlatformNotSupportedException( + $"Root-anchored macOS source capture cannot resolve the required fstat ABI for '{RuntimeInformation.ProcessArchitecture}'.", + exception); + } + if (status != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "fstat failed for an opened source handle."); + } + int mode = metadata.Mode; + return new SourceMetadata( + GetKind(mode), + ToGitMode(mode), + true, + $"darwin:{unchecked((uint)metadata.Device):x8}:{metadata.Inode:x16}", + metadata.Size); + } + + throw new PlatformNotSupportedException("Root-anchored source capture is unavailable on this Unix platform."); + } + finally + { + if (addedReference) + { + handle.DangerousRelease(); + } + } + } + + private static SourceEntryKind GetKind(int mode) + { + return (mode & FileTypeMask) switch + { + RegularFile => SourceEntryKind.Regular, + Directory => SourceEntryKind.Directory, + _ => SourceEntryKind.Other + }; + } + + private static string ToGitMode(int mode) + { + return Convert.ToString(mode, 8).PadLeft(6, '0'); + } + + [StructLayout(LayoutKind.Sequential)] + private struct StatxTimestamp + { + internal long Seconds; + internal uint Nanoseconds; + internal int Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Statx + { + internal uint Mask; + internal uint BlockSize; + internal ulong Attributes; + internal uint LinkCount; + internal uint UserId; + internal uint GroupId; + internal ushort Mode; + internal ushort Padding; + internal ulong Inode; + internal ulong Size; + internal ulong Blocks; + internal ulong AttributesMask; + internal StatxTimestamp AccessTime; + internal StatxTimestamp BirthTime; + internal StatxTimestamp ChangeTime; + internal StatxTimestamp ModificationTime; + internal uint RDeviceMajor; + internal uint RDeviceMinor; + internal uint DeviceMajor; + internal uint DeviceMinor; + internal ulong MountId; + internal uint DirectIoMemoryAlignment; + internal uint DirectIoOffsetAlignment; + internal ulong Spare0; + internal ulong Spare1; + internal ulong Spare2; + internal ulong Spare3; + internal ulong Spare4; + internal ulong Spare5; + internal ulong Spare6; + internal ulong Spare7; + internal ulong Spare8; + internal ulong Spare9; + internal ulong Spare10; + internal ulong Spare11; + } + + [StructLayout(LayoutKind.Sequential)] + private struct DarwinTimespec + { + internal long Seconds; + internal long Nanoseconds; + } + + [StructLayout(LayoutKind.Sequential)] + private struct DarwinStat + { + internal int Device; + internal ushort Mode; + internal ushort LinkCount; + internal ulong Inode; + internal uint UserId; + internal uint GroupId; + internal int SpecialDevice; + internal DarwinTimespec AccessTime; + internal DarwinTimespec ModificationTime; + internal DarwinTimespec ChangeTime; + internal DarwinTimespec BirthTime; + internal long Size; + internal long Blocks; + internal int BlockSize; + internal uint Flags; + internal uint Generation; + internal int Spare; + internal long QSpare0; + internal long QSpare1; + } + } + + internal static class WindowsNative + { + private const uint FileReadData = 0x0001; + private const uint FileListDirectory = 0x0001; + private const uint FileTraverse = 0x0020; + private const uint FileReadAttributes = 0x0080; + private const uint Synchronize = 0x00100000; + private const uint GenericRead = 0x80000000; + private const uint ShareRead = 0x00000001; + private const uint ShareWrite = 0x00000002; + private const uint ShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileDirectoryFile = 0x00000001; + private const uint FileSynchronousIoNonAlert = 0x00000020; + private const uint FileNonDirectoryFile = 0x00000040; + private const uint FileOpenReparsePoint = 0x00200000; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileAttributeReparsePoint = 0x00000400; + private const uint FileAttributeDirectory = 0x00000010; + private const uint FileOpen = 1; + private const uint FileNameNormalized = 0; + + internal const int ErrorFileNotFound = 2; + internal const int ErrorPathNotFound = 3; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out ByHandleFileInformation information); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle handle, + StringBuilder path, + uint pathLength, + uint flags); + + [DllImport("ntdll.dll")] + private static extern int NtCreateFile( + out IntPtr fileHandle, + uint desiredAccess, + ref ObjectAttributes objectAttributes, + out IoStatusBlock ioStatusBlock, + IntPtr allocationSize, + uint fileAttributes, + uint shareAccess, + uint createDisposition, + uint createOptions, + IntPtr eaBuffer, + uint eaLength); + + [DllImport("ntdll.dll")] + private static extern uint RtlNtStatusToDosError(int status); + + internal static SafeFileHandle OpenRoot(string root) + { + SafeFileHandle handle = CreateFileW( + root, + FileTraverse | FileReadAttributes | Synchronize, + ShareRead | ShareWrite | ShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, "Cannot open the repository root without following reparse points."); + } + return handle; + } + + internal static SafeFileHandle OpenRelative(SafeFileHandle parent, string segment, bool directory) + { + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodeStringPointer = IntPtr.Zero; + bool addedReference = false; + IntPtr raw = IntPtr.Zero; + try + { + nameBuffer = Marshal.StringToHGlobalUni(segment); + var unicodeString = new UnicodeString + { + Length = checked((ushort)(segment.Length * 2)), + MaximumLength = checked((ushort)((segment.Length + 1) * 2)), + Buffer = nameBuffer + }; + unicodeStringPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(unicodeString, unicodeStringPointer, false); + parent.DangerousAddRef(ref addedReference); + var attributes = new ObjectAttributes + { + Length = Marshal.SizeOf(), + RootDirectory = parent.DangerousGetHandle(), + ObjectName = unicodeStringPointer, + Attributes = 0 + }; + uint access = FileReadAttributes | Synchronize | + (directory ? FileListDirectory | FileTraverse : GenericRead | FileReadData); + uint options = FileOpenReparsePoint | FileSynchronousIoNonAlert | (directory ? FileDirectoryFile : FileNonDirectoryFile); + int status = NtCreateFile( + out raw, + access, + ref attributes, + out _, + IntPtr.Zero, + 0, + ShareRead | ShareWrite | ShareDelete, + FileOpen, + options, + IntPtr.Zero, + 0); + if (status < 0) + { + int error = unchecked((int)RtlNtStatusToDosError(status)); + throw new Win32Exception(error, $"Cannot open source path segment '{segment}' relative to its verified parent handle."); + } + + var owned = new SafeFileHandle(raw, true); + raw = IntPtr.Zero; + try + { + EnsureExactOpenedSegment(owned, segment); + return owned; + } + catch + { + owned.Dispose(); + throw; + } + } + finally + { + if (raw != IntPtr.Zero && raw != new IntPtr(-1)) + { + new SafeFileHandle(raw, true).Dispose(); + } + if (addedReference) + { + parent.DangerousRelease(); + } + if (unicodeStringPointer != IntPtr.Zero) + { + Marshal.FreeHGlobal(unicodeStringPointer); + } + if (nameBuffer != IntPtr.Zero) + { + Marshal.FreeHGlobal(nameBuffer); + } + } + } + + private static void EnsureExactOpenedSegment(SafeFileHandle handle, string requestedSegment) + { + var path = new StringBuilder(512); + uint length = GetFinalPathNameByHandleW(handle, path, checked((uint)path.Capacity), FileNameNormalized); + if (length == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Cannot query the exact name of an opened source path segment."); + } + if (length >= path.Capacity) + { + path = new StringBuilder(checked((int)length + 1)); + length = GetFinalPathNameByHandleW(handle, path, checked((uint)path.Capacity), FileNameNormalized); + if (length == 0 || length >= path.Capacity) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Cannot query the exact name of an opened source path segment."); + } + } + + string fullPath = path.ToString().TrimEnd('\\', '/'); + int separator = Math.Max(fullPath.LastIndexOf('\\'), fullPath.LastIndexOf('/')); + string openedSegment = separator >= 0 ? fullPath.Substring(separator + 1) : fullPath; + if (!string.Equals(openedSegment, requestedSegment, StringComparison.Ordinal)) + { + throw new IOException($"Source path segment '{requestedSegment}' resolved to alias or differently-cased name '{openedSegment}'."); + } + } + + internal static bool IsReparsePoint(SafeFileHandle handle) + { + return (GetInformation(handle).FileAttributes & FileAttributeReparsePoint) != 0; + } + + internal static SourceMetadata GetMetadata(SafeFileHandle handle) + { + ByHandleFileInformation information = GetInformation(handle); + bool reparsePoint = (information.FileAttributes & FileAttributeReparsePoint) != 0; + bool directory = (information.FileAttributes & FileAttributeDirectory) != 0; + long length = directory ? 0 : ((long)information.FileSizeHigh << 32) | information.FileSizeLow; + string identity = $"windows:{information.VolumeSerialNumber:x8}:{information.FileIndexHigh:x8}{information.FileIndexLow:x8}"; + return new SourceMetadata( + reparsePoint ? SourceEntryKind.Other : directory ? SourceEntryKind.Directory : SourceEntryKind.Regular, + information.FileAttributes.ToString("x8"), + false, + identity, + length); + } + + private static ByHandleFileInformation GetInformation(SafeFileHandle handle) + { + if (!GetFileInformationByHandle(handle, out ByHandleFileInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Cannot read metadata from an opened source handle."); + } + return information; + } + + [StructLayout(LayoutKind.Sequential)] + private struct UnicodeString + { + internal ushort Length; + internal ushort MaximumLength; + internal IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ObjectAttributes + { + internal int Length; + internal IntPtr RootDirectory; + internal IntPtr ObjectName; + internal uint Attributes; + internal IntPtr SecurityDescriptor; + internal IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoStatusBlock + { + internal IntPtr Status; + internal UIntPtr Information; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime + { + internal uint Low; + internal uint High; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + internal uint FileAttributes; + internal FileTime CreationTime; + internal FileTime LastAccessTime; + internal FileTime LastWriteTime; + internal uint VolumeSerialNumber; + internal uint FileSizeHigh; + internal uint FileSizeLow; + internal uint NumberOfLinks; + internal uint FileIndexHigh; + internal uint FileIndexLow; + } + } +} diff --git a/scripts/private/Invoke-GraphKitAuthParityWorker.ps1 b/scripts/private/Invoke-GraphKitAuthParityWorker.ps1 new file mode 100644 index 0000000..4681995 --- /dev/null +++ b/scripts/private/Invoke-GraphKitAuthParityWorker.ps1 @@ -0,0 +1,266 @@ +<# + Private child-process boundary for Invoke-GraphKitAuthParity.ps1. + The parent owns extraction and deletion; this process alone loads the candidate module. +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$runnerPath = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../Invoke-GraphKitAuthParity.ps1')) +$hookKey = 'GraphKit.Task8.ParityTestHooks/1' +$savedHooks = [AppDomain]::CurrentDomain.GetData($hookKey) +[AppDomain]::CurrentDomain.SetData($hookKey, $null) +try { + . $runnerPath -PackagePath 'unused.nupkg' -PackageSha256 ('0' * 64) ` + -AuthMode Certificate -DryRun +} +finally { + [AppDomain]::CurrentDomain.SetData($hookKey, $savedHooks) +} +$workerHooks = Get-GraphKitAuthParityTestHooks +Initialize-GraphKitAuthParityProcessTreeNative +$script:GraphKitAuthParityProcessTreeType::EnterUnixWorkerSession() + +function New-GraphKitAuthParityInternalResult { + param( + [string] $Nonce = $('0' * 64), + [string] $Execution = 'DryRun', + [string] $Mode = 'Certificate', + [string] $Digest = $('0' * 64), + [string] $ModuleVersion = '0.0.0-rejected', + [string] $RequestSha256 = $('0' * 64) + ) + $adapter = [ordered]@{} + foreach ($name in $script:GraphKitAuthParityAdapterChecks) { $adapter[$name] = $false } + return [pscustomobject][ordered]@{ + recordKind = $script:GraphKitAuthParityWorkerResultKind + nonce = $Nonce + requestSha256 = $RequestSha256 + execution = $Execution + authMode = $Mode + packageSha256 = $Digest + moduleVersion = $ModuleVersion + state = 'Failed' + failureStage = 'Import' + failureCode = 'ImportRejected' + exactImport = $false + adapter = [pscustomobject]$adapter + contextMatched = $false + sourceMatched = $false + tenantProofVerified = $false + readAttempted = $false + readSucceeded = $false + rowCount = [long]0 + workerTeardownVerified = $false + } +} + +function Read-GraphKitAuthParityInternalRequest { + $inputStream = [Console]::OpenStandardInput() + $memory = [IO.MemoryStream]::new() + $buffer = [byte[]]::new(4096) + try { + while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + if ($memory.Length + $count -gt $script:GraphKitAuthParityMaxWorkerRequestBytes) { + throw [InvalidOperationException]::new( + 'The protected parity worker request exceeded its byte bound.') + } + $memory.Write($buffer, 0, $count) + } + if ($memory.Length -eq 0) { + throw [InvalidOperationException]::new( + 'The protected parity worker request is invalid.') + } + return ,$memory.ToArray() + } + finally { + $memory.Dispose() + $inputStream.Dispose() + } +} + +$result = New-GraphKitAuthParityInternalResult +$request = $null +$state = $null +$route = $null +$imported = $null +$importedModule = $null +$diagnostics = $null +$providerWeakReference = $null +$liveCore = $null +$failureStage = 'Import' +$failureCode = 'ImportRejected' +$cleanupFailed = $false +$hadModulePath = Test-Path -LiteralPath Env:PSModulePath +$savedModulePath = if ($hadModulePath) { [string]$env:PSModulePath } else { $null } +$modulePathChanged = $false + +try { + [byte[]]$requestBytes = Read-GraphKitAuthParityInternalRequest + $requestSha256 = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($requestBytes)).ToLowerInvariant() + $requestText = [Text.UTF8Encoding]::new($false, $true).GetString($requestBytes) + if ($requestText -cnotmatch '\A\{[^\r\n]*\}\z' -or + $requestText[0] -eq [char]0xFEFF) { + throw [InvalidOperationException]::new('The protected parity worker request frame is invalid.') + } + $request = ConvertFrom-GraphKitAuthParityWorkerJson -Json $requestText ` + -MaximumBytes $script:GraphKitAuthParityMaxWorkerRequestBytes + $converted = ConvertFrom-GraphKitAuthParityWorkerState -Request $request + $state = $converted.State + if ([string]$request.nonce -cnotmatch '^[0-9a-f]{64}$') { + throw [InvalidOperationException]::new('The protected parity worker nonce was rejected.') + } + $result = New-GraphKitAuthParityInternalResult -Nonce ([string]$request.nonce) ` + -Execution ([string]$request.execution) -Mode ([string]$request.authMode) ` + -Digest ([string]$request.packageSha256) ` + -ModuleVersion ([string]$request.moduleVersion) -RequestSha256 $requestSha256 + if (@(Get-Module -Name GraphKit -All).Count -ne 0) { + throw [InvalidOperationException]::new('A GraphKit module is already loaded in the worker.') + } + + Initialize-GraphKitAuthParityNative + Assert-GraphKitAuthParityState -State $state -Purpose Import + $route = Get-GraphKitAuthParityDescriptorRoute ` + -ManifestRoot $state.ModuleRoot -Mode ([string]$request.authMode) + if ((Get-GraphKitAuthParityFullVersion -ManifestPath $state.ExtractedManifestPath) -cne + [string]$request.moduleVersion) { + throw [InvalidOperationException]::new('The protected parity worker version was rejected.') + } + + $env:PSModulePath = if ($hadModulePath -and + -not [string]::IsNullOrEmpty($savedModulePath)) { + $state.ModuleRoot + [IO.Path]::PathSeparator + $savedModulePath + } + else { $state.ModuleRoot } + $modulePathChanged = $true + Assert-GraphKitAuthParityState -State $state -Purpose Import + $imported = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { + Import-Module -Name $state.ExtractedManifestPath -PassThru -Force -ErrorAction Stop + } + $importedModule = $imported[0] + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { [StringComparison]::Ordinal } + if ($importedModule.Name -cne 'GraphKit' -or + -not [string]::Equals( + [IO.Path]::GetFullPath($importedModule.ModuleBase), + [IO.Path]::GetFullPath($state.ModuleRoot), $comparison) -or + -not [string]::Equals( + [IO.Path]::GetFullPath($importedModule.Path), + [IO.Path]::GetFullPath($state.ExtractedModulePath), $comparison) -or + "$($importedModule.Version)-$($importedModule.PrivateData.PSData.Prerelease)" -cne + [string]$request.moduleVersion) { + throw [InvalidOperationException]::new('The exact extracted GraphKit module was not imported.') + } + $state.ImportedManifestPath = $state.ExtractedManifestPath + $state.ImportedModulePath = $importedModule.Path + $result.exactImport = $true + Invoke-GraphKitAuthParityHook -Hooks $workerHooks -Name AfterImport -Arguments @($state) + + $failureStage = 'Diagnostics' + $failureCode = 'DiagnosticsRejected' + $diagnostics = Get-GraphKitAuthParityDiagnostics -Module $importedModule -State $state + $providerWeakReference = $diagnostics.ProviderWeakReference + foreach ($property in $diagnostics.Checks.PSObject.Properties) { + $result.adapter.$($property.Name) = [bool]$property.Value + } + if (@($result.adapter.PSObject.Properties.Value | Where-Object { -not [bool]$_ }).Count -ne 0) { + throw [InvalidOperationException]::new('The GraphKit.Auth adapter diagnostics were rejected.') + } + + if ($request.execution -ceq 'Live') { + $storePathBound = [bool]$request.storePathBound + Invoke-GraphKitAuthParityHook -Hooks $workerHooks -Name PrepareLiveModule ` + -Arguments @( + $importedModule, $state, $route, [string]$request.profileId, + $(if ($storePathBound) { [string]$request.storePath } else { $null }), + $storePathBound) + Assert-GraphKitAuthParityState -State $state -Purpose Import + $contextCommands = @(Get-Command -Name Get-GraphContext -Module GraphKit ` + -CommandType Function -ErrorAction Stop) + $readCommands = @(Get-Command -Name Get-GraphObject -Module GraphKit ` + -CommandType Function -ErrorAction Stop) + if ($contextCommands.Count -ne 1 -or + -not [object]::ReferenceEquals($contextCommands[0].Module, $importedModule) -or + $readCommands.Count -ne 1 -or + -not [object]::ReferenceEquals($readCommands[0].Module, $importedModule)) { + throw [InvalidOperationException]::new('The exact public live commands were not found.') + } + $getContextAction = { + param($requestedProfileId, $requestedStorePath, $selectedRoute) + $parameters = @{ ProfileId = $requestedProfileId; ErrorAction = 'Stop' } + if ($storePathBound) { $parameters.StorePath = $requestedStorePath } + $records = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { + & $contextCommands[0] @parameters + } + return $records[0] + }.GetNewClosure() + $readAction = { + param($context, $type, $operation, $passThruResult) + $records = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { + & $readCommands[0] -Context $context -Type $type -Operation $operation ` + -PassThruResult:$passThruResult -ErrorAction Stop + } + return $records[0] + }.GetNewClosure() + $liveCore = Invoke-GraphKitAuthParityLiveCore -Route $route -Diagnostics $diagnostics ` + -ProfileId ([string]$request.profileId) -StorePath ([string]$request.storePath) ` + -StorePathBound:$storePathBound -GetContextAction $getContextAction ` + -ReadAction $readAction + $result.contextMatched = [bool]$liveCore.contextMatched + $result.sourceMatched = [bool]$liveCore.sourceMatched + $result.tenantProofVerified = [bool]$liveCore.tenantProofVerified + $result.readAttempted = [bool]$liveCore.readAttempted + $result.readSucceeded = [bool]$liveCore.readSucceeded + $result.rowCount = [long]$liveCore.rowCount + if ($liveCore.state -cne 'Passed') { + $failureStage = [string]$liveCore.failureStage + $failureCode = [string]$liveCore.failureCode + throw [InvalidOperationException]::new('The protected parity live core was rejected.') + } + } + $result.state = 'Passed' + $result.failureStage = 'None' + $result.failureCode = 'None' +} +catch { + $result.state = 'Failed' + $result.failureStage = $failureStage + $result.failureCode = $failureCode +} +finally { + $liveCore = $null + $route = $null + $diagnostics = $null + $imported = $null + if ($null -ne $importedModule) { + try { + $null = Invoke-GraphKitAuthParityCaptured -ExpectedCount 0 -Action { + Remove-Module -ModuleInfo $importedModule -Force -ErrorAction Stop + } + } + catch { $cleanupFailed = $true } + $importedModule = $null + } + # The provider context was proven collectible by diagnostics and by its + # dedicated unload gate. Process exit is the isolation boundary here; a + # script-scope local can otherwise retain the WeakReference target until exit. + $providerWeakReference = $null + if ($modulePathChanged) { + if ($hadModulePath) { $env:PSModulePath = $savedModulePath } + else { Remove-Item -LiteralPath Env:PSModulePath -ErrorAction SilentlyContinue } + } + $result.workerTeardownVerified = -not $cleanupFailed + if ($cleanupFailed) { + $result.state = 'Failed' + $result.failureStage = 'Cleanup' + $result.failureCode = 'CleanupFailed' + } +} + +$json = $result | ConvertTo-Json -Compress -Depth 5 +[Console]::Out.WriteLine($json) diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 new file mode 100644 index 0000000..9e65841 --- /dev/null +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -0,0 +1,537 @@ +function Get-GraphKitPackagePrivacyDigest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value + ) + + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) + return [System.Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($bytes) + ).ToLowerInvariant() +} + +function Test-GraphKitPackagePrivacyPlaceholderGuid { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Value + ) + + # Sequential all-zero namespace values are conventional deterministic test ids, including + # ...0002 and ...0099. They cannot be RFC 4122 identifiers because the version field is zero. + if ($Value -match '^00000000-0000-0000-0000-[0-9]{12}$') { + return $true + } + + # Repeated segments alone do not prove a placeholder. A value whose version and variant + # nibbles form an RFC 4122 / RFC 9562 UUID is a plausible tenant or client identifier and + # must pass only through an exact allowlist, even when every segment repeats one character. + if ($Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$') { + return $false + } + + foreach ($segment in @($Value -split '-')) { + if (@($segment.ToCharArray() | Select-Object -Unique).Count -gt 1) { + return $false + } + } + return $true +} + +function Add-GraphKitPackagePrivacyFinding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[object]] $Findings, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[string]] $FindingKeys, + + [Parameter(Mandatory)] + [string] $EntryName, + + [Parameter(Mandatory)] + [string] $Encoding, + + [Parameter(Mandatory)] + [string] $Category, + + [Parameter(Mandatory)] + [string] $Evidence + ) + + $entryDigest = Get-GraphKitPackagePrivacyDigest -Value $EntryName + $evidenceDigest = Get-GraphKitPackagePrivacyDigest -Value $Evidence + $key = "$entryDigest|$Category|$evidenceDigest" + if (-not $FindingKeys.Add($key)) { + return + } + + # No matched value is retained. Callers can safely render the fixed category and digests + # in a public CI log without echoing the identifier the gate exists to contain. + $Findings.Add([pscustomobject] [ordered] @{ + Category = $Category + Encoding = $Encoding + EntrySha256 = $entryDigest + EvidenceSha256 = $evidenceDigest + }) +} + +function Test-GraphKitPackagePrivacyText { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter(Mandatory)] + [string] $EntryName, + + [Parameter(Mandatory)] + [string] $Encoding, + + [Parameter(Mandatory)] + [System.Collections.Generic.HashSet[string]] $AllowedGuids, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[object]] $Findings, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[string]] $FindingKeys + ) + + $fixedPatterns = [ordered] @{ + 'local user path' = '(?i)(?:/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)' + 'internal project name' = '(?i)\bivy24\b|\bIntuneHealthAutomation\b' + } + foreach ($category in $fixedPatterns.Keys) { + foreach ($match in [regex]::Matches($Text, $fixedPatterns[$category])) { + Add-GraphKitPackagePrivacyFinding -Findings $Findings -FindingKeys $FindingKeys ` + -EntryName $EntryName -Encoding $Encoding -Category $category -Evidence $match.Value + } + } + + # These digests bind one exact textual GUID to the pinned Microsoft.Identity.Client 4.82.1 + # package entry and its UTF-16LE scan. The same GUID anywhere else remains a finding. + $allowedVendorEntryDigest = + '05361882fc2186c7978aceec9ede027acbceaa1753c0bfe72e13d281d19261e8' + $allowedVendorGuidDigest = + '391ab33fdbbec5d86574ef81ce268caffeccdc6ea36e7940358e4ded01294842' + $guidPattern = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + foreach ($match in [regex]::Matches($Text, $guidPattern)) { + if ($AllowedGuids.Contains($match.Value) -or + (Test-GraphKitPackagePrivacyPlaceholderGuid -Value $match.Value) -or + ($Encoding -ceq 'binary-utf16le' -and + (Get-GraphKitPackagePrivacyDigest -Value $EntryName) -ceq $allowedVendorEntryDigest -and + (Get-GraphKitPackagePrivacyDigest -Value $match.Value) -ceq $allowedVendorGuidDigest)) { + continue + } + Add-GraphKitPackagePrivacyFinding -Findings $Findings -FindingKeys $FindingKeys ` + -EntryName $EntryName -Encoding $Encoding ` + -Category 'GUID that is not a well-known or package id' -Evidence $match.Value + } + + # Forty hexadecimal characters are also the normal shape of Git source and dependency + # revisions. Treating every such value as a certificate thumbprint makes the real compiled + # package fail on its deterministic RepositoryCommit metadata. Require certificate context + # close to the value instead; the match is still redacted before it leaves this function. + $thumbprintPattern = '(?is)\b(?:certificate(?:[-_ ]?thumbprint)?|thumbprint|certificate[-_ ]?fingerprint)\b[^\r\n]{0,64}?\b(?[0-9a-f]{40})\b' + foreach ($match in [regex]::Matches($Text, $thumbprintPattern)) { + Add-GraphKitPackagePrivacyFinding -Findings $Findings -FindingKeys $FindingKeys ` + -EntryName $EntryName -Encoding $Encoding -Category 'certificate thumbprint' ` + -Evidence $match.Groups['value'].Value + } + + # Customer tokens stay represented only by one-way digests in public source. Never add a + # plaintext customer name here and never retain the matching token in a result object. + $secretTokenHashes = @{ + '5cad5cdbf022740cbfc976f9836ac89d' = 'customer name (A)' + 'e03427b1afcd1e84a97ed1f2241466cb' = 'internal workspace tenant' + '9a08498936078c81ec926fedbce5e7c9' = 'customer name (A, short form)' + '6ca05670c4afd49e806f7cddbab83b00' = 'lab tenant id' + } + $secretTokenCandidates = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $maximumSecretTokenCandidates = 8192 + $secretTokenCandidateLimitExceeded = $false + :secretTokenGeneration foreach ($token in [regex]::Matches($Text, '[A-Za-z0-9][A-Za-z0-9-]{3,}')) { + if (-not $secretTokenCandidates.Contains($token.Value)) { + if ($secretTokenCandidates.Count -ge $maximumSecretTokenCandidates) { + $secretTokenCandidateLimitExceeded = $true + break secretTokenGeneration + } + $null = $secretTokenCandidates.Add($token.Value) + } + $segments = @([regex]::Matches($token.Value, '[A-Za-z0-9]+')) + if ($segments.Count -le 1) { + continue + } + + # Candidate generation stays bounded at 528 substrings of at most 512 characters + # per hyphenated run. An identifier outside that envelope fails closed instead of + # creating quadratic work or silently bypassing the digest scan. + if ($segments.Count -gt 32 -or $token.Value.Length -gt 512) { + Add-GraphKitPackagePrivacyFinding -Findings $Findings -FindingKeys $FindingKeys ` + -EntryName $EntryName -Encoding $Encoding ` + -Category 'hyphenated identifier exceeds bounded privacy scan' ` + -Evidence $token.Value + continue + } + + for ($start = 0; $start -lt $segments.Count; $start++) { + for ($finish = $start; $finish -lt $segments.Count; $finish++) { + $candidateStart = $segments[$start].Index + $candidateLength = $segments[$finish].Index + $segments[$finish].Length - $candidateStart + if ($candidateLength -ge 4) { + $candidate = $token.Value.Substring($candidateStart, $candidateLength) + if (-not $secretTokenCandidates.Contains($candidate)) { + if ($secretTokenCandidates.Count -ge $maximumSecretTokenCandidates) { + $secretTokenCandidateLimitExceeded = $true + break secretTokenGeneration + } + $null = $secretTokenCandidates.Add($candidate) + } + } + } + } + } + if ($secretTokenCandidateLimitExceeded) { + Add-GraphKitPackagePrivacyFinding -Findings $Findings -FindingKeys $FindingKeys ` + -EntryName $EntryName -Encoding $Encoding ` + -Category 'protected-token candidate limit exceeded' ` + -Evidence ([string] $maximumSecretTokenCandidates) + } + foreach ($token in $secretTokenCandidates) { + $tokenDigest = (Get-GraphKitPackagePrivacyDigest -Value $token.ToLowerInvariant()).Substring(0, 32) + if ($secretTokenHashes.ContainsKey($tokenDigest)) { + Add-GraphKitPackagePrivacyFinding -Findings $Findings -FindingKeys $FindingKeys ` + -EntryName $EntryName -Encoding $Encoding ` + -Category ("internal identifier - {0}" -f $secretTokenHashes[$tokenDigest]) ` + -Evidence $token + } + } +} + +function ConvertFrom-GraphKitPackagePrintableAscii { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [byte[]] $Bytes + ) + + $text = [System.Text.StringBuilder]::new() + $run = [System.Text.StringBuilder]::new() + foreach ($value in $Bytes) { + if ($value -ge 0x20 -and $value -le 0x7e) { + $null = $run.Append([char] $value) + continue + } + if ($run.Length -ge 4) { + $null = $text.AppendLine($run.ToString()) + } + $null = $run.Clear() + } + if ($run.Length -ge 4) { + $null = $text.AppendLine($run.ToString()) + } + return $text.ToString() +} + +function Get-GraphKitPackagePrivacyAllowedGuidSet { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [guid] $ModuleGuid + ) + + $allowedGuids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($allowedGuid in @( + '00000000-0000-0000-0000-000000000000', + '00000000-0000-0000-0000-000000000001', + '00000003-0000-0000-c000-000000000000', + # Public solution metadata: the C# project-type id and the three stable + # GraphKit.Auth project ids. Keep this explicit so an unrelated GUID in + # project metadata still fails the privacy gate. + 'FAE04EC0-301F-11D3-BF4B-00C04F79EFBC', + 'A1A5DC18-8823-4AA1-BB0D-6F96E19E13C0', + 'B2B6ED29-9934-4BB2-CC1E-70A7F20F24D1', + 'C3C7FE3A-AA45-4CC3-DD2F-81B8A31035E2', + $ModuleGuid.ToString('D') + )) { + $null = $allowedGuids.Add($allowedGuid) + } + return ,$allowedGuids +} + +function Test-GraphKitAuthSourcePrivacy { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $SourceRoot, + + [Parameter(Mandatory)] + [guid] $ModuleGuid + ) + + if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) { + throw 'GraphKit.Auth privacy scan requires the authored source directory.' + } + + try { + $resolvedSourceRoot = (Resolve-Path -LiteralPath $SourceRoot -ErrorAction Stop).ProviderPath + $sourceCandidates = @(Get-ChildItem -LiteralPath $resolvedSourceRoot -Recurse -File -Force -ErrorAction Stop) + } + catch { + throw 'GraphKit.Auth privacy scan could not enumerate the authored source directory.' + } + + $findings = [System.Collections.Generic.List[object]]::new() + $findingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet -ModuleGuid $ModuleGuid + $sourcePathKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $normalizedSourcePathKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true) + $maximumSourceFileBytes = 32MB + $maximumSourceBytes = 128MB + [long] $scannedBytes = 0 + [int] $sourceFilesScanned = 0 + $authoredTextExtensions = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + foreach ($extension in @('.cs', '.csproj', '.props', '.sln', '.json')) { + $null = $authoredTextExtensions.Add($extension) + } + + foreach ($sourceFile in $sourceCandidates) { + $relativePath = [System.IO.Path]::GetRelativePath($resolvedSourceRoot, $sourceFile.FullName).Replace('\', '/') + $segments = @($relativePath -split '/') + if (@($segments | Where-Object { $_ -ieq 'bin' -or $_ -ieq 'obj' }).Count -gt 0 -or + -not $authoredTextExtensions.Contains([System.IO.Path]::GetExtension($sourceFile.Name))) { + continue + } + + $sourcePathDigest = Get-GraphKitPackagePrivacyDigest -Value $relativePath + $normalizedPath = $relativePath.Normalize([System.Text.NormalizationForm]::FormC) + if ($relativePath -cne $normalizedPath -or + -not $sourcePathKeys.Add($relativePath) -or + -not $normalizedSourcePathKeys.Add($normalizedPath)) { + throw "GraphKit.Auth privacy scan rejected an ambiguous source path (source sha256: $sourcePathDigest)." + } + + [long] $declaredLength = $sourceFile.Length + if ($declaredLength -lt 0 -or $declaredLength -gt $maximumSourceFileBytes) { + throw "GraphKit.Auth privacy scan rejected an oversized source file (source sha256: $sourcePathDigest)." + } + $scannedBytes += $declaredLength + if ($scannedBytes -gt $maximumSourceBytes) { + throw 'GraphKit.Auth privacy scan rejected a source tree whose bytes exceed the fixed bound.' + } + + try { + $bytes = [System.IO.File]::ReadAllBytes($sourceFile.FullName) + } + catch { + throw "GraphKit.Auth privacy scan failed closed while reading source (source sha256: $sourcePathDigest)." + } + if ($bytes.LongLength -ne $declaredLength) { + throw "GraphKit.Auth privacy scan rejected source whose byte count changed while reading (source sha256: $sourcePathDigest)." + } + + try { + $text = $strictUtf8.GetString($bytes) + } + catch { + throw "GraphKit.Auth privacy scan rejected an authored project file that is not strict UTF-8 (source sha256: $sourcePathDigest)." + } + if ($text.Length -gt 0 -and $text[0] -eq [char] 0xfeff) { + $text = $text.Substring(1) + } + + Test-GraphKitPackagePrivacyText -Text $relativePath -EntryName $relativePath -Encoding 'source-path' ` + -AllowedGuids $allowedGuids -Findings $findings -FindingKeys $findingKeys + Test-GraphKitPackagePrivacyText -Text $text -EntryName $relativePath -Encoding 'source-strict-utf8' ` + -AllowedGuids $allowedGuids -Findings $findings -FindingKeys $findingKeys + $sourceFilesScanned++ + } + + if ($sourceFilesScanned -eq 0) { + throw 'GraphKit.Auth privacy scan found no authored project files and failed closed.' + } + + return [pscustomobject] [ordered] @{ + Passed = $findings.Count -eq 0 + Findings = @($findings) + SourceFilesScanned = $sourceFilesScanned + BytesScanned = $scannedBytes + } +} + +function Read-GraphKitPackagePrivacyEntryBytesBounded { + [CmdletBinding()] + [OutputType([byte[]])] + param( + [Parameter(Mandatory)] + [System.IO.Stream] $EntryStream, + + [Parameter(Mandatory)] + [long] $DeclaredLength + ) + + $maximumEntryBytes = 32MB + if ($DeclaredLength -lt 0 -or $DeclaredLength -gt $maximumEntryBytes) { + throw 'Package privacy scan rejected an entry whose declared byte count is outside the fixed bound.' + } + + $memory = [System.IO.MemoryStream]::new() + try { + $buffer = [byte[]]::new(81920) + [long] $remainingWithSentinel = $DeclaredLength + 1 + while ($remainingWithSentinel -gt 0) { + $requested = [int] [Math]::Min([long] $buffer.Length, $remainingWithSentinel) + $read = $EntryStream.Read($buffer, 0, $requested) + if ($read -le 0) { + break + } + $memory.Write($buffer, 0, $read) + $remainingWithSentinel -= $read + } + + if ($memory.Length -ne $DeclaredLength) { + throw 'Package privacy scan rejected an entry whose actual byte count differs from its declared byte count.' + } + return ,$memory.ToArray() + } + finally { + $memory.Dispose() + } +} + +function Test-GraphKitPackagePrivacy { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [Parameter(Mandatory)] + [guid] $ModuleGuid + ) + + if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw 'Package privacy scan requires one existing verifier-owned package file.' + } + + $findings = [System.Collections.Generic.List[object]]::new() + $findingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet -ModuleGuid $ModuleGuid + + $textExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($textExtension in @( + '.psm1', '.psd1', '.ps1', '.ps1xml', '.txt', '.nuspec', '.xml', '.md', '.json', '.cs', '.psmdcp', '.rels' + )) { + $null = $textExtensions.Add($textExtension) + } + $strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true) + $lenientUtf8 = [System.Text.UTF8Encoding]::new($false, $false) + $maximumEntryBytes = 32MB + $maximumScannedBytes = 128MB + [long] $scannedBytes = 0 + [int] $textEntriesScanned = 0 + [int] $binaryEntriesScanned = 0 + + try { + $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).ProviderPath) + } + catch { + throw 'Package privacy scan could not open the verifier-owned package as a ZIP archive.' + } + + try { + $entryNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($entry in $archive.Entries) { + $entryName = [string] $entry.FullName + $entryDigest = Get-GraphKitPackagePrivacyDigest -Value $entryName + if ([string]::IsNullOrWhiteSpace($entryName) -or + [string]::IsNullOrEmpty($entry.Name) -or + -not $entryNames.Add($entryName)) { + throw "Package privacy scan rejected an ambiguous entry (entry sha256: $entryDigest)." + } + if ($entry.Length -lt 0 -or $entry.Length -gt $maximumEntryBytes) { + throw "Package privacy scan rejected an oversized entry (entry sha256: $entryDigest)." + } + $scannedBytes += [long] $entry.Length + if ($scannedBytes -gt $maximumScannedBytes) { + throw 'Package privacy scan rejected a package whose scannable bytes exceed the fixed bound.' + } + + Test-GraphKitPackagePrivacyText -Text $entryName -EntryName $entryName -Encoding 'entry-name' ` + -AllowedGuids $allowedGuids -Findings $findings -FindingKeys $findingKeys + + $extension = [System.IO.Path]::GetExtension($entry.Name) + if (-not $textExtensions.Contains($extension) -and $extension -ine '.dll') { + continue + } + + $entryStream = $entry.Open() + try { + $bytes = Read-GraphKitPackagePrivacyEntryBytesBounded ` + -EntryStream $entryStream -DeclaredLength ([long] $entry.Length) + } + catch { + throw "Package privacy scan failed closed while reading an entry (entry sha256: $entryDigest)." + } + finally { + $entryStream.Dispose() + } + + if ($extension -ine '.dll') { + try { + $text = $strictUtf8.GetString($bytes) + } + catch { + throw "Package privacy scan rejected a text entry that is not strict UTF-8 (entry sha256: $entryDigest)." + } + if ($text.Length -gt 0 -and $text[0] -eq [char] 0xfeff) { + $text = $text.Substring(1) + } + Test-GraphKitPackagePrivacyText -Text $text -EntryName $entryName -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids -Findings $findings -FindingKeys $findingKeys + $textEntriesScanned++ + continue + } + + $asciiText = ConvertFrom-GraphKitPackagePrintableAscii -Bytes $bytes + Test-GraphKitPackagePrivacyText -Text $asciiText -EntryName $entryName -Encoding 'binary-ascii' ` + -AllowedGuids $allowedGuids -Findings $findings -FindingKeys $findingKeys + + $utf8Text = $lenientUtf8.GetString($bytes) + Test-GraphKitPackagePrivacyText -Text $utf8Text -EntryName $entryName -Encoding 'binary-utf8' ` + -AllowedGuids $allowedGuids -Findings $findings -FindingKeys $findingKeys + + foreach ($offset in @(0, 1)) { + $byteCount = $bytes.Length - $offset + if ($byteCount -lt 2) { continue } + if (($byteCount % 2) -ne 0) { $byteCount-- } + $utf16Text = [System.Text.Encoding]::Unicode.GetString($bytes, $offset, $byteCount) + Test-GraphKitPackagePrivacyText -Text $utf16Text -EntryName $entryName -Encoding 'binary-utf16le' ` + -AllowedGuids $allowedGuids -Findings $findings -FindingKeys $findingKeys + } + $binaryEntriesScanned++ + } + } + finally { + $archive.Dispose() + } + + return [pscustomobject] [ordered] @{ + Passed = $findings.Count -eq 0 + Findings = @($findings) + TextEntriesScanned = $textEntriesScanned + BinaryEntriesScanned = $binaryEntriesScanned + BytesScanned = $scannedBytes + } +} diff --git a/source/Data/Operations/AndroidEnrollmentProfile.List.psd1 b/source/Data/Operations/AndroidEnrollmentProfile.List.psd1 index 66fdc6a..0a40f1d 100644 --- a/source/Data/Operations/AndroidEnrollmentProfile.List.psd1 +++ b/source/Data/Operations/AndroidEnrollmentProfile.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.Enrollment' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/AndroidManagedStoreAccountEnterpriseSettings.Get.psd1 b/source/Data/Operations/AndroidManagedStoreAccountEnterpriseSettings.Get.psd1 index 7a5b986..9166400 100644 --- a/source/Data/Operations/AndroidManagedStoreAccountEnterpriseSettings.Get.psd1 +++ b/source/Data/Operations/AndroidManagedStoreAccountEnterpriseSettings.Get.psd1 @@ -42,7 +42,7 @@ ResourceFamily = 'Intune.Connector' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/AppConfigurationPolicy.List.psd1 b/source/Data/Operations/AppConfigurationPolicy.List.psd1 index fe2b971..92b2013 100644 --- a/source/Data/Operations/AppConfigurationPolicy.List.psd1 +++ b/source/Data/Operations/AppConfigurationPolicy.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.MobileApp' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.Read.All' } ) diff --git a/source/Data/Operations/AppInstallSummaryReport.Get.psd1 b/source/Data/Operations/AppInstallSummaryReport.Get.psd1 index cffb1d9..ead06ee 100644 --- a/source/Data/Operations/AppInstallSummaryReport.Get.psd1 +++ b/source/Data/Operations/AppInstallSummaryReport.Get.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.Report' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.Read.All' } ) diff --git a/source/Data/Operations/AppProtectionPolicy.List.psd1 b/source/Data/Operations/AppProtectionPolicy.List.psd1 index 3d13b0b..2da1b73 100644 --- a/source/Data/Operations/AppProtectionPolicy.List.psd1 +++ b/source/Data/Operations/AppProtectionPolicy.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.MobileApp' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.Read.All' } ) diff --git a/source/Data/Operations/AppleEnrollmentProgramToken.List.psd1 b/source/Data/Operations/AppleEnrollmentProgramToken.List.psd1 index 696707d..3413b5b 100644 --- a/source/Data/Operations/AppleEnrollmentProgramToken.List.psd1 +++ b/source/Data/Operations/AppleEnrollmentProgramToken.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.Enrollment' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/ApplePushNotificationCertificate.Get.psd1 b/source/Data/Operations/ApplePushNotificationCertificate.Get.psd1 index 420ee34..a89dc9b 100644 --- a/source/Data/Operations/ApplePushNotificationCertificate.Get.psd1 +++ b/source/Data/Operations/ApplePushNotificationCertificate.Get.psd1 @@ -44,7 +44,7 @@ SensitiveProperties = @('certificate') - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/AppleVppToken.List.psd1 b/source/Data/Operations/AppleVppToken.List.psd1 index aaac3d8..4b5724f 100644 --- a/source/Data/Operations/AppleVppToken.List.psd1 +++ b/source/Data/Operations/AppleVppToken.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.MobileApp' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/AuthenticationMethodsPolicy.Get.psd1 b/source/Data/Operations/AuthenticationMethodsPolicy.Get.psd1 index 64dd74b..cf4576b 100644 --- a/source/Data/Operations/AuthenticationMethodsPolicy.Get.psd1 +++ b/source/Data/Operations/AuthenticationMethodsPolicy.Get.psd1 @@ -45,7 +45,7 @@ ResourceFamily = 'Directory.Policy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Policy.Read.All' } ) diff --git a/source/Data/Operations/AuthorizationPolicy.Get.psd1 b/source/Data/Operations/AuthorizationPolicy.Get.psd1 index 503c34c..e366f68 100644 --- a/source/Data/Operations/AuthorizationPolicy.Get.psd1 +++ b/source/Data/Operations/AuthorizationPolicy.Get.psd1 @@ -47,7 +47,7 @@ ResourceFamily = 'Directory.Policy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Policy.Read.All' } ) diff --git a/source/Data/Operations/AutopilotDevice.List.psd1 b/source/Data/Operations/AutopilotDevice.List.psd1 index b6b8bf8..f7b8745 100644 --- a/source/Data/Operations/AutopilotDevice.List.psd1 +++ b/source/Data/Operations/AutopilotDevice.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.Enrollment' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/CertificateConnector.List.psd1 b/source/Data/Operations/CertificateConnector.List.psd1 index ffb2f54..15d8716 100644 --- a/source/Data/Operations/CertificateConnector.List.psd1 +++ b/source/Data/Operations/CertificateConnector.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.Connector' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/ConditionalAccessPolicy.List.psd1 b/source/Data/Operations/ConditionalAccessPolicy.List.psd1 index 256a7f9..44dfc79 100644 --- a/source/Data/Operations/ConditionalAccessPolicy.List.psd1 +++ b/source/Data/Operations/ConditionalAccessPolicy.List.psd1 @@ -47,7 +47,7 @@ ResourceFamily = 'Directory.ConditionalAccess' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Policy.Read.All' } ) diff --git a/source/Data/Operations/ConfigurationConflict.List.psd1 b/source/Data/Operations/ConfigurationConflict.List.psd1 index fb910df..142d0a3 100644 --- a/source/Data/Operations/ConfigurationConflict.List.psd1 +++ b/source/Data/Operations/ConfigurationConflict.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.DeviceConfiguration' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/ConfigurationPolicy.AssignBeta.psd1 b/source/Data/Operations/ConfigurationPolicy.AssignBeta.psd1 index ccd411f..503a9ad 100644 --- a/source/Data/Operations/ConfigurationPolicy.AssignBeta.psd1 +++ b/source/Data/Operations/ConfigurationPolicy.AssignBeta.psd1 @@ -56,7 +56,7 @@ ResourceFamily = 'Intune.SettingsCatalog' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.ReadWrite.All' } ) diff --git a/source/Data/Operations/ConfigurationPolicy.ListBeta.psd1 b/source/Data/Operations/ConfigurationPolicy.ListBeta.psd1 index bc9caaf..40fd873 100644 --- a/source/Data/Operations/ConfigurationPolicy.ListBeta.psd1 +++ b/source/Data/Operations/ConfigurationPolicy.ListBeta.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.SettingsCatalog' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/ConfigurationPolicyAssignment.ListBeta.psd1 b/source/Data/Operations/ConfigurationPolicyAssignment.ListBeta.psd1 index 4e9de87..29e925e 100644 --- a/source/Data/Operations/ConfigurationPolicyAssignment.ListBeta.psd1 +++ b/source/Data/Operations/ConfigurationPolicyAssignment.ListBeta.psd1 @@ -56,7 +56,7 @@ ResourceFamily = 'Intune.SettingsCatalog' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/ConfigurationPolicySetting.ListBeta.psd1 b/source/Data/Operations/ConfigurationPolicySetting.ListBeta.psd1 index 1de40ca..ad5cf60 100644 --- a/source/Data/Operations/ConfigurationPolicySetting.ListBeta.psd1 +++ b/source/Data/Operations/ConfigurationPolicySetting.ListBeta.psd1 @@ -53,7 +53,7 @@ SensitiveProperties = @('settingInstance.groupSettingCollectionValue') - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/ConfigurationSettingDefinition.ListBeta.psd1 b/source/Data/Operations/ConfigurationSettingDefinition.ListBeta.psd1 index c8b9f5c..0d43403 100644 --- a/source/Data/Operations/ConfigurationSettingDefinition.ListBeta.psd1 +++ b/source/Data/Operations/ConfigurationSettingDefinition.ListBeta.psd1 @@ -84,7 +84,7 @@ ResourceFamily = 'Intune.SettingsCatalog' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/CrossTenantAccessPolicy.GetDefault.psd1 b/source/Data/Operations/CrossTenantAccessPolicy.GetDefault.psd1 index aef3c2b..49caee5 100644 --- a/source/Data/Operations/CrossTenantAccessPolicy.GetDefault.psd1 +++ b/source/Data/Operations/CrossTenantAccessPolicy.GetDefault.psd1 @@ -42,7 +42,7 @@ ResourceFamily = 'Directory.Policy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Policy.Read.All' } ) diff --git a/source/Data/Operations/DeviceCategory.List.psd1 b/source/Data/Operations/DeviceCategory.List.psd1 index 38a7b9e..81c31b3 100644 --- a/source/Data/Operations/DeviceCategory.List.psd1 +++ b/source/Data/Operations/DeviceCategory.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.DeviceCategory' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceCategory.ListBeta.psd1 b/source/Data/Operations/DeviceCategory.ListBeta.psd1 index 9943d12..374eb24 100644 --- a/source/Data/Operations/DeviceCategory.ListBeta.psd1 +++ b/source/Data/Operations/DeviceCategory.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.DeviceCategory' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceCompliancePolicy.Assign.psd1 b/source/Data/Operations/DeviceCompliancePolicy.Assign.psd1 index 603ec6b..71c2b74 100644 --- a/source/Data/Operations/DeviceCompliancePolicy.Assign.psd1 +++ b/source/Data/Operations/DeviceCompliancePolicy.Assign.psd1 @@ -57,7 +57,7 @@ ResourceFamily = 'Intune.DeviceCompliancePolicy' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.ReadWrite.All' } ) diff --git a/source/Data/Operations/DeviceCompliancePolicy.List.psd1 b/source/Data/Operations/DeviceCompliancePolicy.List.psd1 index 29d944b..f07b195 100644 --- a/source/Data/Operations/DeviceCompliancePolicy.List.psd1 +++ b/source/Data/Operations/DeviceCompliancePolicy.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.DeviceCompliancePolicy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceCompliancePolicy.ListBeta.psd1 b/source/Data/Operations/DeviceCompliancePolicy.ListBeta.psd1 index b1b13ba..c0bc3d5 100644 --- a/source/Data/Operations/DeviceCompliancePolicy.ListBeta.psd1 +++ b/source/Data/Operations/DeviceCompliancePolicy.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.DeviceCompliancePolicy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceCompliancePolicyAssignment.List.psd1 b/source/Data/Operations/DeviceCompliancePolicyAssignment.List.psd1 index de9e78c..a78d11c 100644 --- a/source/Data/Operations/DeviceCompliancePolicyAssignment.List.psd1 +++ b/source/Data/Operations/DeviceCompliancePolicyAssignment.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.DeviceCompliancePolicy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceConfiguration.Assign.psd1 b/source/Data/Operations/DeviceConfiguration.Assign.psd1 index b05c810..764e179 100644 --- a/source/Data/Operations/DeviceConfiguration.Assign.psd1 +++ b/source/Data/Operations/DeviceConfiguration.Assign.psd1 @@ -53,7 +53,7 @@ ResourceFamily = 'Intune.DeviceConfiguration' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.ReadWrite.All' } ) diff --git a/source/Data/Operations/DeviceConfiguration.List.psd1 b/source/Data/Operations/DeviceConfiguration.List.psd1 index 7739f20..eddecbd 100644 --- a/source/Data/Operations/DeviceConfiguration.List.psd1 +++ b/source/Data/Operations/DeviceConfiguration.List.psd1 @@ -38,7 +38,7 @@ ResourceFamily = 'Intune.DeviceConfiguration' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceConfiguration.ListBeta.psd1 b/source/Data/Operations/DeviceConfiguration.ListBeta.psd1 index ac7769d..d238d02 100644 --- a/source/Data/Operations/DeviceConfiguration.ListBeta.psd1 +++ b/source/Data/Operations/DeviceConfiguration.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.DeviceConfiguration' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceConfigurationAssignment.List.psd1 b/source/Data/Operations/DeviceConfigurationAssignment.List.psd1 index 0b1d7c9..7be6852 100644 --- a/source/Data/Operations/DeviceConfigurationAssignment.List.psd1 +++ b/source/Data/Operations/DeviceConfigurationAssignment.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.DeviceConfiguration' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceEnrollmentConfiguration.List.psd1 b/source/Data/Operations/DeviceEnrollmentConfiguration.List.psd1 index 7674c4b..d595a16 100644 --- a/source/Data/Operations/DeviceEnrollmentConfiguration.List.psd1 +++ b/source/Data/Operations/DeviceEnrollmentConfiguration.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.DeviceEnrollmentConfiguration' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/DeviceEnrollmentConfiguration.ListBeta.psd1 b/source/Data/Operations/DeviceEnrollmentConfiguration.ListBeta.psd1 index 92959b8..dd8bb52 100644 --- a/source/Data/Operations/DeviceEnrollmentConfiguration.ListBeta.psd1 +++ b/source/Data/Operations/DeviceEnrollmentConfiguration.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.DeviceEnrollmentConfiguration' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/DeviceManagementConfigurationPolicyTemplate.ListBeta.psd1 b/source/Data/Operations/DeviceManagementConfigurationPolicyTemplate.ListBeta.psd1 index 328d97c..44e6c84 100644 --- a/source/Data/Operations/DeviceManagementConfigurationPolicyTemplate.ListBeta.psd1 +++ b/source/Data/Operations/DeviceManagementConfigurationPolicyTemplate.ListBeta.psd1 @@ -48,7 +48,7 @@ ResourceFamily = 'Intune.SettingsCatalog' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceManagementIntent.ListBeta.psd1 b/source/Data/Operations/DeviceManagementIntent.ListBeta.psd1 index 4e98aff..94a942b 100644 --- a/source/Data/Operations/DeviceManagementIntent.ListBeta.psd1 +++ b/source/Data/Operations/DeviceManagementIntent.ListBeta.psd1 @@ -47,7 +47,7 @@ ResourceFamily = 'Intune.SettingsCatalog' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceManagementRoleAssignment.List.psd1 b/source/Data/Operations/DeviceManagementRoleAssignment.List.psd1 index ea1e280..89d412a 100644 --- a/source/Data/Operations/DeviceManagementRoleAssignment.List.psd1 +++ b/source/Data/Operations/DeviceManagementRoleAssignment.List.psd1 @@ -43,7 +43,7 @@ ResourceFamily = 'Intune.ServiceConfig' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementRBAC.Read.All' } ) diff --git a/source/Data/Operations/DeviceManagementRoleDefinition.List.psd1 b/source/Data/Operations/DeviceManagementRoleDefinition.List.psd1 index 3ed74ac..a217e9a 100644 --- a/source/Data/Operations/DeviceManagementRoleDefinition.List.psd1 +++ b/source/Data/Operations/DeviceManagementRoleDefinition.List.psd1 @@ -42,7 +42,7 @@ ResourceFamily = 'Intune.ServiceConfig' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementRBAC.Read.All' } ) diff --git a/source/Data/Operations/DeviceManagementScript.List.psd1 b/source/Data/Operations/DeviceManagementScript.List.psd1 index f00ca6e..62b9eb6 100644 --- a/source/Data/Operations/DeviceManagementScript.List.psd1 +++ b/source/Data/Operations/DeviceManagementScript.List.psd1 @@ -55,7 +55,7 @@ SensitiveProperties = @('scriptContent') - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementScripts.Read.All' } ) diff --git a/source/Data/Operations/DeviceManagementTemplate.ListBeta.psd1 b/source/Data/Operations/DeviceManagementTemplate.ListBeta.psd1 index 46be977..740f3f5 100644 --- a/source/Data/Operations/DeviceManagementTemplate.ListBeta.psd1 +++ b/source/Data/Operations/DeviceManagementTemplate.ListBeta.psd1 @@ -47,7 +47,7 @@ ResourceFamily = 'Intune.SettingsCatalog' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/DeviceManagementUnifiedRoleAssignment.ListBeta.psd1 b/source/Data/Operations/DeviceManagementUnifiedRoleAssignment.ListBeta.psd1 index c3b555a..96fd271 100644 --- a/source/Data/Operations/DeviceManagementUnifiedRoleAssignment.ListBeta.psd1 +++ b/source/Data/Operations/DeviceManagementUnifiedRoleAssignment.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.RBAC' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementRBAC.Read.All' } ) diff --git a/source/Data/Operations/DeviceReport.Export.psd1 b/source/Data/Operations/DeviceReport.Export.psd1 index e9e9350..5b8ec5b 100644 --- a/source/Data/Operations/DeviceReport.Export.psd1 +++ b/source/Data/Operations/DeviceReport.Export.psd1 @@ -38,7 +38,7 @@ ResourceFamily = 'Intune.Reporting' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.Read.All' } ) diff --git a/source/Data/Operations/DirectoryRoleAssignment.List.psd1 b/source/Data/Operations/DirectoryRoleAssignment.List.psd1 index 3a94333..e2a7971 100644 --- a/source/Data/Operations/DirectoryRoleAssignment.List.psd1 +++ b/source/Data/Operations/DirectoryRoleAssignment.List.psd1 @@ -45,7 +45,7 @@ ResourceFamily = 'Directory.RoleManagement' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'RoleManagement.Read.Directory' } ) diff --git a/source/Data/Operations/DirectoryRoleDefinition.List.psd1 b/source/Data/Operations/DirectoryRoleDefinition.List.psd1 index 5c14845..fc16699 100644 --- a/source/Data/Operations/DirectoryRoleDefinition.List.psd1 +++ b/source/Data/Operations/DirectoryRoleDefinition.List.psd1 @@ -47,7 +47,7 @@ ResourceFamily = 'Directory.RoleManagement' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'RoleManagement.Read.Directory' } ) diff --git a/source/Data/Operations/DirectoryRoleDefinition.ListBeta.psd1 b/source/Data/Operations/DirectoryRoleDefinition.ListBeta.psd1 index e5bd0d0..1e38cfa 100644 --- a/source/Data/Operations/DirectoryRoleDefinition.ListBeta.psd1 +++ b/source/Data/Operations/DirectoryRoleDefinition.ListBeta.psd1 @@ -45,7 +45,7 @@ ResourceFamily = 'Directory.RoleManagement' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'RoleManagement.Read.Directory' } ) diff --git a/source/Data/Operations/DirectorySetting.List.psd1 b/source/Data/Operations/DirectorySetting.List.psd1 index d0d9425..7810c38 100644 --- a/source/Data/Operations/DirectorySetting.List.psd1 +++ b/source/Data/Operations/DirectorySetting.List.psd1 @@ -53,7 +53,7 @@ ResourceFamily = 'Directory.Organization' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Directory.Read.All' } ) diff --git a/source/Data/Operations/DirectorySettingTemplate.List.psd1 b/source/Data/Operations/DirectorySettingTemplate.List.psd1 index c5a70d5..a7da32c 100644 --- a/source/Data/Operations/DirectorySettingTemplate.List.psd1 +++ b/source/Data/Operations/DirectorySettingTemplate.List.psd1 @@ -53,7 +53,7 @@ ResourceFamily = 'Directory.Organization' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Directory.Read.All' } ) diff --git a/source/Data/Operations/Domain.List.psd1 b/source/Data/Operations/Domain.List.psd1 index 1e6f2af..b8a55b0 100644 --- a/source/Data/Operations/Domain.List.psd1 +++ b/source/Data/Operations/Domain.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Directory.Domain' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Domain.Read.All' } ) diff --git a/source/Data/Operations/DomainConnector.List.psd1 b/source/Data/Operations/DomainConnector.List.psd1 index f02151f..c4ada45 100644 --- a/source/Data/Operations/DomainConnector.List.psd1 +++ b/source/Data/Operations/DomainConnector.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.Connector' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/EntraDevice.List.psd1 b/source/Data/Operations/EntraDevice.List.psd1 index 401781d..c58607f 100644 --- a/source/Data/Operations/EntraDevice.List.psd1 +++ b/source/Data/Operations/EntraDevice.List.psd1 @@ -42,7 +42,7 @@ ResourceFamily = 'Directory.Device' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Device.Read.All' } ) diff --git a/source/Data/Operations/EntraDevice.ListBeta.psd1 b/source/Data/Operations/EntraDevice.ListBeta.psd1 index 6d3024c..205dea9 100644 --- a/source/Data/Operations/EntraDevice.ListBeta.psd1 +++ b/source/Data/Operations/EntraDevice.ListBeta.psd1 @@ -44,7 +44,7 @@ ResourceFamily = 'Directory.Device' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Device.Read.All' } ) diff --git a/source/Data/Operations/Group.Get.psd1 b/source/Data/Operations/Group.Get.psd1 index 511e6e3..09f6322 100644 --- a/source/Data/Operations/Group.Get.psd1 +++ b/source/Data/Operations/Group.Get.psd1 @@ -1,9 +1,9 @@ <# Operation descriptor - data only. Loaded with Import-PowerShellDataFile. - A single group's protection flags for Intune RBAC group protection (TP.INT.0013). - Distinct from Group.List, which returns the collection without these select-only - properties. + A single group's identity, description, and protection flags for Intune assignment + reporting and RBAC group protection (TP.INT.0013). Distinct from Group.List, which + returns the collection without these select-only properties. $select is part of this operation's identity and lives in the PathTemplate. isAssignableToRole and isManagementRestricted are omitted unless selected. A Get @@ -23,7 +23,7 @@ BetaReason = $null Method = 'GET' - PathTemplate = '/groups/{id}?$select=id,displayName,isAssignableToRole,isManagementRestricted' + PathTemplate = '/groups/{id}?$select=id,displayName,description,isAssignableToRole,isManagementRestricted' RequestBodyKind = $null ResponseKind = 'Json' PagingStrategy = 'None' @@ -47,7 +47,7 @@ ResourceFamily = 'Directory.Group' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Group.Read.All' } ) diff --git a/source/Data/Operations/Group.List.psd1 b/source/Data/Operations/Group.List.psd1 index b1e2dfd..78a0af8 100644 --- a/source/Data/Operations/Group.List.psd1 +++ b/source/Data/Operations/Group.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Directory.Group' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Group.Read.All' } ) diff --git a/source/Data/Operations/Group.ListBeta.psd1 b/source/Data/Operations/Group.ListBeta.psd1 index 2aa9051..2d8f420 100644 --- a/source/Data/Operations/Group.ListBeta.psd1 +++ b/source/Data/Operations/Group.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Directory.Group' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Group.Read.All' } ) diff --git a/source/Data/Operations/GroupMember.List.psd1 b/source/Data/Operations/GroupMember.List.psd1 index 00fbc65..ae637a1 100644 --- a/source/Data/Operations/GroupMember.List.psd1 +++ b/source/Data/Operations/GroupMember.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Directory.Group' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Group.Read.All' } ) diff --git a/source/Data/Operations/GroupPolicyConfiguration.ListBeta.psd1 b/source/Data/Operations/GroupPolicyConfiguration.ListBeta.psd1 index b0819d7..a23db1e 100644 --- a/source/Data/Operations/GroupPolicyConfiguration.ListBeta.psd1 +++ b/source/Data/Operations/GroupPolicyConfiguration.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.GroupPolicy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/GroupPolicyDefinitionValue.ListBeta.psd1 b/source/Data/Operations/GroupPolicyDefinitionValue.ListBeta.psd1 index c304d5a..60949cf 100644 --- a/source/Data/Operations/GroupPolicyDefinitionValue.ListBeta.psd1 +++ b/source/Data/Operations/GroupPolicyDefinitionValue.ListBeta.psd1 @@ -65,7 +65,7 @@ ResourceFamily = 'Intune.GroupPolicy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/GroupPolicyPresentationValue.ListBeta.psd1 b/source/Data/Operations/GroupPolicyPresentationValue.ListBeta.psd1 index c330031..174c46b 100644 --- a/source/Data/Operations/GroupPolicyPresentationValue.ListBeta.psd1 +++ b/source/Data/Operations/GroupPolicyPresentationValue.ListBeta.psd1 @@ -73,7 +73,7 @@ ResourceFamily = 'Intune.GroupPolicy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/IntuneBrandingProfile.List.psd1 b/source/Data/Operations/IntuneBrandingProfile.List.psd1 index 2146a2d..efbda3f 100644 --- a/source/Data/Operations/IntuneBrandingProfile.List.psd1 +++ b/source/Data/Operations/IntuneBrandingProfile.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.ServiceConfig' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/ManagedDevice.Delete.psd1 b/source/Data/Operations/ManagedDevice.Delete.psd1 index 28ed593..61852ea 100644 --- a/source/Data/Operations/ManagedDevice.Delete.psd1 +++ b/source/Data/Operations/ManagedDevice.Delete.psd1 @@ -48,7 +48,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.PrivilegedOperations.All' } ) diff --git a/source/Data/Operations/ManagedDevice.Get.psd1 b/source/Data/Operations/ManagedDevice.Get.psd1 index f05234c..24f1a6e 100644 --- a/source/Data/Operations/ManagedDevice.Get.psd1 +++ b/source/Data/Operations/ManagedDevice.Get.psd1 @@ -43,7 +43,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.Read.All' } ) diff --git a/source/Data/Operations/ManagedDevice.List.psd1 b/source/Data/Operations/ManagedDevice.List.psd1 index a4d3fff..213ee20 100644 --- a/source/Data/Operations/ManagedDevice.List.psd1 +++ b/source/Data/Operations/ManagedDevice.List.psd1 @@ -38,7 +38,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.Read.All' } ) diff --git a/source/Data/Operations/ManagedDevice.ListBeta.psd1 b/source/Data/Operations/ManagedDevice.ListBeta.psd1 index d63e28a..398e4b0 100644 --- a/source/Data/Operations/ManagedDevice.ListBeta.psd1 +++ b/source/Data/Operations/ManagedDevice.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.Read.All' } ) diff --git a/source/Data/Operations/ManagedDevice.Retire.psd1 b/source/Data/Operations/ManagedDevice.Retire.psd1 index ee067b6..a4deace 100644 --- a/source/Data/Operations/ManagedDevice.Retire.psd1 +++ b/source/Data/Operations/ManagedDevice.Retire.psd1 @@ -55,7 +55,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.PrivilegedOperations.All' } ) diff --git a/source/Data/Operations/ManagedDevice.SyncDevice.psd1 b/source/Data/Operations/ManagedDevice.SyncDevice.psd1 index 9799550..5fdbfe1 100644 --- a/source/Data/Operations/ManagedDevice.SyncDevice.psd1 +++ b/source/Data/Operations/ManagedDevice.SyncDevice.psd1 @@ -60,7 +60,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.PrivilegedOperations.All' } ) diff --git a/source/Data/Operations/ManagedDevice.Wipe.psd1 b/source/Data/Operations/ManagedDevice.Wipe.psd1 index bc04bdb..b455e21 100644 --- a/source/Data/Operations/ManagedDevice.Wipe.psd1 +++ b/source/Data/Operations/ManagedDevice.Wipe.psd1 @@ -62,7 +62,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.PrivilegedOperations.All' } ) diff --git a/source/Data/Operations/ManagedDeviceCleanupRule.ListBeta.psd1 b/source/Data/Operations/ManagedDeviceCleanupRule.ListBeta.psd1 index 59ef6e7..3e5c8dd 100644 --- a/source/Data/Operations/ManagedDeviceCleanupRule.ListBeta.psd1 +++ b/source/Data/Operations/ManagedDeviceCleanupRule.ListBeta.psd1 @@ -48,7 +48,7 @@ ResourceFamily = 'Intune.ManagedDevices' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.Read.All' } ) diff --git a/source/Data/Operations/ManagedDeviceSetting.Get.psd1 b/source/Data/Operations/ManagedDeviceSetting.Get.psd1 index 2c07629..b29b965 100644 --- a/source/Data/Operations/ManagedDeviceSetting.Get.psd1 +++ b/source/Data/Operations/ManagedDeviceSetting.Get.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.ServiceConfig' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/MobileApp.Assign.psd1 b/source/Data/Operations/MobileApp.Assign.psd1 index a88fdf1..13d1769 100644 --- a/source/Data/Operations/MobileApp.Assign.psd1 +++ b/source/Data/Operations/MobileApp.Assign.psd1 @@ -44,7 +44,7 @@ ResourceFamily = 'Intune.MobileApps' ThrottleClass = 'Write' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.ReadWrite.All' } ) diff --git a/source/Data/Operations/MobileApp.List.psd1 b/source/Data/Operations/MobileApp.List.psd1 index e657131..00c92e2 100644 --- a/source/Data/Operations/MobileApp.List.psd1 +++ b/source/Data/Operations/MobileApp.List.psd1 @@ -38,7 +38,7 @@ ResourceFamily = 'Intune.MobileApps' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.Read.All' } ) diff --git a/source/Data/Operations/MobileApp.ListBeta.psd1 b/source/Data/Operations/MobileApp.ListBeta.psd1 index 9bdb2fe..309354f 100644 --- a/source/Data/Operations/MobileApp.ListBeta.psd1 +++ b/source/Data/Operations/MobileApp.ListBeta.psd1 @@ -50,7 +50,7 @@ ResourceFamily = 'Intune.MobileApps' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.Read.All' } ) diff --git a/source/Data/Operations/MobileAppAssignment.List.psd1 b/source/Data/Operations/MobileAppAssignment.List.psd1 index 061b854..ff78640 100644 --- a/source/Data/Operations/MobileAppAssignment.List.psd1 +++ b/source/Data/Operations/MobileAppAssignment.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Intune.MobileApp' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.Read.All' } ) diff --git a/source/Data/Operations/MobileAppCategory.List.psd1 b/source/Data/Operations/MobileAppCategory.List.psd1 index ef7e657..e3a100d 100644 --- a/source/Data/Operations/MobileAppCategory.List.psd1 +++ b/source/Data/Operations/MobileAppCategory.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.MobileApp' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementApps.Read.All' } ) diff --git a/source/Data/Operations/MobileThreatDefenseConnector.List.psd1 b/source/Data/Operations/MobileThreatDefenseConnector.List.psd1 index cab59a5..73b0373 100644 --- a/source/Data/Operations/MobileThreatDefenseConnector.List.psd1 +++ b/source/Data/Operations/MobileThreatDefenseConnector.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.Connector' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/NamedLocation.List.psd1 b/source/Data/Operations/NamedLocation.List.psd1 index b4369ea..a687082 100644 --- a/source/Data/Operations/NamedLocation.List.psd1 +++ b/source/Data/Operations/NamedLocation.List.psd1 @@ -45,7 +45,7 @@ ResourceFamily = 'Directory.ConditionalAccess' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Policy.Read.All' } ) diff --git a/source/Data/Operations/OperationApprovalPolicy.List.psd1 b/source/Data/Operations/OperationApprovalPolicy.List.psd1 index b1f84cf..bc6c096 100644 --- a/source/Data/Operations/OperationApprovalPolicy.List.psd1 +++ b/source/Data/Operations/OperationApprovalPolicy.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.ServiceConfig' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementRBAC.Read.All' } ) diff --git a/source/Data/Operations/Organization.GetMdmAuthority.psd1 b/source/Data/Operations/Organization.GetMdmAuthority.psd1 index 74a81e1..86cc8f7 100644 --- a/source/Data/Operations/Organization.GetMdmAuthority.psd1 +++ b/source/Data/Operations/Organization.GetMdmAuthority.psd1 @@ -59,7 +59,7 @@ ResourceFamily = 'Directory.Organization' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Organization.Read.All' } ) diff --git a/source/Data/Operations/Organization.List.psd1 b/source/Data/Operations/Organization.List.psd1 index 6c93cc0..4d6943b 100644 --- a/source/Data/Operations/Organization.List.psd1 +++ b/source/Data/Operations/Organization.List.psd1 @@ -43,7 +43,7 @@ ResourceFamily = 'Directory.Organization' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Organization.Read.All' } ) diff --git a/source/Data/Operations/Organization.ListBeta.psd1 b/source/Data/Operations/Organization.ListBeta.psd1 index 59b0928..a0020cb 100644 --- a/source/Data/Operations/Organization.ListBeta.psd1 +++ b/source/Data/Operations/Organization.ListBeta.psd1 @@ -45,7 +45,7 @@ ResourceFamily = 'Directory.Organization' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Organization.Read.All' } ) diff --git a/source/Data/Operations/RoleAssignmentScheduleInstance.List.psd1 b/source/Data/Operations/RoleAssignmentScheduleInstance.List.psd1 index 552af64..ad1f938 100644 --- a/source/Data/Operations/RoleAssignmentScheduleInstance.List.psd1 +++ b/source/Data/Operations/RoleAssignmentScheduleInstance.List.psd1 @@ -42,7 +42,7 @@ ResourceFamily = 'Directory.RoleManagement' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'RoleAssignmentSchedule.Read.Directory' } ) diff --git a/source/Data/Operations/RoleEligibilityScheduleInstance.List.psd1 b/source/Data/Operations/RoleEligibilityScheduleInstance.List.psd1 index 5ac1bd6..5d4282c 100644 --- a/source/Data/Operations/RoleEligibilityScheduleInstance.List.psd1 +++ b/source/Data/Operations/RoleEligibilityScheduleInstance.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Directory.RoleManagement' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'RoleEligibilitySchedule.Read.Directory' } ) diff --git a/source/Data/Operations/SecurityDefaultsPolicy.Get.psd1 b/source/Data/Operations/SecurityDefaultsPolicy.Get.psd1 index 48724e1..91e3717 100644 --- a/source/Data/Operations/SecurityDefaultsPolicy.Get.psd1 +++ b/source/Data/Operations/SecurityDefaultsPolicy.Get.psd1 @@ -47,7 +47,7 @@ ResourceFamily = 'Directory.Policy' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Policy.Read.All' } ) diff --git a/source/Data/Operations/ServicePrincipal.List.psd1 b/source/Data/Operations/ServicePrincipal.List.psd1 index 0de66dc..9affc11 100644 --- a/source/Data/Operations/ServicePrincipal.List.psd1 +++ b/source/Data/Operations/ServicePrincipal.List.psd1 @@ -61,7 +61,7 @@ 'keyCredentials.customKeyIdentifier' ) - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Application.Read.All' } ) diff --git a/source/Data/Operations/SubscribedSku.List.psd1 b/source/Data/Operations/SubscribedSku.List.psd1 index 74a5c4c..d778f13 100644 --- a/source/Data/Operations/SubscribedSku.List.psd1 +++ b/source/Data/Operations/SubscribedSku.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Directory.Organization' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'Organization.Read.All' } ) diff --git a/source/Data/Operations/User.List.psd1 b/source/Data/Operations/User.List.psd1 index 7d4adfc..7e0074b 100644 --- a/source/Data/Operations/User.List.psd1 +++ b/source/Data/Operations/User.List.psd1 @@ -40,7 +40,7 @@ ResourceFamily = 'Directory.User' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'User.Read.All' } ) diff --git a/source/Data/Operations/WindowsAutopilotDeploymentProfile.List.psd1 b/source/Data/Operations/WindowsAutopilotDeploymentProfile.List.psd1 index 44b9229..8cbb4e2 100644 --- a/source/Data/Operations/WindowsAutopilotDeploymentProfile.List.psd1 +++ b/source/Data/Operations/WindowsAutopilotDeploymentProfile.List.psd1 @@ -47,7 +47,7 @@ ResourceFamily = 'Intune.Enrollment' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } ) diff --git a/source/Data/Operations/WindowsFeatureUpdateProfile.List.psd1 b/source/Data/Operations/WindowsFeatureUpdateProfile.List.psd1 index 3e09c98..c44f15b 100644 --- a/source/Data/Operations/WindowsFeatureUpdateProfile.List.psd1 +++ b/source/Data/Operations/WindowsFeatureUpdateProfile.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.Updates' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/Data/Operations/WindowsUpdateCatalogItem.List.psd1 b/source/Data/Operations/WindowsUpdateCatalogItem.List.psd1 index 59b2299..6fe0b28 100644 --- a/source/Data/Operations/WindowsUpdateCatalogItem.List.psd1 +++ b/source/Data/Operations/WindowsUpdateCatalogItem.List.psd1 @@ -41,7 +41,7 @@ ResourceFamily = 'Intune.WindowsUpdate' ThrottleClass = 'Read' - SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') RequiredPermissions = @( @{ Type = 'Application'; Value = 'DeviceManagementConfiguration.Read.All' } ) diff --git a/source/GraphKit.psd1 b/source/GraphKit.psd1 index 8a3a5c4..b6737fb 100644 --- a/source/GraphKit.psd1 +++ b/source/GraphKit.psd1 @@ -12,7 +12,7 @@ RootModule = 'GraphKit.psm1' # Version number of this module. -ModuleVersion = '0.3.0' +ModuleVersion = '0.4.0' # Supported PSEditions # CompatiblePSEditions = @() @@ -57,7 +57,7 @@ RequiredModules = @( ) # Assemblies that must be loaded prior to importing this module -# RequiredAssemblies = @() +RequiredAssemblies = @() # Script files (.ps1) that are run in the caller's environment prior to importing this module. # ScriptsToProcess = @() @@ -129,39 +129,15 @@ PrivateData = @{ # ReleaseNotes of this module ReleaseNotes = @' -0.3.0 - -Integrated next package. The published PSGallery 0.2.2 artifact remains immutable. - -CHANGED -- Microsoft.PowerShell.SecretManagement 1.1.2+ is resolved only at first vault use. - Non-vault import, managed identity, help, and catalog inspection no longer require it. -- Vault commands are module-qualified and the boundary rejects an unavailable or too-old - SecretManagement module instead of accepting unrelated same-named functions. -- Install-GraphKitPinned installs only hard Microsoft.Graph.Authentication by default for - 0.3.0, offers -InstallSecretManagement for vault hosts, and preserves automatic - SecretManagement installation for immutable 0.2.2 pins. - -ADDED AND LIVE-VERIFIED 2026-08-29 -- DeviceManagementUnifiedRoleAssignment.ListBeta with required roleDefinition/principals - expansion and DeviceManagementRBAC.Read.All. -- DeviceManagementTemplate.ListBeta, DeviceManagementConfigurationPolicyTemplate.ListBeta, - and DeviceManagementIntent.ListBeta for legacy baseline and current Settings Catalog - template/version, assignment, lifecycle, and deprecation interpretation. -- ManagedDeviceCleanupRule.ListBeta, the documented per-platform collection, replacing the - obsolete undocumented managedDeviceCleanupSettings singleton. - -VERIFICATION CORRECTION -- NamedLocation.List is positively proven app-only with Policy.Read.All. The narrower - Policy.Read.ConditionalAccess scope remains insufficient. -- DeviceManagementScript.List remains scope-gated: the service named - DeviceManagementScripts.Read.All in its 403, but no successful live response is claimed. +0.4.0 + +R8 successor prerelease train seed. The immutable public 0.3.0 package remains unchanged. Requires PowerShell 7.4+. '@ # Prerelease string of this module - Prerelease = '' + Prerelease = 'r8' # Flag to indicate whether the module requires explicit user acceptance for install/update/save # RequireLicenseAcceptance = $false @@ -169,8 +145,8 @@ Requires PowerShell 7.4+. # External dependent modules of this module # Do not mark Microsoft.Graph.Authentication external: Publish-Module omits external # modules from the package nuspec, leaving a clean installer with no MSAL dependency - # metadata. SecretManagement is intentionally not a RequiredModule; vault-backed paths - # validate it on demand so non-vault flows do not install or load it. + # metadata. SecretManagement is optional: vault-backed paths discover and import the + # tested minimum on first credential resolution, while non-vault paths never load it. # ExternalModuleDependencies = @() } # End of PSData hashtable diff --git a/source/Private/Confirm-GraphTenantBinding.ps1 b/source/Private/Confirm-GraphTenantBinding.ps1 index a3ce105..860b9cd 100644 --- a/source/Private/Confirm-GraphTenantBinding.ps1 +++ b/source/Private/Confirm-GraphTenantBinding.ps1 @@ -7,8 +7,9 @@ result, telemetry record, and evidence page was stamped Tenant A. This function performs the actual proof: a GET /v1.0/organization issued with the token itself through the normal GraphKit pipeline (Invoke-GraphRetry), using - a synthetic read descriptor. The read is a GET, so Invoke-GraphRetry never - sets VerifyTenantBinding on it - the proof cannot recurse into another proof. + a synthetic AllowUnverifiedRead descriptor. That explicit identity requirement + exempts the proof request from recursively requiring another proof while every + ordinary descriptor that requires Verified identity remains fail-closed. The proof is bound to the CURRENT token result via its TokenFingerprint and CredentialGeneration. A successful proof is cached; a cache hit skips the @@ -39,6 +40,17 @@ function Get-GraphTenantBindingKey { [guid] $TenantId ) + if ([string]::IsNullOrWhiteSpace($Fingerprint)) { + throw [System.InvalidOperationException]::new( + 'Tenant binding requires a non-empty TokenFingerprint.' + ) + } + if ([string]::IsNullOrWhiteSpace($Generation)) { + throw [System.InvalidOperationException]::new( + 'Tenant binding requires a non-empty CredentialGeneration.' + ) + } + return '{0}|{1}|{2}' -f ([string] $Fingerprint), ([string] $Generation), $TenantId.ToString() } @@ -58,10 +70,65 @@ function Test-GraphTenantBinding { [guid] $TenantId ) + # An incomplete tuple is never a cache identity. Return false here so a + # provider-supplied tenant claim cannot turn missing metadata into the + # shared key "||tenant"; Confirm-GraphTenantBinding owns the diagnostic. + if ([string]::IsNullOrWhiteSpace($Fingerprint) -or + [string]::IsNullOrWhiteSpace($Generation)) { + return $false + } + $key = Get-GraphTenantBindingKey -Fingerprint $Fingerprint -Generation $Generation -TenantId $TenantId return ($script:GraphTenantBindingCache.ContainsKey($key) -and $script:GraphTenantBindingCache[$key] -eq $true) } +function New-GraphTenantBindingDeadlineException { + [CmdletBinding()] + [OutputType([System.TimeoutException])] + param() + + $exception = [System.TimeoutException]::new( + 'Tenant proof deadline expired before the token could be verified.' + ) + $exception.Data['GraphKit.TenantBindingDeadlineExpired'] = $true + return $exception +} + +<# + Private: expose one already-acquired result through the token-source duck + contract for the /organization proof. The source is deliberately + non-refreshable: proving a refreshed or independently reacquired bearer and + then caching that proof against the caller's earlier fingerprint would break + the exact-token binding invariant. +#> +function New-GraphPinnedTokenSource { + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true)] + [object] $TokenResult + ) + + $source = [pscustomobject] @{ + CanRefresh = $false + AuthMode = 'PinnedTokenResult' + Audience = $null + ClientId = $null + CredentialGeneration = [string] $TokenResult.CredentialGeneration + Result = $TokenResult + } + + $source | Add-Member -MemberType ScriptMethod -Name Acquire -Value { + param([bool] $forceRefresh, $cancellationToken) + if ($forceRefresh) { + throw [System.InvalidOperationException]::new('A pinned token result cannot be refreshed during tenant proof.') + } + return $this.Result + } + + return $source +} + function Confirm-GraphTenantBinding { [CmdletBinding()] param( @@ -71,12 +138,34 @@ function Confirm-GraphTenantBinding { [Parameter(Mandatory = $true)] [object] $TokenResult, + [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None, + + [TimeSpan] $RemainingDeadline = [TimeSpan]::FromSeconds(300), + [scriptblock] $ProofTransport, [hashtable] $ProofCache ) $targetTenant = $Context.TenantId + $fingerprintProperty = $TokenResult.PSObject.Properties['TokenFingerprint'] + $generationProperty = $TokenResult.PSObject.Properties['CredentialGeneration'] + $fingerprint = if ($null -eq $fingerprintProperty) { $null } else { [string] $fingerprintProperty.Value } + $generation = if ($null -eq $generationProperty) { $null } else { [string] $generationProperty.Value } + + # Validate the complete cache identity before even selecting or consulting + # a cache. Empty metadata would otherwise collapse distinct bearer tokens + # onto the same "||tenant" entry and let the second token inherit proof. + if ([string]::IsNullOrWhiteSpace($fingerprint)) { + throw [System.InvalidOperationException]::new( + 'Tenant binding cannot proceed without a non-empty TokenFingerprint.' + ) + } + if ([string]::IsNullOrWhiteSpace($generation)) { + throw [System.InvalidOperationException]::new( + 'Tenant binding cannot proceed without a non-empty CredentialGeneration.' + ) + } # The binding decision is made from the cache (fingerprint + generation + # tenant), never from a VerifiedTenantId the result already carries: a @@ -87,8 +176,8 @@ function Confirm-GraphTenantBinding { } $cacheKey = Get-GraphTenantBindingKey ` - -Fingerprint ([string] $TokenResult.TokenFingerprint) ` - -Generation ([string] $TokenResult.CredentialGeneration) ` + -Fingerprint $fingerprint ` + -Generation $generation ` -TenantId $targetTenant if ($cache.ContainsKey($cacheKey) -and $cache[$cacheKey] -eq $true) { @@ -97,6 +186,14 @@ function Confirm-GraphTenantBinding { return } + # Caller/module cancellation wins when it coincides with budget exhaustion. + # A pure proof-budget cancellation is converted back to DeadlineExpired by + # the sender, but a caller-signalled token must retain Cancelled semantics. + $CancellationToken.ThrowIfCancellationRequested() + if ($RemainingDeadline -le [TimeSpan]::Zero) { + throw (New-GraphTenantBindingDeadlineException) + } + # ---- Proof read: GET /v1.0/organization with the token itself ---- $proofUri = [uri] ('{0}/v1.0/organization' -f $Context.GraphBaseUri.AbsoluteUri.TrimEnd('/')) @@ -105,7 +202,7 @@ function Confirm-GraphTenantBinding { ReplayPolicy = 'Safe' ThrottleClass = 'Read' ResourceFamily = 'Graph.Directory' - IdentityRequirement = 'Verified' + IdentityRequirement = 'AllowUnverifiedRead' ApiVersion = 'v1.0' Condition = $null Reconciliation = $null @@ -114,15 +211,64 @@ function Confirm-GraphTenantBinding { $transport = $ProofTransport if ($null -eq $transport) { $transport = { - param($Context, $Descriptor, $Uri) + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) + $deadlineSecondsValue = [Math]::Min( + 86400.0, + [Math]::Ceiling(([TimeSpan] $RemainingDeadline).TotalSeconds)) + $deadlineSeconds = [int] $deadlineSecondsValue + if ($deadlineSeconds -lt 1) { + throw (New-GraphTenantBindingDeadlineException) + } Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri $Uri -Method GET ` - -Headers @{} -Body $null -CancellationToken ([System.Threading.CancellationToken]::None) + -Headers @{} -Body $null -CancellationToken $CancellationToken ` + -DeadlineSeconds $deadlineSeconds } } - $envelope = & $transport -Context $Context -Descriptor $proofDescriptor -Uri $proofUri + # Invoke the normal retry/sender pipeline with a source pinned to this exact + # result. The original provider may rotate on every call; it must never be + # consulted while proving the bearer that the outer sender is about to use. + $contextCloud = if ($null -ne $Context.PSObject.Properties['Cloud']) { + [string] $Context.Cloud + } + else { + '' + } + $proofCloud = if ([string]::IsNullOrWhiteSpace($contextCloud)) { 'TenantProof' } else { $contextCloud } + $proofClientId = if ($null -ne $Context.PSObject.Properties['ClientId']) { + $Context.ClientId + } + else { + $null + } + $proofContext = [pscustomobject] @{ + ProfileId = 'tenant-proof' + TenantId = $targetTenant + Cloud = $proofCloud + GraphBaseUri = $Context.GraphBaseUri + ClientId = $proofClientId + TokenSource = New-GraphPinnedTokenSource -TokenResult $TokenResult + CredentialFingerprint = $fingerprint + AcquisitionCacheKey = "tenant-proof|$cacheKey" + IdentityState = 'NotAcquired' + } + + $envelope = & $transport -Context $proofContext -Descriptor $proofDescriptor -Uri $proofUri ` + -CancellationToken $CancellationToken -RemainingDeadline $RemainingDeadline if ($null -eq $envelope -or $envelope.Outcome -ne 'Succeeded') { + # Invoke-GraphRetry represents cancellation as an envelope. Convert a + # caller-signalled proof cancellation back to OperationCanceledException + # so the outer retry loop preserves its established Cancelled outcome and + # releases its admission instead of treating cancellation as an identity + # failure with a misleading diagnostic. + if ($CancellationToken.IsCancellationRequested) { + $CancellationToken.ThrowIfCancellationRequested() + } + if ($null -ne $envelope -and [string] $envelope.Outcome -ceq 'DeadlineExpired') { + throw (New-GraphTenantBindingDeadlineException) + } + throw ( 'Tenant proof failed: the /organization read did not succeed, so the token cannot be verified for tenant {0}.' -f $targetTenant ) diff --git a/source/Private/Get-GraphRetryDecision.ps1 b/source/Private/Get-GraphRetryDecision.ps1 index 904284a..883eed5 100644 --- a/source/Private/Get-GraphRetryDecision.ps1 +++ b/source/Private/Get-GraphRetryDecision.ps1 @@ -10,13 +10,14 @@ delay parser never decides whether to retry. Certainty axis (runtime): - Succeeded A 2xx response was received. + Succeeded The caller classified the 2xx as usable (including an + accepted 202, whose status is authoritative by itself). Rejected The service refused before executing (e.g. a clean 429). Ambiguous Timeout, connection reset, or 502/503/504 with no body. MayHaveCommitted Ambiguous plus evidence of partial effect (reconciliation). Decision rules (spec "Retry must be semantics-aware"): - - 2xx is always success; a 2xx carrying Retry-After is success + pacing, never replay. + - Succeeded certainty is never replayed; Retry-After adds future pacing only. - 401 triggers at most one forced refresh (only when the token source can refresh). - 403/404 never retry. - 409 retries only for known transient inner error codes. @@ -53,8 +54,8 @@ function Get-GraphRetryDecision { $replayPolicy = [string] $Descriptor.ReplayPolicy $isRead = $Method -in @('GET', 'HEAD') - # Successful response: never replay. A 2xx carrying Retry-After is still a - # success (the client must pace future traffic, not resend this request). + # A response already classified Succeeded is never replayed. Retry-After + # paces future traffic; it never turns success into permission to resend. if ($AttemptCertainty -eq 'Succeeded') { return [pscustomobject] @{ ShouldRetry = $false diff --git a/source/Private/Get-GraphVaultCredential.ps1 b/source/Private/Get-GraphVaultCredential.ps1 index 79ea9c5..ff66dd1 100644 --- a/source/Private/Get-GraphVaultCredential.ps1 +++ b/source/Private/Get-GraphVaultCredential.ps1 @@ -22,9 +22,10 @@ BearerToken vault secret -> plain-text string ManagedIdentity ClientId or $null -> no vault call - X509Certificate2 instances returned here are created by GraphKit, so the caller - owns and disposes them. Caller-injected certificates and token providers never - pass through this function (they are context-only and never persisted). + Credential material carries explicit OwnsMaterial metadata. Certificates + constructed from persisted PFX bytes/files and copies of provider-returned + certificates are GraphKit-owned; caller-injected certificates never pass + through this function and remain caller-owned. #> function Get-GraphVaultCredential { @@ -51,11 +52,18 @@ function Get-GraphVaultCredential { throw "AuthMethod 'ClientSecret' is missing a SecretName in the persisted credential; cannot resolve the client secret from the vault." } + Assert-GraphSecretVersionSupported -Name $secretName -Version ([string] $Credential.Version) Assert-GraphVaultRegistered -VaultName $vault $secret = Get-GraphSecret -Vault $vault -Name $secretName -Version ([string] $Credential.Version) $secret = ConvertTo-GraphSecureString -Value $secret - return New-GraphCredentialMaterial -AuthMethod 'ClientSecret' -Material $secret + $generation = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'ClientSecret' + Credential = $Credential + } + + return New-GraphCredentialMaterial -AuthMethod 'ClientSecret' -Material $secret ` + -OwnsMaterial:$true -CredentialGeneration $generation } 'BearerToken' { @@ -65,6 +73,7 @@ function Get-GraphVaultCredential { throw "AuthMethod 'BearerToken' is missing a SecretName in the persisted credential; cannot resolve the bearer token from the vault." } + Assert-GraphSecretVersionSupported -Name $secretName -Version ([string] $Credential.Version) Assert-GraphVaultRegistered -VaultName $vault $secret = Get-GraphSecret -Vault $vault -Name $secretName -Version ([string] $Credential.Version) $plain = if ($secret -is [System.Security.SecureString]) { @@ -78,40 +87,91 @@ function Get-GraphVaultCredential { throw "Secret '$secretName' in vault '$vault' resolved to an empty bearer token." } - return New-GraphCredentialMaterial -AuthMethod 'BearerToken' -Material $plain + $generation = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'BearerToken' + Credential = $Credential + } + return New-GraphCredentialMaterial -AuthMethod 'BearerToken' -Material $plain ` + -CredentialGeneration $generation } 'Certificate' { if ($Credential.ContainsKey('PfxPath') -and -not [string]::IsNullOrEmpty([string] $Credential.PfxPath)) { - $password = Resolve-GraphVaultPassword -Password $Credential.Password -DefaultVault $VaultName - if ($null -eq $password) { - throw "Certificate (PFX) requires a vault-backed password reference (Password = @{ VaultName; SecretName }) alongside PfxPath." - } - + $password = $null + $snapshot = $null try { - $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([string] $Credential.PfxPath, $password) + $password = Resolve-GraphVaultPassword -Password $Credential.Password -DefaultVault $VaultName + if ($null -eq $password) { + throw "Certificate (PFX) requires a vault-backed password reference (Password = @{ VaultName; SecretName }) alongside PfxPath." + } + + $snapshot = Get-GraphPfxSnapshot -Path ([string] $Credential.PfxPath) + try { + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + [byte[]] $snapshot.Bytes, + $password + ) + } + catch { + throw "Could not load the PFX certificate from '$($Credential.PfxPath)': $($_.Exception.Message)" + } + + $generation = Get-GraphCredentialGeneration ` + -TenantProfile @{ AuthMethod = 'Certificate'; Credential = $Credential } ` + -PfxContentSha256 ([string] $snapshot.Sha256) ` + -PfxCanonicalPath ([string] $snapshot.Path) + + return New-GraphCredentialMaterial -AuthMethod 'Certificate' -Material $cert ` + -OwnsMaterial:$true -CredentialGeneration $generation } - catch { - throw "Could not load the PFX certificate from '$($Credential.PfxPath)': $($_.Exception.Message)" + finally { + if ($null -ne $password) { + $password.Dispose() + } + if ($null -ne $snapshot -and $snapshot.Bytes -is [byte[]]) { + [System.Security.Cryptography.CryptographicOperations]::ZeroMemory( + [byte[]] $snapshot.Bytes + ) + } } - - return New-GraphCredentialMaterial -AuthMethod 'Certificate' -Material $cert } if ($Credential.ContainsKey('CertificateName') -and -not [string]::IsNullOrEmpty([string] $Credential.CertificateName)) { + Assert-GraphSecretVersionSupported ` + -Name ([string] $Credential.CertificateName) ` + -Version ([string] $Credential.Version) + Assert-GraphVaultPasswordReference -Password $Credential.Password $vault = Resolve-GraphVaultName -Credential $Credential -DefaultVault $VaultName Assert-GraphVaultRegistered -VaultName $vault - $raw = Get-GraphSecret -Vault $vault -Name ([string] $Credential.CertificateName) -Version ([string] $Credential.Version) - $password = Resolve-GraphVaultPassword -Password $Credential.Password -DefaultVault $VaultName - $cert = ConvertTo-GraphCertificate -Raw $raw -VaultName $vault -SecretName ([string] $Credential.CertificateName) -Password $password - - return New-GraphCredentialMaterial -AuthMethod 'Certificate' -Material $cert + $password = $null + try { + $raw = Get-GraphSecret -Vault $vault -Name ([string] $Credential.CertificateName) -Version ([string] $Credential.Version) + $password = Resolve-GraphVaultPassword -Password $Credential.Password -DefaultVault $VaultName + $cert = ConvertTo-GraphCertificate -Raw $raw -VaultName $vault -SecretName ([string] $Credential.CertificateName) -Password $password + + $generation = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = $Credential + } + return New-GraphCredentialMaterial -AuthMethod 'Certificate' -Material $cert ` + -OwnsMaterial:$true -CredentialGeneration $generation + } + finally { + if ($null -ne $password) { + $password.Dispose() + } + } } if ($Credential.ContainsKey('StoreLocation') -or $Credential.ContainsKey('StoreName') -or $Credential.ContainsKey('Thumbprint') -or $Credential.ContainsKey('Subject')) { $cert = Get-GraphStoreCertificate -Credential $Credential - return New-GraphCredentialMaterial -AuthMethod 'Certificate' -Material $cert + $generation = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = $Credential + } + return New-GraphCredentialMaterial -AuthMethod 'Certificate' -Material $cert ` + -OwnsMaterial:$true -CredentialGeneration $generation } throw "AuthMethod 'Certificate' requires a persisted credential with a PfxPath (+ vault-backed Password), a CertificateName (+ VaultName), or a store lookup (StoreLocation/StoreName with Thumbprint or Subject)." @@ -122,7 +182,12 @@ function Get-GraphVaultCredential { if ($null -ne $clientId) { $clientId = [string] $clientId } - return New-GraphCredentialMaterial -AuthMethod 'ManagedIdentity' -Material $null -ManagedIdentityClientId $clientId + $generation = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'ManagedIdentity' + Credential = $Credential + } + return New-GraphCredentialMaterial -AuthMethod 'ManagedIdentity' -Material $null ` + -ManagedIdentityClientId $clientId -CredentialGeneration $generation } } } @@ -253,11 +318,8 @@ function Get-GraphSecret { ) $params = @{ Vault = $Vault; Name = $Name; SecretErrorAction = 'SilentlyContinue' } + Assert-GraphSecretVersionSupported -Name $Name -Version $Version if (-not [string]::IsNullOrEmpty($Version)) { - $getSecret = Get-Command -Name Get-Secret -Module Microsoft.PowerShell.SecretManagement -ErrorAction SilentlyContinue - if ($null -eq $getSecret -or -not $getSecret.Parameters.ContainsKey('Version')) { - throw "A secret version ('$Version') was requested for '$Name' but the loaded Microsoft.PowerShell.SecretManagement does not support per-secret versions. Store each version under a distinct secret name, or upgrade SecretManagement." - } $params['Version'] = $Version } @@ -268,6 +330,41 @@ function Get-GraphSecret { return $secret } +function Assert-GraphSecretVersionSupported { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Name, + + [string] $Version + ) + + if ([string]::IsNullOrEmpty($Version)) { + return + } + + $null = Import-GraphSecretManagement + $getSecret = Get-Command -Name Get-Secret -Module Microsoft.PowerShell.SecretManagement -ErrorAction SilentlyContinue + if ($null -eq $getSecret -or -not $getSecret.Parameters.ContainsKey('Version')) { + throw "A secret version ('$Version') was requested for '$Name' but the loaded Microsoft.PowerShell.SecretManagement does not support per-secret versions. Store each immutable generation under a distinct secret name; Version metadata cannot be resolved through this Get-Secret API." + } +} + +function Assert-GraphVaultPasswordReference { + [CmdletBinding()] + param([object] $Password) + + if ($Password -isnot [hashtable]) { + return + } + + $secretName = [string] $Password.SecretName + if ([string]::IsNullOrEmpty($secretName)) { + throw 'A vault-backed password reference is missing a SecretName.' + } + Assert-GraphSecretVersionSupported -Name $secretName -Version ([string] $Password.Version) +} + function Resolve-GraphVaultPassword { [CmdletBinding()] [OutputType([System.Security.SecureString])] @@ -281,17 +378,17 @@ function Resolve-GraphVaultPassword { return $null } if ($Password -is [System.Security.SecureString]) { - return $Password + # A directly supplied SecureString is caller-owned. The certificate + # resolver disposes only this private copy after import. + return $Password.Copy() } if ($Password -is [hashtable]) { + Assert-GraphVaultPasswordReference -Password $Password $vault = [string] $Password.VaultName if ([string]::IsNullOrEmpty($vault)) { $vault = [string] $DefaultVault } $secretName = [string] $Password.SecretName - if ([string]::IsNullOrEmpty($secretName)) { - throw "A vault-backed password reference is missing a SecretName." - } Assert-GraphVaultRegistered -VaultName $vault $secret = Get-GraphSecret -Vault $vault -Name $secretName -Version ([string] $Password.Version) @@ -311,7 +408,9 @@ function ConvertTo-GraphSecureString { ) if ($Value -is [System.Security.SecureString]) { - return $Value + # Vault/provider-returned SecureString instances remain provider-owned. + # Callers of this helper explicitly own and dispose the returned copy. + return $Value.Copy() } if ($Value -is [string]) { $secure = [System.Security.SecureString]::new() @@ -340,18 +439,29 @@ function ConvertTo-GraphCertificate { [System.Security.SecureString] $Password ) + $bytes = $null # A byte[] flattened by PowerShell pipeline enumeration into an object[] - # (for example, a mock returning a byte[] through the pipeline) is reassembled. + # (for example, a provider returning a byte[] through the pipeline) is + # reassembled directly into the one GraphKit-owned import buffer. Avoid a + # second clone whose first copy would otherwise survive until GC. if ($Raw -is [System.Array] -and $Raw -isnot [byte[]]) { - $Raw = [byte[]] @($Raw) + $bytes = [byte[]] @($Raw) } - - $bytes = $null - if ($Raw -is [byte[]]) { - $bytes = $Raw + elseif ($Raw -is [byte[]]) { + # Never zero provider-owned material. Import from a private copy and + # deterministically clear that copy below on success or failure. + $bytes = [byte[]] $Raw.Clone() } elseif ($Raw -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) { - return $Raw + # Never retain or later dispose a provider-owned certificate object. + # X509Certificate2's copy constructor duplicates its native context and + # preserves the private-key association without exporting key material. + try { + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($Raw) + } + catch { + throw "The certificate secret '$SecretName' in vault '$VaultName' could not be copied into GraphKit-owned material: $($_.Exception.Message)" + } } elseif ($Raw -is [System.Security.SecureString]) { $plain = [System.Net.NetworkCredential]::new('', $Raw).Password @@ -379,6 +489,11 @@ function ConvertTo-GraphCertificate { catch { throw "The certificate secret '$SecretName' in vault '$VaultName' could not be interpreted as a PFX: $($_.Exception.Message)" } + finally { + if ($bytes -is [byte[]]) { + [System.Security.Cryptography.CryptographicOperations]::ZeroMemory($bytes) + } + } } function ConvertTo-GraphCertificateBytes { @@ -461,7 +576,14 @@ function Get-GraphStoreCertificate { throw "Certificate '$($match.Thumbprint)' in Cert:\$location\$storeName has no accessible private key, so it cannot sign a client assertion. Import the PFX with its key, and for LocalMachine make sure this process has permission to read it." } - return $match + try { + # The certificate-provider wrapper remains provider-owned. Return a + # GraphKit-owned duplicate so module cleanup never disposes that wrapper. + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($match) + } + catch { + throw "Certificate '$($match.Thumbprint)' in Cert:\$location\$storeName could not be copied into GraphKit-owned material: $($_.Exception.Message)" + } } function New-GraphCredentialMaterial { @@ -473,7 +595,11 @@ function New-GraphCredentialMaterial { [object] $Material, - [object] $ManagedIdentityClientId + [object] $ManagedIdentityClientId, + + [bool] $OwnsMaterial = $false, + + [string] $CredentialGeneration ) return [PSCustomObject]@{ @@ -481,5 +607,7 @@ function New-GraphCredentialMaterial { AuthMethod = $AuthMethod Material = $Material ManagedIdentityClientId = $ManagedIdentityClientId + OwnsMaterial = $OwnsMaterial + CredentialGeneration = $CredentialGeneration } } diff --git a/source/Private/Initialize-GraphModuleLifecycle.ps1 b/source/Private/Initialize-GraphModuleLifecycle.ps1 new file mode 100644 index 0000000..e342e89 --- /dev/null +++ b/source/Private/Initialize-GraphModuleLifecycle.ps1 @@ -0,0 +1,648 @@ +<# + Central ownership and shutdown state for one imported GraphKit module instance. + + PowerShell class methods are not reliable synchronization boundaries across + runspaces. Lifecycle admission, cancellation convergence and cleanup ownership + therefore live in one small compiled state object. PowerShell functions retain + the existing private command surface used by the sender and tests. + + Shutdown has two independent gates: every operation lease must drain, and every + cancellation callback must finish. Cleanup starts asynchronously only after both + gates close. Module removal waits through WaitForCleanup only up to its caller-provided + deadline; a blocking or reentrant Dispose therefore cannot wedge OnRemove. +#> + +$script:GraphKitModuleLifecycleStateTypeName = 'GraphKit.Internal.RuntimeV1.ModuleLifecycleState' +$script:GraphKitModuleLifecycleContractMarker = 'GraphKit.ModuleLifecycle.RuntimeV1/2026-08-30.2' + +function Assert-GraphModuleLifecycleTypeContract { + [CmdletBinding()] + [OutputType([type])] + param( + [Parameter(Mandatory)] + [type] $Type + ) + + $issues = [System.Collections.Generic.List[string]]::new() + if ($Type.FullName -cne $script:GraphKitModuleLifecycleStateTypeName) { + $issues.Add( + "type name '$($Type.FullName)' does not match '$($script:GraphKitModuleLifecycleStateTypeName)'" + ) + } + if (-not $Type.IsPublic -or -not $Type.IsSealed) { + $issues.Add('the lifecycle state must be a public sealed type') + } + if ($null -eq $Type.GetConstructor([type[]] @())) { + $issues.Add('a public parameterless constructor is required') + } + + $publicStatic = [System.Reflection.BindingFlags]'Public, Static' + $markerProperty = $Type.GetProperty('ContractMarker', $publicStatic) + if ( + $null -eq $markerProperty -or + $markerProperty.PropertyType -ne [string] -or + $null -eq $markerProperty.GetMethod -or + -not $markerProperty.GetMethod.IsPublic -or + -not $markerProperty.GetMethod.IsStatic + ) { + $issues.Add('public static string ContractMarker is missing') + } + else { + try { + $actualMarker = [string] $markerProperty.GetValue($null) + if ($actualMarker -cne $script:GraphKitModuleLifecycleContractMarker) { + $issues.Add( + "ContractMarker '$actualMarker' does not match '$($script:GraphKitModuleLifecycleContractMarker)'" + ) + } + } + catch { + $issues.Add("ContractMarker could not be read: $($_.Exception.Message)") + } + } + + $publicInstance = [System.Reflection.BindingFlags]'Public, Instance' + $requiredProperties = @( + @{ Name = 'SyncRoot'; PropertyType = [object] } + @{ Name = 'ShutdownCts'; PropertyType = [System.Threading.CancellationTokenSource] } + @{ Name = 'Drained'; PropertyType = [System.Threading.ManualResetEventSlim] } + @{ Name = 'OwnedResources'; PropertyType = [System.Collections.Generic.List[System.IDisposable]] } + @{ Name = 'HttpClients'; PropertyType = [System.Collections.Generic.Dictionary[string, object]] } + @{ Name = 'StopRequested'; PropertyType = [bool] } + @{ Name = 'CleanupStarted'; PropertyType = [bool] } + @{ Name = 'CleanupComplete'; PropertyType = [bool] } + @{ Name = 'CleanupDeferred'; PropertyType = [bool] } + @{ Name = 'ActiveOperations'; PropertyType = [int] } + @{ Name = 'CancellationObserved'; PropertyType = [bool] } + @{ Name = 'CancellationTask'; PropertyType = [System.Threading.Tasks.Task] } + @{ Name = 'CleanupTask'; PropertyType = [System.Threading.Tasks.Task] } + ) + foreach ($requiredProperty in $requiredProperties) { + $property = $Type.GetProperty($requiredProperty.Name, $publicInstance) + if ($null -eq $property) { + $issues.Add("public instance property $($requiredProperty.Name) is missing") + continue + } + + if ($property.PropertyType -ne $requiredProperty.PropertyType) { + $issues.Add( + "property $($requiredProperty.Name) has type '$($property.PropertyType.FullName)' instead of '$($requiredProperty.PropertyType.FullName)'" + ) + } + } + + $requiredMethods = @( + @{ Name = 'EnterOperation'; ReturnType = 'System.Threading.CancellationToken'; Parameters = [string[]] @() } + @{ Name = 'ExitOperation'; ReturnType = 'System.Void'; Parameters = [string[]] @() } + @{ Name = 'RegisterOwnedResource'; ReturnType = 'System.Void'; Parameters = [string[]] @('System.IDisposable') } + @{ Name = 'RequestStop'; ReturnType = 'System.Threading.Tasks.Task'; Parameters = [string[]] @() } + @{ Name = 'TryScheduleCleanup'; ReturnType = 'System.Void'; Parameters = [string[]] @() } + @{ Name = 'MarkCleanupDeferred'; ReturnType = 'System.Void'; Parameters = [string[]] @() } + @{ Name = 'GetFailures'; ReturnType = 'System.Exception[]'; Parameters = [string[]] @() } + @{ Name = 'WaitForCleanup'; ReturnType = 'System.Boolean'; Parameters = [string[]] @('System.Int32') } + ) + $publicMethods = @($Type.GetMethods($publicInstance)) + foreach ($requiredMethod in $requiredMethods) { + $matchingMethod = @( + $publicMethods | Where-Object { + if ($_.Name -cne $requiredMethod.Name) { + return $false + } + + $parameters = @($_.GetParameters()) + if ($parameters.Count -ne $requiredMethod.Parameters.Count) { + return $false + } + for ($index = 0; $index -lt $parameters.Count; $index++) { + if ($parameters[$index].ParameterType.FullName -cne $requiredMethod.Parameters[$index]) { + return $false + } + } + return $true + } + ) | Select-Object -First 1 + + if ($null -eq $matchingMethod) { + $issues.Add("public instance method $($requiredMethod.Name) has a missing or incompatible parameter list") + continue + } + if ($matchingMethod.ReturnType.FullName -cne $requiredMethod.ReturnType) { + $issues.Add( + "method $($requiredMethod.Name) returns '$($matchingMethod.ReturnType.FullName)' instead of '$($requiredMethod.ReturnType)'" + ) + } + } + + if ($issues.Count -gt 0) { + throw [System.InvalidOperationException]::new( + "The loaded GraphKit module lifecycle type is incompatible with the required ABI contract: " + + ($issues -join '; ') + ) + } + + return $Type +} + +$existingLifecycleType = $script:GraphKitModuleLifecycleStateTypeName -as [type] +if ($null -ne $existingLifecycleType) { + $script:GraphKitModuleLifecycleStateType = Assert-GraphModuleLifecycleTypeContract -Type $existingLifecycleType +} +else { + try { + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphKit.Internal.RuntimeV1 +{ +public sealed class ModuleLifecycleState +{ + public static string ContractMarker + { + get { return "GraphKit.ModuleLifecycle.RuntimeV1/2026-08-30.2"; } + } + + private readonly object _stateSync = new object(); + private readonly object _syncRoot = new object(); + private readonly List _ownedResources = new List(); + private readonly Dictionary _httpClients = + new Dictionary(StringComparer.Ordinal); + private readonly List _failures = new List(); + private readonly TaskCompletionSource _cleanupCompletion = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + private bool _stopRequested; + private bool _cleanupStarted; + private bool _cleanupComplete; + private bool _cleanupDeferred; + private bool _cancellationObserved; + private int _activeOperations; + private Task _cancellationTask; + private Task _cleanupTask; + + public ModuleLifecycleState() + { + ShutdownCts = new CancellationTokenSource(); + Drained = new ManualResetEventSlim(true); + } + + // The HTTP-client cache has its own lock because its factory is PowerShell + // code and can block. Lifecycle cancellation must never wait for that lock. + public object SyncRoot { get { return _syncRoot; } } + public CancellationTokenSource ShutdownCts { get; private set; } + public ManualResetEventSlim Drained { get; private set; } + public List OwnedResources { get { return _ownedResources; } } + public Dictionary HttpClients { get { return _httpClients; } } + + public bool StopRequested + { + get { lock (_stateSync) { return _stopRequested; } } + } + + public bool CleanupStarted + { + get { lock (_stateSync) { return _cleanupStarted; } } + } + + public bool CleanupComplete + { + get { lock (_stateSync) { return _cleanupComplete; } } + } + + public bool CleanupDeferred + { + get { lock (_stateSync) { return _cleanupDeferred; } } + } + + public int ActiveOperations + { + get { lock (_stateSync) { return _activeOperations; } } + } + + public bool CancellationObserved + { + get { lock (_stateSync) { return _cancellationObserved; } } + } + + public Task CancellationTask + { + get { lock (_stateSync) { return _cancellationTask; } } + } + + public Task CleanupTask + { + get { lock (_stateSync) { return _cleanupTask; } } + } + + public CancellationToken EnterOperation() + { + lock (_stateSync) + { + if (_stopRequested || _cleanupStarted) + { + throw new ObjectDisposedException( + "GraphKit", + "The GraphKit module is stopping and cannot start another operation."); + } + + checked { _activeOperations++; } + if (_activeOperations == 1) Drained.Reset(); + return ShutdownCts.Token; + } + } + + public void ExitOperation() + { + lock (_stateSync) + { + if (_activeOperations <= 0) + { + throw new InvalidOperationException( + "GraphKit module lifecycle operation count would become negative."); + } + + _activeOperations--; + if (_activeOperations == 0) + { + Drained.Set(); + TryScheduleCleanupNoLock(); + } + } + } + + public void RegisterOwnedResource(IDisposable resource) + { + if (resource == null) throw new ArgumentNullException("resource"); + + lock (_stateSync) + { + if (_stopRequested || _cleanupStarted) + { + throw new ObjectDisposedException( + "GraphKit", + "The GraphKit module stopped before the owned resource could be registered."); + } + + foreach (IDisposable existing in _ownedResources) + { + if (Object.ReferenceEquals(existing, resource)) return; + } + _ownedResources.Add(resource); + } + } + + public Task RequestStop() + { + lock (_stateSync) + { + if (!_stopRequested) + { + _stopRequested = true; + try + { + // CancelAsync marks the token cancelled synchronously but runs + // arbitrary callbacks asynchronously. + _cancellationTask = ShutdownCts.CancelAsync(); + } + catch (Exception ex) + { + AddFailureNoLock(ex); + _cancellationTask = Task.CompletedTask; + _cancellationObserved = true; + } + + if (!_cancellationObserved) + { + Task continuation = _cancellationTask.ContinueWith( + completed => CancellationCompleted(completed), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + GC.KeepAlive(continuation); + } + } + + TryScheduleCleanupNoLock(); + return _cancellationTask; + } + } + + public void TryScheduleCleanup() + { + lock (_stateSync) { TryScheduleCleanupNoLock(); } + } + + public void MarkCleanupDeferred() + { + lock (_stateSync) { _cleanupDeferred = true; } + } + + public Exception[] GetFailures() + { + lock (_stateSync) { return _failures.ToArray(); } + } + + public bool WaitForCleanup(int timeoutMilliseconds) + { + if (timeoutMilliseconds < 0) + { + throw new ArgumentOutOfRangeException("timeoutMilliseconds"); + } + return _cleanupCompletion.Task.Wait(timeoutMilliseconds); + } + + private void CancellationCompleted(Task completed) + { + lock (_stateSync) + { + try + { + if (completed.IsFaulted && completed.Exception != null) + { + foreach (Exception failure in completed.Exception.Flatten().InnerExceptions) + { + AddFailureNoLock(failure); + } + } + else if (completed.IsCanceled) + { + AddFailureNoLock(new TaskCanceledException( + "GraphKit module cancellation callbacks did not complete.")); + } + } + catch (Exception ex) + { + AddFailureNoLock(ex); + } + finally + { + // This is deliberately distinct from Task.IsCompleted. Cleanup + // may start only after this observer has recorded terminal state. + _cancellationObserved = true; + TryScheduleCleanupNoLock(); + } + } + } + + private void TryScheduleCleanupNoLock() + { + if (!_stopRequested || _cleanupStarted || _activeOperations != 0 || + _cancellationTask == null || !_cancellationObserved) + { + return; + } + + _cleanupStarted = true; + IDisposable[] resources = _ownedResources.ToArray(); + _ownedResources.Clear(); + + // Disposal never runs on the Stop, OnRemove, cancellation-callback, or + // final-operation thread. A blocking/reentrant resource can delay only + // this cleanup task; the caller observes the bounded WaitForCleanup wait. + _cleanupTask = Task.Run(() => DisposeResources(resources)); + } + + private void DisposeResources(IDisposable[] resources) + { + try + { + lock (_syncRoot) { _httpClients.Clear(); } + + for (int index = resources.Length - 1; index >= 0; index--) + { + try { resources[index].Dispose(); } + catch (Exception ex) { AddFailure(ex); } + } + } + catch (Exception ex) + { + AddFailure(ex); + } + finally + { + try { ShutdownCts.Dispose(); } + catch (Exception ex) { AddFailure(ex); } + try { Drained.Dispose(); } + catch (Exception ex) { AddFailure(ex); } + + lock (_stateSync) { _cleanupComplete = true; } + _cleanupCompletion.TrySetResult(true); + } + } + + private void AddFailure(Exception failure) + { + lock (_stateSync) { AddFailureNoLock(failure); } + } + + private void AddFailureNoLock(Exception failure) + { + if (failure != null) _failures.Add(failure); + } +} +} +'@ + + $loadedLifecycleType = $script:GraphKitModuleLifecycleStateTypeName -as [type] + if ($null -eq $loadedLifecycleType) { + throw [System.TypeLoadException]::new( + "Add-Type completed without loading '$($script:GraphKitModuleLifecycleStateTypeName)'." + ) + } + $script:GraphKitModuleLifecycleStateType = Assert-GraphModuleLifecycleTypeContract -Type $loadedLifecycleType + } + catch { + $addTypeFailure = $_ + $racedLifecycleType = $script:GraphKitModuleLifecycleStateTypeName -as [type] + if ($null -eq $racedLifecycleType) { + throw + } + + # Concurrent imports can both observe the type as absent before one + # Add-Type wins. Suppress only that race and only after validating the + # exact namespace, ABI marker and callable member surface. + try { + $script:GraphKitModuleLifecycleStateType = + Assert-GraphModuleLifecycleTypeContract -Type $racedLifecycleType + } + catch { + throw $addTypeFailure + } + } +} + +function New-GraphModuleLifecycleState { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Creates private in-process lifecycle state and does not change external state.' + )] + [CmdletBinding()] + [OutputType([object])] + param() + + $state = [System.Activator]::CreateInstance($script:GraphKitModuleLifecycleStateType) + $state.PSObject.TypeNames.Insert(0, 'GraphKit.ModuleLifecycleState') + return $state +} + +function Enter-GraphModuleOperation { + [CmdletBinding()] + [OutputType([System.Threading.CancellationToken])] + param( + [object] $State = $script:GraphKitModuleLifecycle + ) + + if ($null -eq $State) { + throw [System.InvalidOperationException]::new('GraphKit module lifecycle state is unavailable.') + } + try { + return $State.EnterOperation() + } + catch { + if ($null -ne $_.Exception.InnerException) { + throw $_.Exception.InnerException + } + throw + } +} + +function Exit-GraphModuleOperation { + [CmdletBinding()] + param( + [object] $State = $script:GraphKitModuleLifecycle + ) + + if ($null -eq $State) { + throw [System.InvalidOperationException]::new('GraphKit module lifecycle state is unavailable.') + } + $State.ExitOperation() +} + +function Register-GraphModuleOwnedResource { + [CmdletBinding()] + [OutputType([System.IDisposable])] + param( + [Parameter(Mandatory)] + [System.IDisposable] $Resource, + + [Parameter(Mandatory)] + [bool] $OwnedByGraphKit, + + [object] $State = $script:GraphKitModuleLifecycle + ) + + if (-not $OwnedByGraphKit) { + return $Resource + } + if ($null -eq $State) { + throw [System.InvalidOperationException]::new('GraphKit module lifecycle state is unavailable.') + } + + # Ownership transfers only if this call returns. A refused registration + # deliberately leaves disposal with its caller. + try { + $State.RegisterOwnedResource($Resource) + } + catch { + if ($null -ne $_.Exception.InnerException) { + throw $_.Exception.InnerException + } + throw + } + return $Resource +} + +function Complete-GraphModuleCleanup { + [CmdletBinding()] + param( + [object] $State = $script:GraphKitModuleLifecycle + ) + + if ($null -eq $State) { + return + } + $State.TryScheduleCleanup() +} + +function Stop-GraphModule { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Stops private in-process lifecycle state during module removal.' + )] + [CmdletBinding()] + param( + [object] $State = $script:GraphKitModuleLifecycle, + + [ValidateRange(0, 600000)] + [int] $DrainTimeoutMilliseconds = 5000 + ) + + if ($null -eq $State) { + return + } + + $watch = [System.Diagnostics.Stopwatch]::StartNew() + $requestFailure = $null + try { + $null = $State.RequestStop() + } + catch { + $requestFailure = $_.Exception + } + + $remaining = [Math]::Max(0, $DrainTimeoutMilliseconds - [int] $watch.ElapsedMilliseconds) + $cleanupObserved = $State.WaitForCleanup($remaining) + $watch.Stop() + + if (-not $cleanupObserved) { + $State.MarkCleanupDeferred() + Write-Warning ( + "GraphKit shutdown did not complete within $DrainTimeoutMilliseconds ms. " + + "$($State.ActiveOperations) active operation(s) remain; cancellation callbacks or " + + 'owned-resource disposal may also still be running. Cleanup will continue asynchronously.' + ) + } + + $failures = [System.Collections.Generic.List[System.Exception]]::new() + if ($null -ne $requestFailure) { + $failures.Add($requestFailure) + } + foreach ($failure in [System.Exception[]] $State.GetFailures()) { + $failures.Add($failure) + } + + if ($failures.Count -gt 0) { + throw [System.AggregateException]::new( + 'GraphKit module shutdown encountered one or more failures.', + $failures.ToArray() + ) + } +} + +$script:GraphKitModuleLifecycle = New-GraphModuleLifecycleState +$graphAuthPayloadRoot = Join-Path $PSScriptRoot 'Assemblies/GraphKit.Auth' +$graphAuthHostCandidate = [GraphKit.Auth.GraphAuthHost]::new( + $graphAuthPayloadRoot, + [version] '1.0.0.0', + [timespan]::FromSeconds(5) +) +try { + $script:GraphKitAuthHost = Register-GraphModuleOwnedResource ` + -Resource $graphAuthHostCandidate ` + -OwnedByGraphKit:$true ` + -State $script:GraphKitModuleLifecycle +} +catch { + $graphAuthHostCandidate.Dispose() + throw +} +$graphKitLifecycleForRemoval = $script:GraphKitModuleLifecycle +$stopGraphModuleForRemoval = Get-Command -Name Stop-GraphModule -CommandType Function + +# Exactly one removal hook owns module cleanup. Process-wide token flights are +# intentionally untouched and may outlive any one imported module instance. +$ExecutionContext.SessionState.Module.OnRemove = { + & $stopGraphModuleForRemoval -State $graphKitLifecycleForRemoval +}.GetNewClosure() diff --git a/source/Private/Invoke-GraphPaging.ps1 b/source/Private/Invoke-GraphPaging.ps1 index 3f3b60e..75f51ad 100644 --- a/source/Private/Invoke-GraphPaging.ps1 +++ b/source/Private/Invoke-GraphPaging.ps1 @@ -34,11 +34,29 @@ function Invoke-GraphPaging { [int] $MaxPages = 200, [Parameter()] - [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None + [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None, + + [Parameter()] + [ValidateRange(0.001, 86400)] + [double] $DeadlineSeconds = 300, + + [Parameter()] + [scriptblock] $UtcNow ) + if ($null -eq $UtcNow) { $UtcNow = { [datetime]::UtcNow } } + $operationStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $deadlineUtc = (& $UtcNow).AddSeconds($DeadlineSeconds) + $getRemainingSeconds = { + $remainingStopwatch = $DeadlineSeconds - $operationStopwatch.Elapsed.TotalSeconds + $remainingClock = ($deadlineUtc - (& $UtcNow)).TotalSeconds + return [Math]::Max(0.0, [Math]::Min($remainingStopwatch, $remainingClock)) + }.GetNewClosure() + $allData = [System.Collections.Generic.List[object]]::new() $allTelemetry = [System.Collections.Generic.List[object]]::new() + $aggregateProvenance = $null + $verifiedTokenIdentity = $null $seenIds = $null if ($Descriptor.DeduplicationKey) { $seenIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -48,6 +66,33 @@ function Invoke-GraphPaging { $pageCount = 0 while ($nextLink -and $pageCount -lt $MaxPages) { + # Caller cancellation wins at the exact deadline boundary. No URI + # validation, request construction, acquisition or send may begin after + # this one collection-wide budget is exhausted. + if ($CancellationToken.IsCancellationRequested) { + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @() + Outcome = 'Cancelled' + Certainty = 'Indeterminate' + Telemetry = @($allTelemetry) + Provenance = $aggregateProvenance + PageCount = $pageCount + } + } + $remainingSeconds = [double] (& $getRemainingSeconds) + if ($remainingSeconds -lt 0.001) { + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @() + Outcome = 'DeadlineExpired' + Certainty = 'Indeterminate' + Telemetry = @($allTelemetry) + Provenance = $aggregateProvenance + PageCount = $pageCount + } + } + $pageCount++ # Validate the nextLink authority before attaching a bearer token. The link is opaque @@ -58,6 +103,35 @@ function Invoke-GraphPaging { # Build the page request. The factory may repeat RequiredPagingHeaders. $request = & $RequestFactoryScript -Uri $nextLink -Descriptor $Descriptor + # URI validation and request construction are part of this collection's + # one budget. Recompute immediately before transport so elapsed setup + # time cannot be handed to retry as a fresh/stale allowance. Retry cannot + # represent less than one millisecond, so a smaller positive remainder is + # expired here rather than rounded up or surfaced as a binding failure. + if ($CancellationToken.IsCancellationRequested) { + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @() + Outcome = 'Cancelled' + Certainty = 'Indeterminate' + Telemetry = @($allTelemetry) + Provenance = $aggregateProvenance + PageCount = $pageCount + } + } + $remainingSeconds = [double] (& $getRemainingSeconds) + if ($remainingSeconds -lt 0.001) { + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @() + Outcome = 'DeadlineExpired' + Certainty = 'Indeterminate' + Telemetry = @($allTelemetry) + Provenance = $aggregateProvenance + PageCount = $pageCount + } + } + # Execute with retry via the transport delegate. # # -CancellationToken is passed explicitly. Omitting it bound $null to a parameter @@ -70,7 +144,8 @@ function Invoke-GraphPaging { -Method $request.Method ` -Headers $request.Headers ` -Body $request.Body ` - -CancellationToken $CancellationToken + -CancellationToken $CancellationToken ` + -DeadlineSeconds $remainingSeconds if ($null -eq $pageResult) { throw "Transport delegate returned null for page $pageCount; expected a GraphKit.OperationResult." @@ -83,16 +158,103 @@ function Invoke-GraphPaging { # A non-success outcome stops pagination. if ($pageResult.Outcome -ne 'Succeeded') { - $allData = @($allData) $allTelemetry = @($allTelemetry) return [PSCustomObject]@{ PSTypeName = 'GraphKit.OperationResult' - Data = $allData + # A collection is one certainty boundary. Never return a + # successful prefix when a later page failed, cancelled or ran + # out of budget: consumers cannot treat that prefix as complete. + Data = @() Outcome = $pageResult.Outcome Certainty = $pageResult.Certainty Telemetry = $allTelemetry Provenance = $pageResult.Provenance + PageCount = $pageCount + } + } + + # A successful transport result is not allowed to outlive the pager's + # canonical collection deadline. Check before provenance validation or + # row retention so a late terminal page cannot be returned as success. + # Cancellation wins when it arrives at the same boundary. + if ($CancellationToken.IsCancellationRequested) { + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @() + Outcome = 'Cancelled' + Certainty = 'Indeterminate' + Telemetry = @($allTelemetry) + Provenance = $aggregateProvenance + PageCount = $pageCount + } + } + if ([double] (& $getRemainingSeconds) -le 0.0) { + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @() + Outcome = 'DeadlineExpired' + Certainty = 'Indeterminate' + Telemetry = @($allTelemetry) + Provenance = $aggregateProvenance + PageCount = $pageCount + } + } + + # A collection envelope attributes every aggregated row to one context, so + # every successful page of a Verified operation must independently carry + # the transport's exact tenant proof. Checking only the final page could + # relabel rows from an earlier unverified or wrong-tenant page. Validate + # before retaining any row, then carry the final validated provenance onto + # the aggregate instead of discarding it. + $pageProvenance = $pageResult.Provenance + if ([string] $Descriptor.IdentityRequirement -ceq 'Verified') { + $contextTenant = [guid]::Empty + $pageTenant = [guid]::Empty + $actualTenant = [guid]::Empty + $contextTenantParsed = [guid]::TryParse([string] $Context.TenantId, [ref] $contextTenant) + $pageTenantParsed = $null -ne $pageProvenance -and + [guid]::TryParse([string] $pageProvenance.TenantId, [ref] $pageTenant) + $actualTenantParsed = $null -ne $pageProvenance -and + [guid]::TryParse([string] $pageProvenance.ActualTenantId, [ref] $actualTenant) + $identityVerified = $null -ne $pageProvenance -and + ([string] $pageProvenance.IdentityState -ceq 'VerifiedForToken') + $tokenFingerprint = if ($null -ne $pageProvenance) { [string] $pageProvenance.TokenFingerprint } else { $null } + $credentialGeneration = if ($null -ne $pageProvenance) { [string] $pageProvenance.CredentialGeneration } else { $null } + $pageCloud = if ($null -ne $pageProvenance) { [string] $pageProvenance.Cloud } else { $null } + $cloudMatches = [string]::Equals( + $pageCloud, + [string] $Context.Cloud, + [System.StringComparison]::OrdinalIgnoreCase + ) + $tokenIdentityComplete = + -not [string]::IsNullOrWhiteSpace($tokenFingerprint) -and + -not [string]::IsNullOrWhiteSpace($credentialGeneration) + $tokenIdentityMatches = $true + if ($null -ne $verifiedTokenIdentity) { + $tokenIdentityMatches = + [string]::Equals($tokenFingerprint, [string] $verifiedTokenIdentity.TokenFingerprint, [System.StringComparison]::Ordinal) -and + [string]::Equals($credentialGeneration, [string] $verifiedTokenIdentity.CredentialGeneration, [System.StringComparison]::Ordinal) -and + [string]::Equals($pageCloud, [string] $verifiedTokenIdentity.Cloud, [System.StringComparison]::OrdinalIgnoreCase) + } + + if (-not $contextTenantParsed -or $contextTenant -eq [guid]::Empty -or + -not $pageTenantParsed -or -not $actualTenantParsed -or + -not $identityVerified -or + $pageTenant -ne $contextTenant -or $actualTenant -ne $contextTenant -or + -not $cloudMatches -or -not $tokenIdentityComplete -or -not $tokenIdentityMatches) { + throw [System.InvalidOperationException]::new( + 'A successful page of a Verified operation did not carry exact VerifiedForToken tenant provenance or exact-token provenance.' + ) + } + + if ($null -eq $verifiedTokenIdentity) { + $verifiedTokenIdentity = [pscustomobject] @{ + TokenFingerprint = $tokenFingerprint + CredentialGeneration = $credentialGeneration + Cloud = $pageCloud + } } + $aggregateProvenance = $pageProvenance } # Collect rows from this page. Data is the parsed response body; for collections it is a @@ -176,6 +338,6 @@ function Invoke-GraphPaging { Truncated = $truncated PageCount = $pageCount Telemetry = @($allTelemetry) - Provenance = $null + Provenance = $aggregateProvenance } } diff --git a/source/Private/Invoke-GraphRetry.ps1 b/source/Private/Invoke-GraphRetry.ps1 index d9b3393..94311f5 100644 --- a/source/Private/Invoke-GraphRetry.ps1 +++ b/source/Private/Invoke-GraphRetry.ps1 @@ -1,10 +1,10 @@ <# The retry engine: one GraphKit attempt loop that owns replay decisions. - Consumes the normalized GraphTransportResult contract, never PowerShell or - HttpClient exception internals. Send, UtcNow, Delay, and Jitter are injectable, - so the full matrix and every deadline/cancellation path is testable with - virtual time (a five-minute scenario runs in milliseconds). + Consumes the normalized GraphTransportResult contract, never provider-specific + PowerShell or HttpClient exception shapes. Send, UtcNow, Delay, and Jitter are + injectable, so the full matrix and every deadline/cancellation path is testable + with virtual time (a five-minute scenario runs in milliseconds). Returns exactly one GraphKit.OperationResult envelope and never throws for transport-level outcomes. The only hard errors are credential-boundary @@ -25,10 +25,22 @@ $script:GraphKnownTransientErrorCodes = @( function Get-GraphAttemptCertainty { param( [int] $StatusCode, - [bool] $ResponseReceived + [bool] $ResponseReceived, + + [AllowNull()] + [object] $TransportException ) if ($ResponseReceived) { + # Accepted means the service owns the work. Replaying a 202 can duplicate + # an asynchronous operation even when its optional response body failed. + if ($StatusCode -eq 202) { return 'Succeeded' } + + # Headers alone do not make a 2xx usable. A timeout/reset while reading + # its body leaves a normalized transport failure and incomplete data. + if ($StatusCode -ge 200 -and $StatusCode -lt 300 -and $null -ne $TransportException) { + return 'Ambiguous' + } if ($StatusCode -ge 200 -and $StatusCode -lt 300) { return 'Succeeded' } if ($StatusCode -eq 408) { return 'Ambiguous' } if ($StatusCode -ge 500 -and $StatusCode -le 599) { return 'Ambiguous' } @@ -45,7 +57,7 @@ function Test-GraphDeadlineExpired { [System.Diagnostics.Stopwatch] $Stopwatch, [datetime] $DeadlineUtc, [scriptblock] $UtcNow, - [int] $DeadlineSeconds + [double] $DeadlineSeconds ) if ($Stopwatch.Elapsed.TotalSeconds -ge [double] $DeadlineSeconds) { return $true } @@ -137,8 +149,8 @@ function Invoke-GraphRetry { [ValidateRange(1, 100)] [int] $MaxAttempts = 5, - [ValidateRange(1, 86400)] - [int] $DeadlineSeconds = 300 + [ValidateRange(0.001, 86400)] + [double] $DeadlineSeconds = 300 ) # ---- Resolve injections ---- @@ -164,9 +176,35 @@ function Invoke-GraphRetry { } } if ($null -eq $utcNow) { $utcNow = { [datetime]::UtcNow } } - if ($null -eq $delay) { $delay = { param([double] $Seconds) Start-Sleep -Seconds $Seconds } } + if ($null -eq $delay) { + $delay = { + param( + [double] $Seconds, + [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None + ) + + if ($Seconds -le 0) { return } + if ($CancellationToken.CanBeCanceled) { + if ($CancellationToken.WaitHandle.WaitOne([TimeSpan]::FromSeconds($Seconds))) { + $CancellationToken.ThrowIfCancellationRequested() + } + } + else { + Start-Sleep -Seconds $Seconds + } + } + } if ($null -eq $jitter) { $jitter = { Get-Random -Minimum 0.0 -Maximum 1.0 } } + $delayAcceptsCancellationToken = $false + if ($null -ne $delay.Ast.ParamBlock) { + $delayAcceptsCancellationToken = @( + $delay.Ast.ParamBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'CancellationToken' + } + ).Count -gt 0 + } + # ---- Deadline: monotonic Stopwatch plus the injected (virtual) clock ---- $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $deadlineUtc = (& $utcNow).AddSeconds([double] $DeadlineSeconds) @@ -175,6 +213,13 @@ function Invoke-GraphRetry { $credentialPolicy = [string] $Descriptor.CredentialPolicy $isMutating = $Method -notin @('GET', 'HEAD') + # Catalog operations declare whether tenant-attributed results require proof. + # Method-based mutation remains the fail-closed fallback for raw/private callers + # whose synthesized descriptor predates IdentityRequirement. A verified GET must + # be proved just as a write is: Graph's shared authority cannot identify which + # tenant the bearer addresses. + $requiresTenantBinding = $isMutating -or + ([string] $Descriptor.IdentityRequirement -ceq 'Verified') $canRefresh = ($null -ne $Context.TokenSource) -and ($Context.TokenSource.CanRefresh -eq $true) # ---- Throttle scope (coarse + leaf) ---- @@ -188,6 +233,8 @@ function Invoke-GraphRetry { $certaintyFinal = 'Known' $data = @() $verifiedTenantId = $null + $verifiedTokenFingerprint = $null + $verifiedCredentialGeneration = $null $lastAttemptCertainty = $null for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { @@ -204,7 +251,49 @@ function Invoke-GraphRetry { } # ---- Throttle admission ---- - $admission = Wait-GraphThrottleGate -Scope $scope + $remainingGateStopwatchSeconds = [double] $DeadlineSeconds - $stopwatch.Elapsed.TotalSeconds + $remainingGateClockSeconds = ($deadlineUtc - (& $utcNow)).TotalSeconds + $remainingGateSeconds = [Math]::Max( + 0.0, + [Math]::Min($remainingGateStopwatchSeconds, $remainingGateClockSeconds) + ) + try { + $admission = Wait-GraphThrottleGate -Scope $scope ` + -CancellationToken $CancellationToken ` + -UtcNow (& $utcNow) ` + -UtcNowScript $utcNow ` + -DeadlineUtc $deadlineUtc ` + -RemainingDeadline ([TimeSpan]::FromSeconds($remainingGateSeconds)) + } + catch { + $gateFailure = $_.Exception + $candidate = $gateFailure + $isCancellationFailure = $false + $isOperationDeadline = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellationFailure = $true + } + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.OperationDeadlineExpired'] -eq $true) { + $isOperationDeadline = $true + } + $candidate = $candidate.InnerException + } + + if ($CancellationToken.IsCancellationRequested -and + ($isCancellationFailure -or $isOperationDeadline)) { + $outcome = 'Cancelled' + $certaintyFinal = 'Indeterminate' + break + } + if ($isOperationDeadline) { + $outcome = 'DeadlineExpired' + $certaintyFinal = 'Indeterminate' + break + } + throw + } # ---- Deadline / cancellation mid-throttle (wait may have consumed time) ---- if ($CancellationToken.IsCancellationRequested -or @@ -233,22 +322,6 @@ function Invoke-GraphRetry { # later operation on that tenant|client|class|family then blocks and reports # back-pressure - blaming Graph for a slot this module never gave back. try { - # ---- Token acquisition (force refresh when the prior decision demanded it) ---- - if ($credentialPolicy -eq 'GraphBearer' -and $null -ne $Context.TokenSource) { - $acquireForce = $forceRefreshPending - $tokenResult = $Context.TokenSource.Acquire($acquireForce, $CancellationToken) - if ($acquireForce) { - $forceRefreshPending = $false - $forceRefreshUsed = $true - } - - if ($null -ne $tokenResult -and - -not [string]::IsNullOrEmpty([string] $tokenResult.VerifiedTenantId) -and - [string]::Equals([string] $tokenResult.VerifiedTenantId, [string] $Context.TenantId, [System.StringComparison]::OrdinalIgnoreCase)) { - $verifiedTenantId = $Context.TenantId - } - } - # ---- Build per-attempt request headers (never mutate the caller's table) ---- $clientRequestId = [guid]::NewGuid() $sendHeaders = @{} @@ -303,30 +376,134 @@ function Invoke-GraphRetry { if ($credentialPolicy -eq 'GraphBearer') { $sendParams.TokenSource = $Context.TokenSource + $sendParams.ForceRefresh = $forceRefreshPending + $sendParams.TokenAcquisitionKey = [string] $Context.AcquisitionCacheKey $sendParams.ExpectedAuthority = $Context.GraphBaseUri $sendParams.TargetTenantId = $Context.TenantId - if ($isMutating) { + if ($requiresTenantBinding) { $sendParams.VerifyTenantBinding = $true + # The proof is part of this attempt, not a new operation with + # a fresh five-minute clock. Pass the smaller remaining budget + # reported by the monotonic and injected clocks, plus the exact + # caller scope needed by the nested proof admission. + $remainingStopwatchSeconds = [double] $DeadlineSeconds - $stopwatch.Elapsed.TotalSeconds + $remainingClockSeconds = ($deadlineUtc - (& $utcNow)).TotalSeconds + $remainingProofSeconds = [Math]::Max( + 0.0, + [Math]::Min($remainingStopwatchSeconds, $remainingClockSeconds) + ) + $sendParams.TenantBindingContext = [pscustomobject] @{ + Cloud = $Context.Cloud + ClientId = $Context.ClientId + RemainingDeadline = [TimeSpan]::FromSeconds($remainingProofSeconds) + DeadlineUtc = $deadlineUtc + UtcNow = $utcNow + } } } # ---- One attempt = exactly one send ---- $result = & $send @sendParams + # Sender cancellation is normalized like every other transport + # outcome. Consume only GraphKit's boolean marker; do not infer + # cancellation from provider-specific exception messages or types. + $candidate = $result.TransportException + $isOperationCancellation = $false + while ($null -ne $candidate) { + if ($candidate -is [System.Exception] -and + $candidate.Data['GraphKit.OperationCancellation'] -eq $true) { + $isOperationCancellation = $true + break + } + $candidate = $candidate.InnerException + } + if ($isOperationCancellation) { + throw $result.TransportException + } + + # A handler may ignore cancellation and still return a clean-looking + # response. Recheck immediately, before body/provenance/telemetry can + # be accepted as a successful operation result. + $CancellationToken.ThrowIfCancellationRequested() + + if ($forceRefreshPending) { + $forceRefreshPending = $false + $forceRefreshUsed = $true + } + + $attemptVerifiedTenantId = $null + $attemptTokenFingerprint = $null + $attemptCredentialGeneration = $null + if (-not [string]::IsNullOrEmpty([string] $result.VerifiedTenantId) -and + [string]::Equals([string] $result.VerifiedTenantId, [string] $Context.TenantId, [System.StringComparison]::OrdinalIgnoreCase)) { + if ([string]::IsNullOrWhiteSpace([string] $result.TokenFingerprint) -or + [string]::IsNullOrWhiteSpace([string] $result.CredentialGeneration)) { + throw [System.InvalidOperationException]::new( + 'VerifiedForToken transport provenance requires a non-empty TokenFingerprint and CredentialGeneration.' + ) + } + $attemptVerifiedTenantId = $Context.TenantId + $attemptTokenFingerprint = [string] $result.TokenFingerprint + $attemptCredentialGeneration = [string] $result.CredentialGeneration + } + # ---- Runtime certainty, then release admission ---- # Complete-GraphThrottleGate's -Success switch drives additive-increase # (AIMD restore); without it a qualified throttle never recovers. - $certainty = Get-GraphAttemptCertainty -StatusCode $result.StatusCode -ResponseReceived $result.ResponseReceived + $certainty = Get-GraphAttemptCertainty -StatusCode $result.StatusCode ` + -ResponseReceived $result.ResponseReceived -TransportException $result.TransportException $lastAttemptCertainty = $certainty if ($null -ne $admission) { Complete-GraphThrottleGate -Admission $admission -Success:($certainty -eq 'Succeeded') } $admission = $null } catch { + $sendFailure = $_.Exception if ($null -ne $admission) { Complete-GraphThrottleGate -Admission $admission $admission = $null } + + # Cancellation can occur while this caller is waiting on another + # context's in-flight token acquisition. Preserve the retry engine's + # established Cancelled envelope instead of leaking a credential-path + # OperationCanceledException, but never mask an unrelated failure just + # because the caller token happened to be signalled at the same time. + $candidate = $sendFailure + $isCancellationFailure = $false + $isOperationCancellation = $false + $isTenantBindingDeadline = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellationFailure = $true + } + if ($candidate -is [System.Exception] -and + $candidate.Data['GraphKit.OperationCancellation'] -eq $true) { + $isOperationCancellation = $true + } + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isTenantBindingDeadline = $true + } + $candidate = $candidate.InnerException + } + + # Caller cancellation wins at a simultaneous proof-deadline boundary. + # The sender normally preserves OCE causality, but a marked deadline + # can be thrown in the narrow race after the proof checked its token. + if ($isOperationCancellation -or + ($CancellationToken.IsCancellationRequested -and + ($isCancellationFailure -or $isTenantBindingDeadline))) { + $outcome = 'Cancelled' + $certaintyFinal = 'Indeterminate' + break + } + if ($isTenantBindingDeadline) { + $outcome = 'DeadlineExpired' + $certaintyFinal = 'Indeterminate' + break + } throw } @@ -414,11 +591,78 @@ function Invoke-GraphRetry { # ---- Retry or finish ---- if ($decision.ShouldRetry) { if ($null -ne $delayInfo) { - & $delay $delayInfo.DelaySeconds + # Never grant a retry sleep a fresh or unbounded budget. Clamp it + # to the smaller remaining monotonic/injected-clock deadline and + # pass caller/proof cancellation into the wait implementation. + if ($CancellationToken.IsCancellationRequested) { + $outcome = 'Cancelled' + $certaintyFinal = 'Indeterminate' + break + } + + $remainingStopwatchSeconds = [double] $DeadlineSeconds - $stopwatch.Elapsed.TotalSeconds + $remainingClockSeconds = ($deadlineUtc - (& $utcNow)).TotalSeconds + $remainingDelaySeconds = [Math]::Max( + 0.0, + [Math]::Min($remainingStopwatchSeconds, $remainingClockSeconds) + ) + if ($remainingDelaySeconds -le 0.0) { + $outcome = 'DeadlineExpired' + $certaintyFinal = 'Indeterminate' + break + } + + $requestedDelaySeconds = [double] $delayInfo.DelaySeconds + $boundedDelaySeconds = [Math]::Min($requestedDelaySeconds, $remainingDelaySeconds) + try { + if ($delayAcceptsCancellationToken) { + & $delay $boundedDelaySeconds $CancellationToken + } + else { + & $delay $boundedDelaySeconds + } + } + catch { + $delayFailure = $_.Exception + $candidate = $delayFailure + $isCancellationFailure = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellationFailure = $true + break + } + $candidate = $candidate.InnerException + } + + if ($CancellationToken.IsCancellationRequested -and $isCancellationFailure) { + $outcome = 'Cancelled' + $certaintyFinal = 'Indeterminate' + break + } + throw + } + + if ($CancellationToken.IsCancellationRequested) { + $outcome = 'Cancelled' + $certaintyFinal = 'Indeterminate' + break + } + if ($requestedDelaySeconds -ge $remainingDelaySeconds -or + (Test-GraphDeadlineExpired -Stopwatch $stopwatch -DeadlineUtc $deadlineUtc -UtcNow $utcNow -DeadlineSeconds $DeadlineSeconds)) { + $outcome = 'DeadlineExpired' + $certaintyFinal = 'Indeterminate' + break + } } continue } + # Tenant verification belongs to the token used by this terminal attempt. + # A proven token that receives 401 must never lend its identity to the + # refreshed token whose response becomes the operation result. + $verifiedTenantId = $attemptVerifiedTenantId + $verifiedTokenFingerprint = $attemptTokenFingerprint + $verifiedCredentialGeneration = $attemptCredentialGeneration $outcome = $decision.Outcome $certaintyFinal = $decision.Certainty if ($decision.Outcome -eq 'Succeeded') { @@ -439,8 +683,11 @@ function Invoke-GraphRetry { ApiVersion = $Descriptor.ApiVersion ResourceFamily = $Descriptor.ResourceFamily RetrievedUtc = (& $utcNow) - IdentityState = $Context.IdentityState + IdentityState = if ($null -ne $verifiedTenantId) { 'VerifiedForToken' } else { $Context.IdentityState } ActualTenantId = $verifiedTenantId + TokenFingerprint = $verifiedTokenFingerprint + CredentialGeneration = $verifiedCredentialGeneration + Cloud = $Context.Cloud } # Carry the operation's declared secret-bearing properties on the envelope so it is @@ -458,6 +705,7 @@ function Invoke-GraphRetry { Data = $data Outcome = $outcome Certainty = $certaintyFinal + Truncated = $false Telemetry = @($telemetry) Provenance = $provenance } diff --git a/source/Private/Operations/Assert-GraphOperationAuthMode.ps1 b/source/Private/Operations/Assert-GraphOperationAuthMode.ps1 new file mode 100644 index 0000000..005fb8d --- /dev/null +++ b/source/Private/Operations/Assert-GraphOperationAuthMode.ps1 @@ -0,0 +1,41 @@ +function Assert-GraphOperationAuthMode { + <# + .SYNOPSIS + Refuses a descriptor-driven operation when its persisted auth-mode + declaration excludes the context's token source. + + .DESCRIPTION + Provider is an injected, non-persistable context source and is deliberately + outside descriptor SupportedAuthModes. Descriptor policy applies only to the + four profile AuthMethod values validated by Import-GraphOperationDescriptor. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [PSCustomObject] $Context, + + [Parameter(Mandatory)] + [hashtable] $Descriptor + ) + + if ($null -eq $Context.TokenSource) { + throw 'Descriptor-driven operations require a context token source to enforce SupportedAuthModes.' + } + + $authMode = [string] $Context.TokenSource.AuthMode + if ([string]::IsNullOrWhiteSpace($authMode)) { + throw 'Descriptor-driven operations require a context token source with an AuthMode.' + } + + if ($authMode -eq 'Provider') { + return $true + } + + $supportedAuthModes = @($Descriptor.SupportedAuthModes) + if ($supportedAuthModes -notcontains $authMode) { + throw "Operation '$($Descriptor.Type)/$($Descriptor.Operation)' does not support auth mode '$authMode'." + } + + return $true +} diff --git a/source/Private/Operations/Import-GraphOperationDescriptor.ps1 b/source/Private/Operations/Import-GraphOperationDescriptor.ps1 index b18f61b..d735968 100644 --- a/source/Private/Operations/Import-GraphOperationDescriptor.ps1 +++ b/source/Private/Operations/Import-GraphOperationDescriptor.ps1 @@ -42,6 +42,12 @@ $script:GraphOperationArrayFields = @( 'RequiredPermissions', 'RequiredLicense', 'SupportedClouds' ) +# Persisted profile authentication methods. Provider is intentionally absent: it is an +# injected, non-persistable context source rather than a profile AuthMethod. +$script:GraphOperationPersistedAuthModes = @( + 'Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity' +) + # Closed enums: field name -> allowed values. $script:GraphOperationEnumFields = @( 'OperationKind', 'ApiVersion', 'Stability', 'PagingStrategy', 'ReplayPolicy', @@ -155,6 +161,31 @@ function Import-GraphOperationDescriptor { } } + if ($descriptor.ContainsKey('SupportedAuthModes') -and + $descriptor['SupportedAuthModes'] -is [System.Array]) { + $supportedAuthModes = @($descriptor['SupportedAuthModes']) + if ($supportedAuthModes.Count -eq 0) { + $violations.Add("Field 'SupportedAuthModes' must be a non-empty array.") + } + else { + $seenAuthModes = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + foreach ($authMode in $supportedAuthModes) { + if ($authMode -isnot [string] -or [string]::IsNullOrWhiteSpace($authMode)) { + $violations.Add("Field 'SupportedAuthModes' must contain only non-empty auth-mode names.") + continue + } + if ($authMode -notin $script:GraphOperationPersistedAuthModes) { + $violations.Add("Field 'SupportedAuthModes' contains unknown auth mode '$authMode'.") + continue + } + if (-not $seenAuthModes.Add($authMode)) { + $violations.Add("Field 'SupportedAuthModes' contains duplicate auth mode '$authMode'.") + } + } + } + } + # --- Enum checks --------------------------------------------------------- foreach ($field in $script:GraphOperationEnumFields) { if ($descriptor.ContainsKey($field)) { diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index 87d9b0c..90604be 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -36,8 +36,22 @@ class GraphTokenSourceBase { [System.DateTimeOffset] $ExpiresOn [string] $VerifiedTenantId [string] $CredentialGeneration + hidden [guid] $CreationRunspaceId hidden [GraphTokenResult] $CachedResult + hidden [bool] $CachedResultWasForceRefresh + hidden [object] $CacheLock + + GraphTokenSourceBase() { + $this.CacheLock = [object]::new() + $runspace = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace + $this.CreationRunspaceId = if ($null -eq $runspace) { + [guid]::Empty + } + else { + $runspace.InstanceId + } + } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { throw [System.NotImplementedException]::new('GraphTokenSourceBase.Acquire must be overridden by a concrete token source.') @@ -73,34 +87,111 @@ class GraphTokenSourceBase { return $skew + $spread } - hidden [bool] HasValidCachedToken() { - if ($null -eq $this.CachedResult) { - return $false + hidden [GraphTokenResult] GetValidCachedToken() { + [System.Threading.Monitor]::Enter($this.CacheLock) + try { + $current = $this.CachedResult + if ($null -eq $current) { + return $null + } + + $expires = $current.ExpiresOnUtc + if ($expires -le [System.DateTimeOffset]::MinValue) { + # No expiry is known (a fixed bearer): never treat it as skew-valid. + return $null + } + + $refreshAt = $expires.AddSeconds(-1.0 * $this.RefreshSkewSeconds($current)) + if ($refreshAt -gt [System.DateTimeOffset]::UtcNow) { + return $current + } + return $null + } + finally { + [System.Threading.Monitor]::Exit($this.CacheLock) } + } - $expires = $this.CachedResult.ExpiresOnUtc - if ($expires -le [System.DateTimeOffset]::MinValue) { - # No expiry is known (a fixed bearer): never treat it as skew-valid. - return $false + hidden [GraphTokenResult] GetCachedToken() { + [System.Threading.Monitor]::Enter($this.CacheLock) + try { + return $this.CachedResult } + finally { + [System.Threading.Monitor]::Exit($this.CacheLock) + } + } - $refreshAt = $expires.AddSeconds(-1.0 * $this.RefreshSkewSeconds($this.CachedResult)) - return $refreshAt -gt [System.DateTimeOffset]::UtcNow + hidden [void] CacheResult([GraphTokenResult]$result, [bool]$forceRefresh) { + [System.Threading.Monitor]::Enter($this.CacheLock) + try { + $current = $this.CachedResult + $replace = $null -eq $current + + if (-not $replace) { + $replace = $result.ReceivedOnUtc -gt $current.ReceivedOnUtc + + # ReceivedOnUtc is recorded at acquisition time and normally + # provides a strict order. When two results share a clock tick, + # preserve a forced-refresh result over an ordinary result, then + # prefer the later expiry within the same acquisition mode. + # Otherwise retain the incumbent instead of making cache order + # depend on whichever sender resumes last. + if (-not $replace -and $result.ReceivedOnUtc -eq $current.ReceivedOnUtc) { + $replace = ($forceRefresh -and -not $this.CachedResultWasForceRefresh) -or + ($forceRefresh -eq $this.CachedResultWasForceRefresh -and + $result.ExpiresOnUtc -gt $current.ExpiresOnUtc) + } + } + + if ($replace) { + $this.CachedResult = $result + $this.CachedResultWasForceRefresh = $forceRefresh + $this.ExpiresOn = $result.ExpiresOnUtc + $this.VerifiedTenantId = $result.VerifiedTenantId + } + } + finally { + [System.Threading.Monitor]::Exit($this.CacheLock) + } } - hidden [void] CacheResult([GraphTokenResult]$result) { - $this.CachedResult = $result - $this.ExpiresOn = $result.ExpiresOnUtc - $this.VerifiedTenantId = $result.VerifiedTenantId + [void] AdoptSharedResult([GraphTokenResult]$result, [bool]$forceRefresh) { + $currentRunspace = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace + $currentRunspaceId = if ($null -eq $currentRunspace) { [guid]::Empty } else { $currentRunspace.InstanceId } + if ($currentRunspaceId -ne $this.CreationRunspaceId) { + throw [System.InvalidOperationException]::new( + 'This legacy PowerShell token source is bound to the runspace where its context was created. ' + + 'Cross-runspace context use is disabled because PowerShell-class token acquisition can hang; ' + + 'the compiled GraphKit.Auth token source is required for that contract.' + ) + } + + if ($null -eq $result) { + throw [System.ArgumentNullException]::new('result') + } + + if (-not [string]::Equals( + [string] $result.CredentialGeneration, + [string] $this.CredentialGeneration, + [System.StringComparison]::Ordinal)) { + throw [System.InvalidOperationException]::new( + 'Refusing to adopt a shared token result from a different credential generation.' + ) + } + + $this.CacheResult($result, $forceRefresh) } } class ConfidentialClientTokenSource : GraphTokenSourceBase { hidden [scriptblock] $BuilderFactory hidden [object] $Application + hidden [object] $ApplicationLock ConfidentialClientTokenSource([scriptblock]$builderFactory, [string]$authMode, [string]$audience, [string]$clientId, [string]$generation) { $this.BuilderFactory = $builderFactory + $this.ApplicationLock = [object]::new() $this.AuthMode = $authMode $this.Audience = $audience $this.ClientId = $clientId @@ -109,20 +200,46 @@ class ConfidentialClientTokenSource : GraphTokenSourceBase { } hidden [object] GetApplication() { - if ($null -eq $this.Application) { - $this.Application = & $this.BuilderFactory + [System.Threading.Monitor]::Enter($this.ApplicationLock) + try { + if ($null -eq $this.Application) { + $candidate = & $this.BuilderFactory + if ($null -eq $candidate) { + throw [System.InvalidOperationException]::new( + 'The confidential-client application factory returned no application.' + ) + } + $this.Application = $candidate + } + return $this.Application + } + finally { + [System.Threading.Monitor]::Exit($this.ApplicationLock) } - return $this.Application } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { - if (-not $forceRefresh -and $this.HasValidCachedToken()) { - return $this.CachedResult + $currentRunspace = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace + $currentRunspaceId = if ($null -eq $currentRunspace) { [guid]::Empty } else { $currentRunspace.InstanceId } + if ($currentRunspaceId -ne $this.CreationRunspaceId) { + throw [System.InvalidOperationException]::new( + 'This legacy PowerShell token source is bound to the runspace where its context was created. ' + + 'Cross-runspace context use is disabled because PowerShell-class token acquisition can hang; ' + + 'the compiled GraphKit.Auth token source is required for that contract.' + ) + } + + if (-not $forceRefresh) { + $cached = $this.GetValidCachedToken() + if ($null -ne $cached) { + return $cached + } } $app = $this.GetApplication() $scopes = [string[]]@("$($this.Audience)/.default") - $authResult = $app.AcquireTokenForClient($scopes).ExecuteAsync($cancellation).GetAwaiter().GetResult() + $builder = $app.AcquireTokenForClient($scopes).WithForceRefresh($forceRefresh) + $authResult = $builder.ExecuteAsync($cancellation).GetAwaiter().GetResult() $result = [GraphTokenResult]::new() $result.AccessToken = $authResult.AccessToken @@ -134,7 +251,7 @@ class ConfidentialClientTokenSource : GraphTokenSourceBase { $result.TokenFingerprint = Get-GraphFingerprint -Value $authResult.AccessToken $result.CredentialGeneration = $this.CredentialGeneration - $this.CacheResult($result) + $this.CacheResult($result, $forceRefresh) return $result } } @@ -142,9 +259,11 @@ class ConfidentialClientTokenSource : GraphTokenSourceBase { class ManagedIdentityTokenSource : GraphTokenSourceBase { hidden [scriptblock] $BuilderFactory hidden [object] $Application + hidden [object] $ApplicationLock ManagedIdentityTokenSource([scriptblock]$builderFactory, [string]$audience, [string]$clientId, [string]$generation) { $this.BuilderFactory = $builderFactory + $this.ApplicationLock = [object]::new() $this.AuthMode = 'ManagedIdentity' $this.Audience = $audience $this.ClientId = $clientId @@ -153,20 +272,46 @@ class ManagedIdentityTokenSource : GraphTokenSourceBase { } hidden [object] GetApplication() { - if ($null -eq $this.Application) { - $this.Application = & $this.BuilderFactory + [System.Threading.Monitor]::Enter($this.ApplicationLock) + try { + if ($null -eq $this.Application) { + $candidate = & $this.BuilderFactory + if ($null -eq $candidate) { + throw [System.InvalidOperationException]::new( + 'The managed-identity application factory returned no application.' + ) + } + $this.Application = $candidate + } + return $this.Application + } + finally { + [System.Threading.Monitor]::Exit($this.ApplicationLock) } - return $this.Application } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { - if (-not $forceRefresh -and $this.HasValidCachedToken()) { - return $this.CachedResult + $currentRunspace = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace + $currentRunspaceId = if ($null -eq $currentRunspace) { [guid]::Empty } else { $currentRunspace.InstanceId } + if ($currentRunspaceId -ne $this.CreationRunspaceId) { + throw [System.InvalidOperationException]::new( + 'This legacy PowerShell token source is bound to the runspace where its context was created. ' + + 'Cross-runspace context use is disabled because PowerShell-class token acquisition can hang; ' + + 'the compiled GraphKit.Auth token source is required for that contract.' + ) + } + + if (-not $forceRefresh) { + $cached = $this.GetValidCachedToken() + if ($null -ne $cached) { + return $cached + } } $app = $this.GetApplication() $scope = "$($this.Audience)/.default" - $authResult = $app.AcquireTokenForManagedIdentity($scope).ExecuteAsync($cancellation).GetAwaiter().GetResult() + $builder = $app.AcquireTokenForManagedIdentity($scope).WithForceRefresh($forceRefresh) + $authResult = $builder.ExecuteAsync($cancellation).GetAwaiter().GetResult() $result = [GraphTokenResult]::new() $result.AccessToken = $authResult.AccessToken @@ -178,7 +323,7 @@ class ManagedIdentityTokenSource : GraphTokenSourceBase { $result.TokenFingerprint = Get-GraphFingerprint -Value $authResult.AccessToken $result.CredentialGeneration = $this.CredentialGeneration - $this.CacheResult($result) + $this.CacheResult($result, $forceRefresh) return $result } } @@ -196,8 +341,21 @@ class ProviderTokenSource : GraphTokenSourceBase { } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { - if (-not $forceRefresh -and $this.HasValidCachedToken()) { - return $this.CachedResult + $currentRunspace = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace + $currentRunspaceId = if ($null -eq $currentRunspace) { [guid]::Empty } else { $currentRunspace.InstanceId } + if ($currentRunspaceId -ne $this.CreationRunspaceId) { + throw [System.InvalidOperationException]::new( + 'This legacy PowerShell token source is bound to the runspace where its context was created. ' + + 'Cross-runspace context use is disabled because PowerShell-class token acquisition can hang; ' + + 'the compiled GraphKit.Auth token source is required for that contract.' + ) + } + + if (-not $forceRefresh) { + $cached = $this.GetValidCachedToken() + if ($null -ne $cached) { + return $cached + } } $provided = & $this.Provider @@ -244,7 +402,7 @@ class ProviderTokenSource : GraphTokenSourceBase { # Only cache a provider token that carries an explicit future expiry; a # token with no expiry is never reused and forces a fresh provider call. if ($expires -gt [System.DateTimeOffset]::UtcNow) { - $this.CacheResult($result) + $this.CacheResult($result, $forceRefresh) } return $result } @@ -262,11 +420,22 @@ class FixedBearerTokenSource : GraphTokenSourceBase { } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { + $currentRunspace = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace + $currentRunspaceId = if ($null -eq $currentRunspace) { [guid]::Empty } else { $currentRunspace.InstanceId } + if ($currentRunspaceId -ne $this.CreationRunspaceId) { + throw [System.InvalidOperationException]::new( + 'This legacy PowerShell token source is bound to the runspace where its context was created. ' + + 'Cross-runspace context use is disabled because PowerShell-class token acquisition can hang; ' + + 'the compiled GraphKit.Auth token source is required for that contract.' + ) + } + if ($forceRefresh) { throw [System.InvalidOperationException]::new('A fixed bearer token cannot be refreshed. Supply a new token (a new context) instead of forcing a refresh on an unrefreshable source.') } - if ($null -eq $this.CachedResult) { + $cached = $this.GetCachedToken() + if ($null -eq $cached) { $result = [GraphTokenResult]::new() $result.AccessToken = $this.Bearer $result.ExpiresOnUtc = [System.DateTimeOffset]::MinValue @@ -276,25 +445,88 @@ class FixedBearerTokenSource : GraphTokenSourceBase { $result.VerifiedTenantId = $null $result.TokenFingerprint = Get-GraphFingerprint -Value $this.Bearer $result.CredentialGeneration = $this.CredentialGeneration - $this.CachedResult = $result + $this.CacheResult($result, $false) + $cached = $this.GetCachedToken() } - return $this.CachedResult + return $cached } } class GraphTokenFlight { - [System.Threading.ManualResetEventSlim] $Done - [object] $Result - [System.Exception] $Error + [System.Threading.Tasks.TaskCompletionSource[object]] $Completion + [bool] $LeaderCancellationRequested + hidden [int] $WaiterCount + hidden [object] $WaiterCountLock GraphTokenFlight() { - $this.Done = [System.Threading.ManualResetEventSlim]::new($false) + $this.WaiterCountLock = [object]::new() + $this.WaiterCount = 0 + $this.Completion = [System.Threading.Tasks.TaskCompletionSource[object]]::new( + [System.Threading.Tasks.TaskCreationOptions]::RunContinuationsAsynchronously + ) + $this.LeaderCancellationRequested = $false + } +} + +function Add-GraphTokenFlightWaiter { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [GraphTokenFlight] $Flight + ) + + [System.Threading.Monitor]::Enter($Flight.WaiterCountLock) + try { + # Observation must remain behavior-neutral even at the diagnostic bound. + if ($Flight.WaiterCount -lt [int]::MaxValue) { + $Flight.WaiterCount = $Flight.WaiterCount + 1 + } + } + finally { + [System.Threading.Monitor]::Exit($Flight.WaiterCountLock) + } +} + +function Remove-GraphTokenFlightWaiter { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [GraphTokenFlight] $Flight + ) + + [System.Threading.Monitor]::Enter($Flight.WaiterCountLock) + try { + # A diagnostic invariant cannot replace the caller's primary outcome. + if ($Flight.WaiterCount -gt 0) { + $Flight.WaiterCount = $Flight.WaiterCount - 1 + } + } + finally { + [System.Threading.Monitor]::Exit($Flight.WaiterCountLock) + } +} + +function Get-GraphTokenFlightWaiterCount { + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] + [GraphTokenFlight] $Flight + ) + + [System.Threading.Monitor]::Enter($Flight.WaiterCountLock) + try { + return $Flight.WaiterCount + } + finally { + [System.Threading.Monitor]::Exit($Flight.WaiterCountLock) } } class GraphTokenFlightRegistry { static [System.Collections.Concurrent.ConcurrentDictionary[string, GraphTokenFlight]] $Flights = [System.Collections.Concurrent.ConcurrentDictionary[string, GraphTokenFlight]]::new() + static [object] $RemovalLock = [object]::new() } <# @@ -325,17 +557,105 @@ function Get-GraphFingerprint { } } +function Get-GraphPfxSnapshot { + <# + Read a persisted PFX once and bind its canonical path, exact bytes and + SHA-256 identity together. The compiled bridge imports the returned + Bytes directly. The legacy factory seam instead reopens the bound + canonical path after the snapshot bytes have been zeroed. + #> + [CmdletBinding()] + [OutputType([System.Management.Automation.PSCustomObject])] + param( + [Parameter(Mandatory)] + [string] $Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw 'A persisted PFX path is empty; GraphKit cannot derive its credential generation.' + } + + try { + # .NET's GetFullPath resolves against Environment.CurrentDirectory, + # which PowerShell does not update for Set-Location. Resolve through + # the PowerShell path API so a relative PFX means relative to the + # caller's actual FileSystem location at context construction. + $provider = $null + $drive = $null + $canonicalPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $Path, + [ref] $provider, + [ref] $drive + ) + if ($null -eq $provider -or $provider.Name -ne 'FileSystem') { + $providerName = if ($null -eq $provider) { '' } else { $provider.Name } + throw "PFX paths must use the FileSystem provider; '$Path' resolved through '$providerName'." + } + $bytes = [System.IO.File]::ReadAllBytes($canonicalPath) + } + catch { + throw "The PFX at '$Path' could not be read to derive its credential generation: $($_.Exception.Message)" + } + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $digest = ([System.BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant() + } + finally { + $sha.Dispose() + } + + return [pscustomobject] @{ + Path = $canonicalPath + Bytes = $bytes + Sha256 = $digest + } +} + <# Private: derive a non-secret credential-generation string from a profile. The generation changes whenever the underlying vault version, certificate or provider generation changes, but never embeds a secret value. #> +function New-GraphCredentialGenerationValue { + <# + Build an unambiguous, non-secret credential identity. Raw delimiter + concatenation is unsafe because distinct persisted references can contain + `|` and collapse to the same string. Each field is therefore length- + prefixed; the internal kind is fixed by GraphKit and versioned as `g1`. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string] $Kind, + + [AllowNull()] + [object[]] $Components + ) + + $builder = [System.Text.StringBuilder]::new("g1|$Kind") + foreach ($component in @($Components)) { + $value = if ($null -eq $component) { '' } else { [string] $component } + $null = $builder.Append('|').Append($value.Length).Append(':').Append($value) + } + return $builder.ToString() +} + function Get-GraphCredentialGeneration { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] - [hashtable] $TenantProfile + [hashtable] $TenantProfile, + + # Internal snapshot seam: the PFX resolver supplies the already-bound + # digest/path so generation derivation never re-resolves a caller's + # relative path. The compiled path imports the captured bytes, while + # the legacy compatibility factory reopens the captured canonical path. + [string] $PfxContentSha256, + + [string] $PfxCanonicalPath ) $authMethod = [string]$TenantProfile.AuthMethod @@ -343,32 +663,81 @@ function Get-GraphCredentialGeneration { switch ($authMethod) { 'ClientSecret' { - return "ClientSecret|$($credential.VaultName)|$($credential.SecretName)|$($credential.Version)" + return New-GraphCredentialGenerationValue -Kind 'ClientSecret' -Components @( + $credential.VaultName, + $credential.SecretName, + $credential.Version + ) } 'Certificate' { if ($null -ne $credential.PfxPath) { $passwordRef = $credential.Password - return "Certificate|PFX|$($credential.PfxPath)|$($passwordRef.VaultName)|$($passwordRef.SecretName)" + $contentHash = $PfxContentSha256 + $path = $PfxCanonicalPath + if ([string]::IsNullOrEmpty($contentHash) -or [string]::IsNullOrEmpty($path)) { + $snapshot = Get-GraphPfxSnapshot -Path ([string] $credential.PfxPath) + try { + $contentHash = [string] $snapshot.Sha256 + $path = [string] $snapshot.Path + } + finally { + if ($snapshot.Bytes -is [byte[]]) { + [System.Security.Cryptography.CryptographicOperations]::ZeroMemory( + [byte[]] $snapshot.Bytes + ) + } + } + } + return New-GraphCredentialGenerationValue -Kind 'Certificate.PFX' -Components @( + $path, + "sha256:$contentHash", + $passwordRef.VaultName, + $passwordRef.SecretName, + $passwordRef.Version + ) } if ($null -ne $credential.CertificateName) { - return "Certificate|Vault|$($credential.VaultName)|$($credential.CertificateName)|$($credential.Version)" + $passwordRef = $credential.Password + return New-GraphCredentialGenerationValue -Kind 'Certificate.Vault' -Components @( + $credential.VaultName, + $credential.CertificateName, + $credential.Version, + $passwordRef.VaultName, + $passwordRef.SecretName, + $passwordRef.Version + ) } if ($null -ne $credential.StoreLocation) { - return "Certificate|Store|$($credential.StoreLocation)|$($credential.StoreName)|$($credential.Thumbprint)|$($credential.Subject)" + return New-GraphCredentialGenerationValue -Kind 'Certificate.Store' -Components @( + $credential.StoreLocation, + $credential.StoreName, + $credential.Thumbprint, + $credential.Subject + ) } - return "Certificate|Injected|$($credential.Thumbprint)" + return New-GraphCredentialGenerationValue -Kind 'Certificate.Injected' -Components @( + $credential.Thumbprint + ) } 'BearerToken' { - return "BearerToken|$($credential.VaultName)|$($credential.SecretName)|$($credential.Version)" + return New-GraphCredentialGenerationValue -Kind 'BearerToken' -Components @( + $credential.VaultName, + $credential.SecretName, + $credential.Version + ) } 'ManagedIdentity' { if ($null -ne $credential.ClientId -and $credential.ClientId -ne '') { - return "ManagedIdentity|$($credential.ClientId)" + return New-GraphCredentialGenerationValue -Kind 'ManagedIdentity' -Components @( + $credential.ClientId + ) } - return 'ManagedIdentity|system' + return New-GraphCredentialGenerationValue -Kind 'ManagedIdentity' -Components @('system') } 'Provider' { - return "Provider|$($credential.Identity)" + return New-GraphCredentialGenerationValue -Kind 'Provider' -Components @( + $credential.Identity + ) } default { throw "Unknown AuthMethod '$authMethod'." @@ -376,6 +745,58 @@ function Get-GraphCredentialGeneration { } } +function Test-GraphCredentialReferencePinned { + <# + A versioned vault slot or certificate thumbprint is immutable enough to + participate in cross-context token sharing. Mutable selectors (an + unversioned secret name or certificate subject) are context-scoped so a + rotation can never make a new context adopt an old context's flight. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [hashtable] $TenantProfile + ) + + $credential = $TenantProfile.Credential + switch ([string] $TenantProfile.AuthMethod) { + 'ClientSecret' { + return -not [string]::IsNullOrEmpty([string] $credential.Version) + } + 'BearerToken' { + return [string]::IsNullOrEmpty([string] $credential.Token) -and + -not [string]::IsNullOrEmpty([string] $credential.Version) + } + 'Certificate' { + if (-not [string]::IsNullOrEmpty([string] $credential.PfxPath)) { + return -not [string]::IsNullOrEmpty([string] $credential.Password.Version) + } + if (-not [string]::IsNullOrEmpty([string] $credential.CertificateName)) { + $materialPinned = -not [string]::IsNullOrEmpty([string] $credential.Version) + $hasPassword = $null -ne $credential.Password -and + (-not [string]::IsNullOrEmpty([string] $credential.Password.SecretName) -or + -not [string]::IsNullOrEmpty([string] $credential.Password.VaultName)) + $passwordPinned = -not $hasPassword -or + -not [string]::IsNullOrEmpty([string] $credential.Password.Version) + return $materialPinned -and $passwordPinned + } + if (-not [string]::IsNullOrEmpty([string] $credential.Thumbprint)) { + return $true + } + if (-not [string]::IsNullOrEmpty([string] $credential.Subject)) { + return $false + } + # Caller-injected certificates are identified by thumbprint in the + # synthetic profile and provider identities already carry a nonce. + return $true + } + default { + return $true + } + } +} + <# Private: build the canonical acquisition tuple key. GUIDs and hosts are lower-cased and scopes are sorted and de-duplicated so that equivalent @@ -430,10 +851,42 @@ function Get-GraphTokenAcquisitionKey { return ($parts -join '|') } +<# + Private: remove a completed flight only when the key still names that exact + instance. TryRemove(key, out) alone can remove a newer replacement flight if + a cancelled leader completes while a live waiter starts the replacement. +#> +function Remove-GraphTokenFlightIfCurrent { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [string] $Key, + [Parameter(Mandatory)] + [GraphTokenFlight] $Flight + ) + + [System.Threading.Monitor]::Enter([GraphTokenFlightRegistry]::RemovalLock) + try { + $current = [GraphTokenFlight] $null + if (-not [GraphTokenFlightRegistry]::Flights.TryGetValue($Key, [ref] $current) -or + -not [object]::ReferenceEquals($current, $Flight)) { + return $false + } + + $removed = [GraphTokenFlight] $null + return [GraphTokenFlightRegistry]::Flights.TryRemove($Key, [ref] $removed) + } + finally { + [System.Threading.Monitor]::Exit([GraphTokenFlightRegistry]::RemovalLock) + } +} + <# Private: single-flight acquisition per canonical tuple key. The first caller - runs the acquisition script and everyone else awaits the same result; a - failure surfaces to every waiter and is not cached. + runs the acquisition script and everyone else awaits the same result. A + non-cancellation failure surfaces to every waiter and is not cached; if the + leader is cancelled, a still-live waiter starts or joins a replacement flight. #> function Invoke-GraphTokenSingleFlight { [CmdletBinding()] @@ -442,42 +895,109 @@ function Invoke-GraphTokenSingleFlight { [Parameter(Mandatory)] [string] $Key, [Parameter(Mandatory)] - [scriptblock] $AcquireScript + [scriptblock] $AcquireScript, + [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None ) - $flight = [GraphTokenFlight]::new() + while ($true) { + $flight = [GraphTokenFlight]::new() - if ([GraphTokenFlightRegistry]::Flights.TryAdd($Key, $flight)) { - try { - $flight.Result = & $AcquireScript - } - catch { - $flight.Error = $_.Exception + if ([GraphTokenFlightRegistry]::Flights.TryAdd($Key, $flight)) { + try { + $result = & $AcquireScript + $null = $flight.Completion.TrySetResult($result) + return $result + } + catch { + $failure = $_.Exception + $candidate = $failure + $isCancellationFailure = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellationFailure = $true + break + } + $candidate = $candidate.InnerException + } + + # Record the leader's caller-specific cancellation disposition + # before publishing completion. A provider may throw its own OCE + # while the leader token remains live; followers must fan that out + # as one shared failure rather than multiplying provider calls. + $flight.LeaderCancellationRequested = + $CancellationToken.IsCancellationRequested -and $isCancellationFailure + $null = $flight.Completion.TrySetException($failure) + # The leader observes its own failed task even when no waiter was + # present, preventing an unobserved-task exception later. + $null = $flight.Completion.Task.Exception + throw + } + finally { + $null = Remove-GraphTokenFlightIfCurrent -Key $Key -Flight $flight + } } - finally { - $flight.Done.Set() - $removed = [GraphTokenFlight] $null - $null = [GraphTokenFlightRegistry]::Flights.TryRemove($Key, [ref] $removed) + + $existing = [GraphTokenFlight] $null + if (-not [GraphTokenFlightRegistry]::Flights.TryGetValue($Key, [ref] $existing)) { + # The leader completed and removed the entry between TryAdd and + # TryGetValue. Retry the registry operation; never bypass the flight + # with a direct duplicate acquisition. + continue } - } - else { - $existing = [GraphTokenFlightRegistry]::Flights[$Key] - if ($null -eq $existing) { - # Narrow race: the leader removed the entry between our failed - # TryAdd and the lookup. Fall back to acquiring directly. - return & $AcquireScript + + Add-GraphTokenFlightWaiter -Flight $existing + try { + try { + return $existing.Completion.Task.WaitAsync($CancellationToken).GetAwaiter().GetResult() + } + catch { + $candidate = $_.Exception + $sharedAcquisitionWasCancelled = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $sharedAcquisitionWasCancelled = $true + break + } + $candidate = $candidate.InnerException + } + + $leaderCallerWasCancelled = + $sharedAcquisitionWasCancelled -and $existing.LeaderCancellationRequested + + if (-not $leaderCallerWasCancelled -or $CancellationToken.IsCancellationRequested) { + throw + } + + # A leader's caller-specific cancellation must not poison live + # waiters. Remove only the exact completed flight (never a newer + # replacement added for the same key), then let this caller compete + # to lead or join the replacement acquisition. + $null = Remove-GraphTokenFlightIfCurrent -Key $Key -Flight $existing + continue + } } - $existing.Done.Wait() - if ($null -ne $existing.Error) { - throw $existing.Error + finally { + Remove-GraphTokenFlightWaiter -Flight $existing } - return $existing.Result } +} - if ($null -ne $flight.Error) { - throw $flight.Error - } - return $flight.Result +<# + Private: separate ordinary and forced refresh work for one canonical tuple. + A forced waiter must never join an ordinary acquisition that can legally + return the token Graph has just rejected with 401. +#> +function Get-GraphTokenFlightKey { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string] $AcquisitionKey, + [bool] $ForceRefresh = $false + ) + + $mode = if ($ForceRefresh) { 'refresh' } else { 'ordinary' } + return "$AcquisitionKey|flight:$mode" } <# @@ -499,32 +1019,74 @@ function New-GraphTokenSource { $authMethod = [string]$Profile.AuthMethod $audience = [string]$Cloud.Resource $clientId = $Profile.ClientId - $generation = Get-GraphCredentialGeneration -TenantProfile $Profile + if ($null -eq $MsalFactory) { + return New-GraphAuthTokenSource -Profile $Profile -Cloud $Cloud + } + + $factoryProfile = $Profile + $resolvedMsalFactory = $MsalFactory + if ($authMethod -eq 'Certificate' -and + -not [string]::IsNullOrEmpty([string] $Profile.Credential.PfxPath)) { + # Capture the canonical path at context/source construction. Lazy vault + # resolution may occur after Set-Location; it must reopen the same path + # whose bytes were bound into this immutable source's generation. + $snapshot = Get-GraphPfxSnapshot -Path ([string] $Profile.Credential.PfxPath) + try { + $generation = Get-GraphCredentialGeneration -TenantProfile $Profile ` + -PfxContentSha256 ([string] $snapshot.Sha256) ` + -PfxCanonicalPath ([string] $snapshot.Path) + $factoryProfile = $Profile.Clone() + $factoryCredential = $Profile.Credential.Clone() + $factoryCredential.PfxPath = [string] $snapshot.Path + $factoryProfile.Credential = $factoryCredential + $callerFactory = $MsalFactory + $canonicalFactoryProfile = $factoryProfile + $factoryAcceptsProfile = $null -ne $callerFactory.Ast.ParamBlock -and + $callerFactory.Ast.ParamBlock.Parameters.Count -gt 0 + $resolvedMsalFactory = { + if ($factoryAcceptsProfile) { + & $callerFactory $canonicalFactoryProfile + } + else { + & $callerFactory + } + }.GetNewClosure() + } + finally { + if ($snapshot.Bytes -is [byte[]]) { + [System.Security.Cryptography.CryptographicOperations]::ZeroMemory( + [byte[]] $snapshot.Bytes + ) + } + } + } + else { + $generation = Get-GraphCredentialGeneration -TenantProfile $Profile + } + if (-not (Test-GraphCredentialReferencePinned -TenantProfile $Profile)) { + # Never hash secret/password material to discover an unversioned + # rotation. Instead, isolate mutable selectors to this immutable + # context. Versioned references still coalesce across contexts. + $generation = "$generation|context:$([guid]::NewGuid().ToString('N'))" + } switch ($authMethod) { 'Certificate' { - # -MsalFactory remains injectable for tests; when absent the REAL factory is - # used. It previously defaulted to a scriptblock that threw, which meant the - # module could not authenticate by any means outside a test. - $factory = $MsalFactory - if ($null -eq $factory) { - $factory = New-GraphMsalApplicationFactory -Profile $Profile -Cloud $Cloud - } - return [ConfidentialClientTokenSource]::new($factory, 'Certificate', $audience, $clientId, $generation) + # A caller-supplied factory selects this legacy same-runspace compatibility + # path. Built-in authentication returned through GraphKit.Auth above. + return [ConfidentialClientTokenSource]::new( + $resolvedMsalFactory, 'Certificate', $audience, $clientId, $generation) } 'ClientSecret' { - $factory = $MsalFactory - if ($null -eq $factory) { - $factory = New-GraphMsalApplicationFactory -Profile $Profile -Cloud $Cloud - } - return [ConfidentialClientTokenSource]::new($factory, 'ClientSecret', $audience, $clientId, $generation) + return [ConfidentialClientTokenSource]::new( + $MsalFactory, 'ClientSecret', $audience, $clientId, $generation) } 'ManagedIdentity' { - $factory = $MsalFactory - if ($null -eq $factory) { - $factory = New-GraphManagedIdentityFactory -Profile $Profile - } - return [ManagedIdentityTokenSource]::new($factory, $audience, $clientId, $generation) + return [ManagedIdentityTokenSource]::new( + $MsalFactory, + $audience, + ([string] $Profile.Credential.ClientId), + $generation) } 'BearerToken' { # An inline token (context-only, never persisted) wins; otherwise resolve the diff --git a/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 b/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 new file mode 100644 index 0000000..30b1e7e --- /dev/null +++ b/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 @@ -0,0 +1,282 @@ +<# + Private: validate the successor profile identity discriminator shared by + registration, metadata validation, and context construction. +#> +function New-GraphTenantProfileAuthSchemaErrorRecord { + [CmdletBinding()] + [OutputType([System.Management.Automation.ErrorRecord])] + param( + [Parameter(Mandatory)] + [string] $Message + ) + + return [System.Management.Automation.ErrorRecord]::new( + [System.ArgumentException]::new($Message), + 'GraphKit.InvalidTenantProfileAuthSchema', + [System.Management.Automation.ErrorCategory]::InvalidData, + $null + ) +} + +function Assert-GraphTenantProfileAuthSchema { + [CmdletBinding()] + [OutputType([System.Management.Automation.PSCustomObject])] + param( + [Parameter(Mandatory)] + [hashtable] $Profile + ) + + $authMethod = [string] $Profile.AuthMethod + $credential = if ($Profile.Credential -is [hashtable]) { + [hashtable] $Profile.Credential + } + else { + @{} + } + + $topLevelClientId = [string] $Profile.ClientId + $nestedClientId = [string] $credential.ClientId + $hasTopLevelClientId = -not [string]::IsNullOrWhiteSpace($topLevelClientId) + $hasNonNullTopLevelClientId = $Profile.ContainsKey('ClientId') -and + $null -ne $Profile.ClientId + $hasNestedClientId = $credential.ContainsKey('ClientId') + $applicationClientId = $null + $managedIdentityClientId = $null + + $unsupportedSelectors = [System.Collections.Generic.List[string]]::new() + foreach ($name in @( + 'ManagedIdentityClientId', + 'ApplicationClientId', + 'UserAssignedClientId', + 'IdentitySelector', + 'ObjectId', + 'ResourceId', + 'ManagedIdentityObjectId', + 'ManagedIdentityResourceId' + )) { + if ($Profile.ContainsKey($name)) { + $unsupportedSelectors.Add($name) + } + if ($credential.ContainsKey($name)) { + $unsupportedSelectors.Add("Credential.$name") + } + } + if ($unsupportedSelectors.Count -ne 0) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod '$authMethod' contains unsupported identity selector metadata ($($unsupportedSelectors -join ', ')). Re-register the profile using only top-level ClientId for Certificate/ClientSecret or Credential.ClientId for user-assigned ManagedIdentity.") + } + + switch ($authMethod) { + { $_ -in @('Certificate', 'ClientSecret') } { + if (-not $hasTopLevelClientId) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod '$authMethod' requires a non-empty, non-zero top-level ClientId. Re-register the profile with -ClientId.") + } + if ($hasNestedClientId) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod '$authMethod' must not declare ManagedIdentityClientId or Credential.ClientId. Re-register the profile with only the top-level application ClientId.") + } + + $parsed = [guid]::Empty + if (-not [guid]::TryParse($topLevelClientId, [ref] $parsed)) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod '$authMethod' ClientId '$topLevelClientId' is not a valid GUID. Re-register the profile with a non-zero application ClientId.") + } + if ($parsed -eq [guid]::Empty) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod '$authMethod' requires a non-zero ClientId. Re-register the profile with the application ClientId.") + } + $applicationClientId = $parsed.ToString('D') + break + } + 'ManagedIdentity' { + if ($hasNonNullTopLevelClientId) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod 'ManagedIdentity' must not declare top-level ClientId. Re-register the profile and use -ManagedIdentityClientId only for a user-assigned identity.") + } + $selector = if ($hasNestedClientId) { + $nestedClientId + } + else { + $null + } + if ($null -ne $selector) { + if ([string]::IsNullOrWhiteSpace($selector)) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message 'ManagedIdentity Credential.ClientId must be a non-empty, non-zero GUID when the key is present. Re-register the profile or omit Credential.ClientId entirely for system-assigned identity.') + } + $parsed = [guid]::Empty + if (-not [guid]::TryParse($selector, [ref] $parsed)) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "ManagedIdentityClientId / Credential.ClientId '$selector' is not a valid GUID. Re-register the profile with a non-zero user-assigned managed-identity client GUID.") + } + if ($parsed -eq [guid]::Empty) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message 'ManagedIdentity requires a non-zero ManagedIdentityClientId / Credential.ClientId for user-assigned identity. Re-register the profile or omit the selector for system-assigned identity.') + } + $managedIdentityClientId = $parsed.ToString('D') + } + break + } + 'BearerToken' { + if ($hasNonNullTopLevelClientId -or $hasNestedClientId) { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod 'BearerToken' must not declare ClientId, ManagedIdentityClientId, or Credential.ClientId. Re-register the profile without a client identity selector.") + } + break + } + default { + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "Unknown AuthMethod '$authMethod'. Re-register the profile with Certificate, ClientSecret, ManagedIdentity, or BearerToken.") + } + } + + return [pscustomobject] @{ + ApplicationClientId = $applicationClientId + ManagedIdentityClientId = $managedIdentityClientId + } +} + +<# + Private: the sole PowerShell-to-GraphKit.Auth descriptor bridge. Credential + material stays PowerShell-owned until the exact typed CreateSource call. +#> +function New-GraphAuthTokenSource { + [CmdletBinding()] + [OutputType([GraphKit.Auth.IGraphTokenSource])] + param( + [Parameter(Mandatory)] + [hashtable] $Profile, + + [Parameter(Mandatory)] + [hashtable] $Cloud, + + [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate + ) + + if ($null -eq $script:GraphKitAuthHost) { + throw [System.InvalidOperationException]::new('The module-scoped GraphKit.Auth host is unavailable.') + } + + $schema = Assert-GraphTenantProfileAuthSchema -Profile $Profile + $authMethod = [string] $Profile.AuthMethod + $material = $null + $ownsMaterial = $false + $generation = $null + $credential = $null + $request = $null + $ownershipCeded = $false + + try { + if ($null -ne $Certificate) { + if ($authMethod -ne 'Certificate') { + throw [System.ArgumentException]::new('An injected certificate may only be used with Certificate authentication.', 'Certificate') + } + $material = $Certificate + $generation = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ Thumbprint = $Certificate.Thumbprint } + } + } + elseif ($authMethod -eq 'BearerToken' -and + -not [string]::IsNullOrWhiteSpace([string] $Profile.Credential.Token)) { + $material = [string] $Profile.Credential.Token + $generation = Get-GraphCredentialGeneration -TenantProfile $Profile + } + else { + $resolved = Get-GraphVaultCredential -Credential $Profile.Credential -AuthMethod $authMethod + $material = $resolved.Material + $ownsMaterial = [bool] $resolved.OwnsMaterial + $generation = [string] $resolved.CredentialGeneration + if ([string]::IsNullOrWhiteSpace($generation)) { + $generation = Get-GraphCredentialGeneration -TenantProfile $Profile + } + } + + if (-not (Test-GraphCredentialReferencePinned -TenantProfile $Profile) -and + $null -eq $Certificate) { + $generation = "$generation|context:$([guid]::NewGuid().ToString('N'))" + } + if ([string]::IsNullOrWhiteSpace($generation)) { + throw [System.InvalidOperationException]::new('Credential generation resolution returned an empty value.') + } + + switch ($authMethod) { + 'Certificate' { + $credential = [GraphKit.Auth.CertificateCredential]::new( + [System.Security.Cryptography.X509Certificates.X509Certificate2] $material, + $ownsMaterial) + $clientId = [Nullable[guid]] ([guid] $schema.ApplicationClientId) + $mode = [GraphKit.Auth.GraphAuthMode]::Certificate + } + 'ClientSecret' { + $credential = [GraphKit.Auth.ClientSecretCredential]::new( + [Security.SecureString] $material, + $ownsMaterial) + $clientId = [Nullable[guid]] ([guid] $schema.ApplicationClientId) + $mode = [GraphKit.Auth.GraphAuthMode]::ClientSecret + } + 'ManagedIdentity' { + $managedIdentitySelector = if ([string]::IsNullOrEmpty([string] $schema.ManagedIdentityClientId)) { + $null + } + else { + [string] $schema.ManagedIdentityClientId + } + # PowerShell's direct constructor binder coerces a null string + # argument to String.Empty. Invoke the exact ABI constructor + # through reflection so system-assigned identity remains a + # genuine null discriminator. + $managedIdentityArguments = [object[]]::new(1) + $managedIdentityArguments[0] = $managedIdentitySelector + $credential = [GraphKit.Auth.ManagedIdentityCredential].GetConstructor( + [type[]] @([string])).Invoke($managedIdentityArguments) + $clientId = [Nullable[guid]] $null + $mode = [GraphKit.Auth.GraphAuthMode]::ManagedIdentity + } + 'BearerToken' { + $credential = [GraphKit.Auth.FixedBearerCredential]::new([string] $material) + $clientId = [Nullable[guid]] $null + $mode = [GraphKit.Auth.GraphAuthMode]::BearerToken + } + } + + $request = [GraphKit.Auth.GraphTokenRequest]::new( + [string] $Profile.Environment, + [guid] ([string] $Profile.TenantId), + [uri] $Cloud.Authority, + [uri] $Cloud.Resource, + $clientId, + $mode, + $credential, + $generation) + + if ($ownsMaterial) { + # The default-context host accepts ownership on method entry. From + # this exact point forward it alone decides whether host or provider + # cleanup applies, including when CreateSource throws. + $ownershipCeded = $true + } + $source = $script:GraphKitAuthHost.CreateSource( + [GraphKit.Auth.GraphTokenRequest] $request) + } + catch { + if ($ownsMaterial -and -not $ownershipCeded -and $material -is [IDisposable]) { + try { + $material.Dispose() + } + catch { + throw [GraphKit.Auth.GraphAuthException]::new( + 'credential_material_cleanup_failed', + 'CredentialOwnership', + 'GraphKit.Auth could not clean up credential material after request construction failed before host entry.', + $null, + $null) + } + } + throw + } + + try { + return Register-GraphModuleOwnedResource -Resource $source -OwnedByGraphKit:$true + } + catch { + try { + $source.Dispose() + } + catch { + throw [System.InvalidOperationException]::new( + 'GraphKit.Auth source registration failed and the returned source could not be disposed safely.') + } + throw + } +} diff --git a/source/Private/TokenSources/New-GraphMsalApplication.ps1 b/source/Private/TokenSources/New-GraphMsalApplication.ps1 index 8673c87..9f5301c 100644 --- a/source/Private/TokenSources/New-GraphMsalApplication.ps1 +++ b/source/Private/TokenSources/New-GraphMsalApplication.ps1 @@ -27,7 +27,19 @@ function New-GraphMsalApplicationFactory { # Injected for tests: resolves the profile's credential to material. Defaults to # the real vault-backed resolver. - [scriptblock] $CredentialResolver + [scriptblock] $CredentialResolver, + + # The generation captured when the immutable context/source was built. + # Persisted PFX resolution returns the generation of the exact byte + # snapshot it imported; a mismatch means the path changed underneath the + # context and must never share token/proof identity with the old bytes. + [string] $ExpectedCredentialGeneration, + + # Private test seams. Production uses the loaded MSAL builder and the + # centralized module-lifecycle resource registrar. + [scriptblock] $ApplicationBuilderFactory, + + [scriptblock] $OwnedResourceRegistrar ) $authority = '{0}/{1}' -f ([string] $Cloud.Authority).TrimEnd('/'), [string] $Profile.TenantId @@ -45,49 +57,145 @@ function New-GraphMsalApplicationFactory { # runs at module import, so a version below the tested minimum has already failed the # import before any factory can be built. $vaultResolve = Get-Command -Name Get-GraphVaultCredential -CommandType Function + $ownedResourceRegister = Get-Command -Name Register-GraphModuleOwnedResource -CommandType Function $resolver = $CredentialResolver if ($null -eq $resolver) { $resolver = { param($P) & $vaultResolve -Credential $P.Credential -AuthMethod $P.AuthMethod }.GetNewClosure() } + $builderCreate = $ApplicationBuilderFactory + if ($null -eq $builderCreate) { + $builderCreate = { + param($ApplicationClientId) + [Microsoft.Identity.Client.ConfidentialClientApplicationBuilder]::Create($ApplicationClientId) + } + } + + $resourceRegistrar = $OwnedResourceRegistrar + if ($null -eq $resourceRegistrar) { + $resourceRegistrar = { + param($Resource, [bool] $OwnedByGraphKit) + & $ownedResourceRegister -Resource $Resource -OwnedByGraphKit:$OwnedByGraphKit + }.GetNewClosure() + } + return { $material = & $resolver $profileCopy if ($null -eq $material) { throw "GraphKit could not resolve credential material for tenant '$($profileCopy.TenantId)'." } - $builder = [Microsoft.Identity.Client.ConfidentialClientApplicationBuilder]::Create($clientId) + $ownedResource = $null + $resourceTransferred = $false + $ownedEphemeralMaterial = $null - switch ($authMethod) { - 'Certificate' { - $certificate = $material.Material - if ($certificate -isnot [System.Security.Cryptography.X509Certificates.X509Certificate2]) { - throw "Certificate profile for tenant '$($profileCopy.TenantId)' resolved to '$($certificate.GetType().Name)' rather than an X509Certificate2." + try { + if ([bool] $material.OwnsMaterial -and $material.Material -is [System.IDisposable]) { + if ($authMethod -eq 'Certificate') { + $ownedResource = [System.IDisposable] $material.Material + } + else { + # Client-secret material is copied into MSAL during builder + # configuration and must never enter the module lifetime. + $ownedEphemeralMaterial = [System.IDisposable] $material.Material } - if (-not $certificate.HasPrivateKey) { - throw "The certificate for tenant '$($profileCopy.TenantId)' carries no private key, so it cannot sign a client assertion." + } + + $actualGeneration = [string] $material.CredentialGeneration + $expectedMatchesActual = [string]::Equals( + $ExpectedCredentialGeneration, + $actualGeneration, + [System.StringComparison]::Ordinal + ) + $expectedIsIsolatedActual = $false + if (-not [string]::IsNullOrEmpty($ExpectedCredentialGeneration) -and + -not [string]::IsNullOrEmpty($actualGeneration)) { + $expectedIsIsolatedActual = $ExpectedCredentialGeneration.StartsWith( + "$actualGeneration|context:", + [System.StringComparison]::Ordinal + ) -and + $ExpectedCredentialGeneration.Substring( + ("$actualGeneration|context:").Length + ) -match '^[0-9a-f]{32}$' + } + + if (-not [string]::IsNullOrEmpty($ExpectedCredentialGeneration) -and + ([string]::IsNullOrEmpty($actualGeneration) -or + (-not $expectedMatchesActual -and -not $expectedIsIsolatedActual))) { + if ([string]::IsNullOrEmpty($actualGeneration)) { + throw ( + "Credential material for tenant '$($profileCopy.TenantId)' did not report the generation " + + 'captured when this context was created. Refusing acquisition because material identity cannot be verified.' + ) } - $builder = $builder.WithCertificate($certificate) + throw ( + "Credential material changed after this context was created for tenant '$($profileCopy.TenantId)'. " + + 'Create a new GraphKit context so acquisition and tenant-proof identity use the new credential generation.' + ) } - 'ClientSecret' { - $secret = $material.Material - if ($secret -is [System.Security.SecureString]) { - $secret = [System.Net.NetworkCredential]::new('', $secret).Password + $builder = & $builderCreate $clientId + if ($null -eq $builder) { + throw 'The confidential-client application builder factory returned no builder.' + } + + switch ($authMethod) { + 'Certificate' { + $certificate = $material.Material + if ($certificate -isnot [System.Security.Cryptography.X509Certificates.X509Certificate2]) { + $resolvedType = if ($null -eq $certificate) { '' } else { $certificate.GetType().Name } + throw "Certificate profile for tenant '$($profileCopy.TenantId)' resolved to '$resolvedType' rather than an X509Certificate2." + } + if (-not $certificate.HasPrivateKey) { + throw "The certificate for tenant '$($profileCopy.TenantId)' carries no private key, so it cannot sign a client assertion." + } + $builder = $builder.WithCertificate($certificate) } - if ([string]::IsNullOrEmpty([string] $secret)) { - throw "Client-secret profile for tenant '$($profileCopy.TenantId)' resolved to an empty secret." + + 'ClientSecret' { + $secret = $material.Material + if ($secret -is [System.Security.SecureString]) { + $secret = [System.Net.NetworkCredential]::new('', $secret).Password + } + if ([string]::IsNullOrEmpty([string] $secret)) { + throw "Client-secret profile for tenant '$($profileCopy.TenantId)' resolved to an empty secret." + } + $builder = $builder.WithClientSecret([string] $secret) + } + + default { + throw "New-GraphMsalApplicationFactory does not build confidential clients for AuthMethod '$authMethod'." } - $builder = $builder.WithClientSecret([string] $secret) } - default { - throw "New-GraphMsalApplicationFactory does not build confidential clients for AuthMethod '$authMethod'." + $application = $builder.WithAuthority($authority).Build() + if ($null -eq $application) { + throw 'The confidential-client application builder returned no application.' } - } - return $builder.WithAuthority($authority).Build() + if ($null -ne $ownedResource) { + # Registration is an ownership transfer, not factory output. + # The default registrar returns the resource for convenience; + # suppress it so this factory always emits exactly one object: + # the confidential-client application. + $null = & $resourceRegistrar $ownedResource $true + $resourceTransferred = $true + } + + return $application + } + catch { + if ($null -ne $ownedResource -and -not $resourceTransferred) { + try { $ownedResource.Dispose() } catch { } + } + throw + } + finally { + if ($null -ne $ownedEphemeralMaterial) { + try { $ownedEphemeralMaterial.Dispose() } catch { } + } + } }.GetNewClosure() } diff --git a/source/Private/Transport/GraphTransportResult.ps1 b/source/Private/Transport/GraphTransportResult.ps1 index 3d60b35..09701fd 100644 --- a/source/Private/Transport/GraphTransportResult.ps1 +++ b/source/Private/Transport/GraphTransportResult.ps1 @@ -15,7 +15,14 @@ RequestId The response `request-id` header, when present. TransportException The exception for transport-level failures (timeout, reset, cancellation); $null on a clean response. + Caller/module cancellation carries the internal boolean + marker GraphKit.OperationCancellation in Exception.Data. ResponseReceived $true when HTTP response headers were actually received. + VerifiedTenantId Tenant proven for the exact bearer placed on the request; + never populated from an unverified provider claim. + TokenFingerprint Non-secret fingerprint of the exact bearer placed on the + request. The bearer itself never enters this record. + CredentialGeneration Non-secret credential generation for that bearer. #> class GraphTransportResult { [int] $StatusCode @@ -24,4 +31,7 @@ class GraphTransportResult { [string] $RequestId [object] $TransportException [bool] $ResponseReceived + [string] $VerifiedTenantId + [string] $TokenFingerprint + [string] $CredentialGeneration } diff --git a/source/Private/Transport/Send-GraphHttpRequest.ps1 b/source/Private/Transport/Send-GraphHttpRequest.ps1 index fba6714..a3a8e1c 100644 --- a/source/Private/Transport/Send-GraphHttpRequest.ps1 +++ b/source/Private/Transport/Send-GraphHttpRequest.ps1 @@ -10,7 +10,9 @@ This function NEVER throws for transport or HTTP outcomes (timeouts, connection resets, 3xx/4xx/5xx statuses): it normalizes them into a GraphTransportResult. The only hard errors are credential-boundary violations, which throw before any - token is acquired or any bytes leave the process. + token is acquired or any bytes leave the process. Operation-control cancellation + and tenant-proof deadlines can propagate with GraphKit-owned markers so the retry + owner can return the correct non-success envelope. Split timeouts: the connection phase is bounded by the handler ConnectTimeout; the header phase and body phase are bounded by linked CancellationTokenSources @@ -25,34 +27,104 @@ # value - an API that accepts a per-call, range-validated parameter it does not # apply. One client per distinct timeout keeps the parameter honest while # preserving connection pooling within each timeout class (in practice one or two). -$script:GraphKitHttpClients = @{} +# The cache lives in the centralized lifecycle state so creation, admission and +# removal use one lock and one ownership ledger. function Get-GraphHttpClient { - param([int] $ConnectTimeoutSeconds = 10) + [CmdletBinding()] + [OutputType([System.Net.Http.HttpClient])] + param( + [int] $ConnectTimeoutSeconds = 10, + + [object] $State = $script:GraphKitModuleLifecycle, + + # Deterministic test seam. The result must declare both the client and + # whether GraphKit owns it; injected clients remain caller-owned. + [scriptblock] $ClientFactory + ) + + if ($null -eq $State) { + throw [System.InvalidOperationException]::new('GraphKit module lifecycle state is unavailable.') + } $key = [string] $ConnectTimeoutSeconds + [System.Threading.Monitor]::Enter($State.SyncRoot) + try { + if ($State.StopRequested -or $State.CleanupStarted) { + throw [System.ObjectDisposedException]::new( + 'GraphKit', + 'The GraphKit module is stopping and cannot create or return an HTTP client.' + ) + } - if (-not $script:GraphKitHttpClients.ContainsKey($key)) { - $handler = [System.Net.Http.SocketsHttpHandler]::new() - $handler.AllowAutoRedirect = $false - $handler.UseCookies = $false - $handler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(5) - $handler.ConnectTimeout = [TimeSpan]::FromSeconds($ConnectTimeoutSeconds) + if ($State.HttpClients.ContainsKey($key)) { + return [System.Net.Http.HttpClient] $State.HttpClients[$key].Client + } - # No handler is chained and no DelegatingHandler wraps this client, so - # nothing can retry behind GraphKit's back. - $client = [System.Net.Http.HttpClient]::new($handler) - # GraphKit enforces per-phase timeouts itself; disable HttpClient's own - # 100s wall-clock cap so it cannot fire before a configured phase timeout. - $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan + if ($null -ne $ClientFactory) { + $created = & $ClientFactory $ConnectTimeoutSeconds + if ($null -eq $created -or + $null -eq $created.PSObject.Properties['Client'] -or + $created.Client -isnot [System.Net.Http.HttpClient] -or + $null -eq $created.PSObject.Properties['OwnedByGraphKit']) { + throw [System.InvalidOperationException]::new( + 'The GraphKit HTTP client factory must return Client (HttpClient) and OwnedByGraphKit properties.' + ) + } - $script:GraphKitHttpClients[$key] = [pscustomobject] @{ - Handler = $handler - Client = $client + $client = [System.Net.Http.HttpClient] $created.Client + $ownedByGraphKit = [bool] $created.OwnedByGraphKit + } + else { + $handler = [System.Net.Http.SocketsHttpHandler]::new() + try { + $handler.AllowAutoRedirect = $false + $handler.UseCookies = $false + $handler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(5) + $handler.ConnectTimeout = [TimeSpan]::FromSeconds($ConnectTimeoutSeconds) + + # No handler is chained and no DelegatingHandler wraps this client, + # so nothing can retry behind GraphKit's back. + $client = [System.Net.Http.HttpClient]::new($handler, $true) + $handler = $null + # GraphKit enforces per-phase timeouts itself; disable HttpClient's + # own 100s wall-clock cap so it cannot fire before a configured + # phase timeout. + $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan + } + finally { + if ($null -ne $handler) { + $handler.Dispose() + } + } + $ownedByGraphKit = $true + } + + $entry = [pscustomobject] @{ + Client = $client + OwnedByGraphKit = $ownedByGraphKit + } + $State.HttpClients.Add($key, $entry) + try { + $null = Register-GraphModuleOwnedResource -State $State -Resource $client ` + -OwnedByGraphKit:$ownedByGraphKit + } + catch { + $null = $State.HttpClients.Remove($key) + # Register-GraphModuleOwnedResource transfers ownership only on a + # successful return. A failed registration leaves this client here + # for exactly-once disposal, including a shutdown race. + if ($ownedByGraphKit) { + $client.Dispose() + } + throw } - } - return $script:GraphKitHttpClients[$key].Client + return $client + } + finally { + [System.Threading.Monitor]::Exit($State.SyncRoot) + } } function Send-GraphHttpRequest { @@ -84,6 +156,10 @@ function Send-GraphHttpRequest { [object] $TokenSource, + [bool] $ForceRefresh = $false, + + [string] $TokenAcquisitionKey, + [ValidateSet('GraphBearer', 'None')] [string] $CredentialPolicy = 'None', @@ -93,15 +169,116 @@ function Send-GraphHttpRequest { [switch] $VerifyTenantBinding, - [scriptblock] $TenantBindingProver + [scriptblock] $TenantBindingProver, + + # Private outer-operation state for the nested /organization proof. + # Invoke-GraphRetry supplies the caller's canonical scope and exact + # remaining deadline; direct private tests may omit it. + [object] $TenantBindingContext, + + # Private deterministic seams. Production callers use the current + # module lifecycle and the GraphKit-owned client factory. + [object] $LifecycleState = $script:GraphKitModuleLifecycle, + + [scriptblock] $HttpClientFactory ) + $leaseAcquired = $false + $lifetimeCts = $null + $effectiveCancellationToken = [System.Threading.CancellationToken]::None + $phaseCts = $null + $tenantBindingDeadlineCts = $null + $request = $null + $response = $null + $proofCloud = 'TenantProof' + $proofClientId = $null + $tenantBindingCancellationToken = [System.Threading.CancellationToken]::None + $getTenantBindingRemaining = $null + $tenantBindingStopwatch = if ($VerifyTenantBinding) { + [System.Diagnostics.Stopwatch]::StartNew() + } + else { + $null + } + + $moduleCancellationToken = Enter-GraphModuleOperation -State $LifecycleState + $leaseAcquired = $true + try { + $lifetimeCts = [System.Threading.CancellationTokenSource]::CreateLinkedTokenSource( + $CancellationToken, + $moduleCancellationToken + ) + $effectiveCancellationToken = $lifetimeCts.Token + + if ($VerifyTenantBinding) { + $initialRemainingDeadline = [TimeSpan]::FromSeconds(300) + $deadlineUtc = $null + $utcNow = $null + $elapsed = { $tenantBindingStopwatch.Elapsed }.GetNewClosure() + + if ($null -ne $TenantBindingContext) { + if ($null -ne $TenantBindingContext.PSObject.Properties['Cloud']) { + $proofCloud = [string] $TenantBindingContext.Cloud + } + if ($null -ne $TenantBindingContext.PSObject.Properties['ClientId']) { + $proofClientId = $TenantBindingContext.ClientId + } + if ($null -ne $TenantBindingContext.PSObject.Properties['RemainingDeadline']) { + $initialRemainingDeadline = [TimeSpan] $TenantBindingContext.RemainingDeadline + } + if ($null -ne $TenantBindingContext.PSObject.Properties['Elapsed'] -and + $TenantBindingContext.Elapsed -is [scriptblock]) { + $elapsed = [scriptblock] $TenantBindingContext.Elapsed + } + if ($null -ne $TenantBindingContext.PSObject.Properties['DeadlineUtc']) { + $deadlineUtc = [datetime] $TenantBindingContext.DeadlineUtc + } + if ($null -ne $TenantBindingContext.PSObject.Properties['UtcNow'] -and + $TenantBindingContext.UtcNow -is [scriptblock]) { + $utcNow = [scriptblock] $TenantBindingContext.UtcNow + } + } + + # One monotonic budget covers acquisition, cache lookup, proof and the + # target send. The injected elapsed-time seam lets tests advance that + # budget without sleeping; production uses the sender-local stopwatch. + $getTenantBindingRemaining = { + $remaining = $initialRemainingDeadline - [TimeSpan] (& $elapsed) + if ($null -ne $deadlineUtc -and $null -ne $utcNow) { + $remainingByCallerClock = $deadlineUtc - (& $utcNow) + if ($remainingByCallerClock -lt $remaining) { + $remaining = $remainingByCallerClock + } + } + return $remaining + }.GetNewClosure() + + # Caller/module cancellation wins at the exact zero-budget boundary. + $effectiveCancellationToken.ThrowIfCancellationRequested() + $remainingAtEntry = [TimeSpan] (& $getTenantBindingRemaining) + if ($remainingAtEntry -le [TimeSpan]::Zero) { + throw (New-GraphTenantBindingDeadlineException) + } + + $tenantBindingDeadlineCts = [System.Threading.CancellationTokenSource]::CreateLinkedTokenSource( + $effectiveCancellationToken + ) + $tenantBindingDeadlineCts.CancelAfter($remainingAtEntry) + $tenantBindingCancellationToken = $tenantBindingDeadlineCts.Token + } + else { + $tenantBindingCancellationToken = $effectiveCancellationToken + } + $result = [GraphTransportResult]::new() $result.StatusCode = 0 $result.Headers = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) $result.RequestId = $null $result.TransportException = $null $result.ResponseReceived = $false + $result.VerifiedTenantId = $null + $result.TokenFingerprint = $null + $result.CredentialGeneration = $null # ---- Credential boundary (non-bypassable, enforced before any send) ---- if ($CredentialPolicy -eq 'GraphBearer') { @@ -126,6 +303,29 @@ function Send-GraphHttpRequest { if ($null -eq $TokenSource) { throw 'GraphBearer credential policy requires a token source.' } + + # Legacy PowerShell-class sources cannot execute Acquire safely after a + # context crosses runspaces. Check the captured field here, before the + # single-flight registry can make this caller wait on an unrelated + # leader and before invoking any source method. GraphKit.Auth replaces + # this containment with a compiled runspace-neutral source. + if ($TokenSource -is [GraphTokenSourceBase]) { + $currentRunspace = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace + $currentRunspaceId = if ($null -eq $currentRunspace) { + [guid]::Empty + } + else { + $currentRunspace.InstanceId + } + $sourceRunspaceId = ([GraphTokenSourceBase] $TokenSource).CreationRunspaceId + if ($currentRunspaceId -ne $sourceRunspaceId) { + throw [System.InvalidOperationException]::new( + 'This legacy PowerShell token source is bound to the runspace where its context was created. ' + + 'Cross-runspace context use is disabled because PowerShell-class token acquisition can hang; ' + + 'the compiled GraphKit.Auth token source is required for that contract.' + ) + } + } } # ---- Build the request ---- @@ -179,11 +379,78 @@ function Send-GraphHttpRequest { # Authorization is attached per-message, never as a default header. if ($CredentialPolicy -eq 'GraphBearer') { - $tokenResult = $TokenSource.Acquire($false, $CancellationToken) + # The sender is the sole token-acquisition owner for this physical + # attempt. Keeping acquisition beside the credential boundary makes the + # value acquired, tenant-proved and attached to Authorization one exact + # result rather than three independently rotating provider values. + try { + if ([string]::IsNullOrEmpty($TokenAcquisitionKey)) { + # Direct private callers and injected tests may not carry a context. + # Production Invoke-GraphRetry always supplies the canonical tuple. + $tokenResult = $TokenSource.Acquire($ForceRefresh, $tenantBindingCancellationToken) + } + else { + $sourceForAcquire = $TokenSource + $forceForAcquire = $ForceRefresh + $cancellationForAcquire = $tenantBindingCancellationToken + $flightKey = Get-GraphTokenFlightKey ` + -AcquisitionKey $TokenAcquisitionKey ` + -ForceRefresh:$ForceRefresh + $tokenResult = Invoke-GraphTokenSingleFlight ` + -Key $flightKey ` + -CancellationToken $tenantBindingCancellationToken ` + -AcquireScript { + $sourceForAcquire.Acquire($forceForAcquire, $cancellationForAcquire) + }.GetNewClosure() + } + } + catch { + if ($VerifyTenantBinding -and + $null -ne $tenantBindingDeadlineCts -and + $tenantBindingDeadlineCts.IsCancellationRequested -and + -not $effectiveCancellationToken.IsCancellationRequested) { + throw (New-GraphTenantBindingDeadlineException) + } + throw + } if ($null -eq $tokenResult) { throw 'GraphBearer credential policy: token source returned no token.' } + if (-not [string]::IsNullOrEmpty($TokenAcquisitionKey) -and + $TokenSource -is [GraphTokenSourceBase] -and + $tokenResult -is [GraphTokenResult]) { + # The winner caches inside Acquire, but every follower owns a separate + # immutable context and token-source instance. Adopt the shared result + # into each follower so a forced-refresh follower cannot serve its + # previously rejected cached token on the next ordinary request. + ([GraphTokenSourceBase] $TokenSource).AdoptSharedResult( + [GraphTokenResult] $tokenResult, + $ForceRefresh + ) + } + elseif (-not [string]::IsNullOrEmpty($TokenAcquisitionKey) -and + $TokenSource -is [GraphKit.Auth.IGraphTokenSource] -and + $tokenResult -is [GraphKit.Auth.GraphTokenResult]) { + # The compiled branch is intentionally exact. No arbitrary object + # with similarly named members receives a cross-context result. + ([GraphKit.Auth.IGraphTokenSource] $TokenSource).AdoptSharedResult( + [GraphKit.Auth.GraphTokenResult] $tokenResult, + $ForceRefresh + ) + } if ($VerifyTenantBinding) { + # A provider may ignore cancellation and still return a token. Reject a + # deadline consumed during acquisition before consulting even a valid + # cached binding. Caller cancellation is intentionally allowed through + # to an uncached prover so it observes the same cancelled token as the + # established contract; the final pre-send check still forbids bytes. + $remainingAfterAcquire = [TimeSpan] (& $getTenantBindingRemaining) + if (-not $effectiveCancellationToken.IsCancellationRequested -and + (($null -ne $tenantBindingDeadlineCts -and $tenantBindingDeadlineCts.IsCancellationRequested) -or + $remainingAfterAcquire -le [TimeSpan]::Zero)) { + throw (New-GraphTenantBindingDeadlineException) + } + # Mutating sends require tenant proof BEFORE the request is issued. # A result that carries no VerifiedTenantId, or whose binding is not # recorded for the current fingerprint + generation + tenant, is @@ -202,22 +469,60 @@ function Send-GraphHttpRequest { } if (-not $claimMatches -or -not $bindingCached) { - # The sender is deliberately context-free (it receives the - # expected authority, target tenant and token source rather than - # the full context), so reconstruct the minimal shape the prover - # needs to build its proof read and binding key. + $remainingDeadline = [TimeSpan] (& $getTenantBindingRemaining) + + # Reconstruct the private proof context without losing the caller's + # cloud/client throttle identity. $proofContext = [pscustomobject] @{ TenantId = $TargetTenantId GraphBaseUri = $ExpectedAuthority TokenSource = $TokenSource + Cloud = $proofCloud + ClientId = $proofClientId } $prover = $TenantBindingProver if ($null -eq $prover) { - $prover = { param($Context, $TokenResult) Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult } + $prover = { + param($Context, $TokenResult, $CancellationToken, $RemainingDeadline) + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` + -CancellationToken $CancellationToken -RemainingDeadline $RemainingDeadline + } + } + + try { + & $prover -Context $proofContext -TokenResult $tokenResult ` + -CancellationToken $tenantBindingCancellationToken -RemainingDeadline $remainingDeadline } + catch { + $proofFailure = $_.Exception + $candidate = $proofFailure + $isCancellationFailure = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellationFailure = $true + break + } + $candidate = $candidate.InnerException + } + + if ($isCancellationFailure -and + $tenantBindingDeadlineCts.IsCancellationRequested -and + -not $effectiveCancellationToken.IsCancellationRequested) { + throw (New-GraphTenantBindingDeadlineException) + } + throw + } + } - & $prover -Context $proofContext -TokenResult $tokenResult + # Proof/cache success is not send authority after the outer budget has + # elapsed. Recheck the same monotonic budget and caller cancellation + # before accepting the tenant claim or creating a target client. + $effectiveCancellationToken.ThrowIfCancellationRequested() + $remainingAfterProof = [TimeSpan] (& $getTenantBindingRemaining) + if (($null -ne $tenantBindingDeadlineCts -and $tenantBindingDeadlineCts.IsCancellationRequested) -or + $remainingAfterProof -le [TimeSpan]::Zero) { + throw (New-GraphTenantBindingDeadlineException) } if ($null -eq $tokenResult -or @@ -231,20 +536,56 @@ function Send-GraphHttpRequest { $request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', [string] $tokenResult.AccessToken) + + # Return only non-secret identity metadata to the retry/provenance layer. + # A provider may CLAIM VerifiedTenantId; provenance may trust it only when + # the fingerprint/generation/tenant tuple is in GraphKit's proof cache. + $bindingRecorded = $TargetTenantId -ne [guid]::Empty -and + -not [string]::IsNullOrEmpty([string] $tokenResult.VerifiedTenantId) -and + [string]::Equals([string] $tokenResult.VerifiedTenantId, [string] $TargetTenantId, [System.StringComparison]::OrdinalIgnoreCase) -and + (Test-GraphTenantBinding ` + -Fingerprint ([string] $tokenResult.TokenFingerprint) ` + -Generation ([string] $tokenResult.CredentialGeneration) ` + -TenantId $TargetTenantId) + + $result.VerifiedTenantId = if ($bindingRecorded) { $TargetTenantId.ToString() } else { $null } + $result.TokenFingerprint = [string] $tokenResult.TokenFingerprint + $result.CredentialGeneration = [string] $tokenResult.CredentialGeneration + } + + if ($VerifyTenantBinding) { + # Cache/provenance work is still part of the inherited operation budget. + # Check once more before client creation, then again immediately before + # the one physical send to close both no-send boundary races. + $effectiveCancellationToken.ThrowIfCancellationRequested() + $remainingBeforeClient = [TimeSpan] (& $getTenantBindingRemaining) + if (($null -ne $tenantBindingDeadlineCts -and $tenantBindingDeadlineCts.IsCancellationRequested) -or + $remainingBeforeClient -le [TimeSpan]::Zero) { + throw (New-GraphTenantBindingDeadlineException) + } } # ---- Send (one attempt = exactly one physical send) ---- - $client = Get-GraphHttpClient -ConnectTimeoutSeconds $TimeoutConnectionSeconds + $client = Get-GraphHttpClient -State $LifecycleState ` + -ConnectTimeoutSeconds $TimeoutConnectionSeconds ` + -ClientFactory $HttpClientFactory # The connection phase is bounded by the handler ConnectTimeout (set once); # header and body phases are bounded via a linked CancellationTokenSource. - $phaseCts = [System.Threading.CancellationTokenSource]::CreateLinkedTokenSource($CancellationToken) - - $response = $null + $phaseCts = [System.Threading.CancellationTokenSource]::CreateLinkedTokenSource($tenantBindingCancellationToken) try { $phaseCts.CancelAfter([TimeSpan]::FromSeconds($TimeoutHeadersSeconds)) + if ($VerifyTenantBinding) { + $effectiveCancellationToken.ThrowIfCancellationRequested() + $remainingBeforeSend = [TimeSpan] (& $getTenantBindingRemaining) + if (($null -ne $tenantBindingDeadlineCts -and $tenantBindingDeadlineCts.IsCancellationRequested) -or + $remainingBeforeSend -le [TimeSpan]::Zero) { + throw (New-GraphTenantBindingDeadlineException) + } + } + $response = $client.SendAsync( $request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, @@ -278,14 +619,67 @@ function Send-GraphHttpRequest { $bodyBytes = $response.Content.ReadAsByteArrayAsync($phaseCts.Token).GetAwaiter().GetResult() $result.Body = ConvertFrom-GraphResponseBody -Bytes $bodyBytes -Headers $result.Headers + + # A handler is not trusted to honour caller or module cancellation, and + # completion can race either signal. Recheck the linked operation token + # for every request before a clean response can leave the sender. + $effectiveCancellationToken.ThrowIfCancellationRequested() + + # Tenant-bound operations additionally inherit the proof deadline. Success + # is authoritative only while that budget remains after the entire body. + if ($VerifyTenantBinding) { + $remainingAfterBody = [TimeSpan] (& $getTenantBindingRemaining) + if (($null -ne $tenantBindingDeadlineCts -and $tenantBindingDeadlineCts.IsCancellationRequested) -or + $remainingAfterBody -le [TimeSpan]::Zero) { + throw (New-GraphTenantBindingDeadlineException) + } + } } catch { + $failure = $_.Exception + $candidate = $failure + $isCancellationFailure = $false + $isTenantBindingDeadline = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellationFailure = $true + } + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isTenantBindingDeadline = $true + } + $candidate = $candidate.InnerException + } + + # Preserve the normalized sender boundary even for caller/module + # cancellation. Invoke-GraphRetry consumes the GraphKit-owned marker below + # before it considers status, body, telemetry or admission success. + $isOperationCancellation = $effectiveCancellationToken.IsCancellationRequested -and + ($isCancellationFailure -or $isTenantBindingDeadline) + if ($isTenantBindingDeadline -and -not $isOperationCancellation) { + throw + } + + # The sender-wide proof budget also bounds an in-flight target request. + # Preserve caller/module cancellation when both signals arrive together; + # otherwise surface the dedicated marker consumed by Invoke-GraphRetry. + if ($VerifyTenantBinding -and -not $effectiveCancellationToken.IsCancellationRequested) { + $remainingAfterTransport = [TimeSpan] (& $getTenantBindingRemaining) + if (($null -ne $tenantBindingDeadlineCts -and $tenantBindingDeadlineCts.IsCancellationRequested) -or + $remainingAfterTransport -le [TimeSpan]::Zero) { + throw (New-GraphTenantBindingDeadlineException) + } + } + # Unwrap PowerShell's MethodInvocationException to the underlying # transport exception (HttpRequestException, TaskCanceledException, ...). $ex = $_.Exception if ($null -ne $ex -and $null -ne $ex.InnerException) { $ex = $ex.InnerException } + if ($isOperationCancellation -and $null -ne $ex) { + $ex.Data['GraphKit.OperationCancellation'] = $true + } $result.TransportException = $ex # Preserve ResponseReceived/StatusCode when the failure happened while # reading the body (response headers WERE received). Only a failure before @@ -294,13 +688,78 @@ function Send-GraphHttpRequest { $result.StatusCode = 0 } } - finally { - $phaseCts.Dispose() - $request.Dispose() - if ($null -ne $response) { $response.Dispose() } + return $result } + catch { + # Cancellation can also surface before the physical-send normalization + # block: token acquisition, tenant proof and their boundary checks all + # receive the same linked caller/module token. Mark only causal OCEs or a + # simultaneous tenant deadline; never relabel an unrelated credential + # failure merely because shutdown was signalled at the same time. + $failure = $_.Exception + $candidate = $failure + $isCancellationFailure = $false + $isTenantBindingDeadline = $false + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellationFailure = $true + } + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isTenantBindingDeadline = $true + } + $candidate = $candidate.InnerException + } - return $result + if ($effectiveCancellationToken.IsCancellationRequested -and + ($isCancellationFailure -or $isTenantBindingDeadline)) { + $failure.Data['GraphKit.OperationCancellation'] = $true + } + throw + } + finally { + # The lease is released last. Stop-GraphModule cannot dispose a cached + # client while this sender still owns any request, response or linked + # cancellation source associated with that client. + try { + if ($null -ne $response) { + $response.Dispose() + } + } + finally { + try { + if ($null -ne $request) { + $request.Dispose() + } + } + finally { + try { + if ($null -ne $phaseCts) { + $phaseCts.Dispose() + } + } + finally { + try { + if ($null -ne $tenantBindingDeadlineCts) { + $tenantBindingDeadlineCts.Dispose() + } + } + finally { + try { + if ($null -ne $lifetimeCts) { + $lifetimeCts.Dispose() + } + } + finally { + if ($leaseAcquired) { + Exit-GraphModuleOperation -State $LifecycleState + } + } + } + } + } + } + } } <# diff --git a/source/Private/Wait-GraphThrottleGate.ps1 b/source/Private/Wait-GraphThrottleGate.ps1 index 210c5cb..da83400 100644 --- a/source/Private/Wait-GraphThrottleGate.ps1 +++ b/source/Private/Wait-GraphThrottleGate.ps1 @@ -16,8 +16,19 @@ function Wait-GraphThrottleGate { [scriptblock] $Delay, + [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None, + [System.DateTime] $UtcNow = [System.DateTime]::MinValue, + # Optional inherited operation deadline. Invoke-GraphRetry supplies all + # three values from its one caller budget; direct legacy callers may omit + # them and retain the existing admission-timeout-only behaviour. + [System.TimeSpan] $RemainingDeadline, + + [System.DateTime] $DeadlineUtc = [System.DateTime]::MinValue, + + [scriptblock] $UtcNowScript, + # Bound on how long to wait for an admission slot. Reaching it is back-pressure, # not a transport failure, and is reported as such. [ValidateRange(1, 3600)] @@ -32,16 +43,96 @@ function Wait-GraphThrottleGate { $Coordinator = Get-GraphThrottleCoordinator } + $CancellationToken.ThrowIfCancellationRequested() + + $deadlineEnabled = $PSBoundParameters.ContainsKey('RemainingDeadline') + $deadlineStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $initialRemaining = if ($deadlineEnabled) { $RemainingDeadline } else { [TimeSpan]::MaxValue } + $getRemainingMilliseconds = { + if (-not $deadlineEnabled) { return [double]::PositiveInfinity } + + $remaining = $initialRemaining - $deadlineStopwatch.Elapsed + if ($DeadlineUtc -ne [System.DateTime]::MinValue -and $null -ne $UtcNowScript) { + $clockRemaining = $DeadlineUtc - (& $UtcNowScript) + if ($clockRemaining -lt $remaining) { + $remaining = $clockRemaining + } + } + + return [Math]::Max(0.0, $remaining.TotalMilliseconds) + }.GetNewClosure() + $newDeadlineException = { + $failure = [System.TimeoutException]::new( + 'The Graph operation deadline expired while waiting for throttle admission.' + ) + $failure.Data['GraphKit.OperationDeadlineExpired'] = $true + return $failure + } + + # Production waits block on the token wait handle, so cancellation wakes the + # thread without polling or duration-based guesses. An injected delay receives + # the token as its second positional argument only when it declares a + # CancellationToken parameter. That preserves the original one-parameter test + # seam, including advanced scriptblocks that reject undeclared parameters. + # Cancellation is checked again immediately after every injected step. + $delayAcceptsCancellationToken = $false + if ($null -ne $Delay -and $null -ne $Delay.Ast.ParamBlock) { + $delayAcceptsCancellationToken = @( + $Delay.Ast.ParamBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'CancellationToken' + } + ).Count -gt 0 + } + + $wait = { + param([long] $Milliseconds) + + $CancellationToken.ThrowIfCancellationRequested() + if ($Milliseconds -le 0) { return } + + if ($null -eq $Delay) { + if ($CancellationToken.CanBeCanceled) { + if ($CancellationToken.WaitHandle.WaitOne([TimeSpan]::FromMilliseconds($Milliseconds))) { + $CancellationToken.ThrowIfCancellationRequested() + } + } + else { + Start-Sleep -Milliseconds $Milliseconds + } + } + else { + if ($delayAcceptsCancellationToken) { + & $Delay $Milliseconds $CancellationToken + } + else { + & $Delay $Milliseconds + } + $CancellationToken.ThrowIfCancellationRequested() + } + }.GetNewClosure() + $coarseWait = $Coordinator.GetWaitMilliseconds([string] $Scope.CoarseKey, $UtcNow) $leafWait = $Coordinator.GetWaitMilliseconds([string] $Scope.LeafKey, $UtcNow) $waitMilliseconds = [long] [Math]::Max($coarseWait, $leafWait) if ($waitMilliseconds -gt 0) { - if ($null -eq $Delay) { - Start-Sleep -Milliseconds $waitMilliseconds + $remainingBeforeCooldown = [double] (& $getRemainingMilliseconds) + if ($remainingBeforeCooldown -le 0.0) { + $CancellationToken.ThrowIfCancellationRequested() + throw (& $newDeadlineException) } - else { - & $Delay -Milliseconds $waitMilliseconds + + $boundedCooldown = [long] [Math]::Ceiling( + [Math]::Min([double] $waitMilliseconds, $remainingBeforeCooldown) + ) + & $wait $boundedCooldown + + # Cancellation wins if it arrives at the same instant as expiry. + $CancellationToken.ThrowIfCancellationRequested() + if ($boundedCooldown -lt $waitMilliseconds -or + $boundedCooldown -ge $remainingBeforeCooldown -or + ([double] (& $getRemainingMilliseconds)) -le 0.0) { + throw (& $newDeadlineException) } } @@ -56,29 +147,77 @@ function Wait-GraphThrottleGate { $admissionWaited = 0.0 $pollMilliseconds = 50 - while (-not $Coordinator.TryAcquireAdmission([string] $Scope.LeafKey)) { + $acquired = $false + while (-not $acquired) { + $CancellationToken.ThrowIfCancellationRequested() + $remainingBeforeAcquire = [double] (& $getRemainingMilliseconds) + if ($remainingBeforeAcquire -le 0.0) { + $CancellationToken.ThrowIfCancellationRequested() + throw (& $newDeadlineException) + } + + $acquired = $Coordinator.TryAcquireAdmission([string] $Scope.LeafKey) + if ($acquired) { + # Cancellation and expiry can race TryAcquireAdmission. Release the + # exact slot before surfacing either condition; cancellation wins. + if ($CancellationToken.IsCancellationRequested -or + ([double] (& $getRemainingMilliseconds)) -le 0.0) { + $Coordinator.ReleaseAdmission([string] $Scope.LeafKey, $false) + $CancellationToken.ThrowIfCancellationRequested() + throw (& $newDeadlineException) + } + break + } + if ($admissionWaited -ge $AdmissionTimeoutSeconds * 1000.0) { + # Cancellation may arrive inside the final TryAcquireAdmission call. + # It wins over the independent back-pressure timeout at that exact + # boundary, just as it does at operation-deadline boundaries. + $CancellationToken.ThrowIfCancellationRequested() throw ( "Throttle admission timed out after {0}s waiting for a slot on scope '{1}'. " -f - $AdmissionTimeoutSeconds, $Scope.LeafKey + $AdmissionTimeoutSeconds, $Scope.LeafKey ) + 'Concurrency is at the floor and in-flight work is not completing; this is back-pressure, not a transport error.' } - if ($null -eq $Delay) { - Start-Sleep -Milliseconds $pollMilliseconds - } - else { - & $Delay -Milliseconds $pollMilliseconds + $remainingAdmissionTimeout = ($AdmissionTimeoutSeconds * 1000.0) - $admissionWaited + $remainingOperation = [double] (& $getRemainingMilliseconds) + $boundedPoll = [long] [Math]::Ceiling( + [Math]::Min( + [double] $pollMilliseconds, + [Math]::Min($remainingAdmissionTimeout, $remainingOperation) + ) + ) + if ($boundedPoll -le 0) { + $CancellationToken.ThrowIfCancellationRequested() + throw (& $newDeadlineException) } - $admissionWaited += $pollMilliseconds + & $wait $boundedPoll + + $admissionWaited += $boundedPoll + $CancellationToken.ThrowIfCancellationRequested() + if ($boundedPoll -ge $remainingOperation -or + ([double] (& $getRemainingMilliseconds)) -le 0.0) { + throw (& $newDeadlineException) + } } - return @{ + $admission = @{ Key = [string] $Scope.LeafKey - AcquiredUtc = $UtcNow.AddMilliseconds([double] $waitMilliseconds) + AcquiredUtc = $UtcNow.AddMilliseconds([double] $waitMilliseconds + $admissionWaited) Coordinator = $Coordinator CooldownWaitMs = $waitMilliseconds AdmissionWaitMs = $admissionWaited } + + # Cancellation can race the successful TryAcquire. Release the exact slot + # before surfacing cancellation so no caller can inherit an admission that + # must never progress to a send. + if ($CancellationToken.IsCancellationRequested) { + $Coordinator.ReleaseAdmission([string] $Scope.LeafKey, $false) + $CancellationToken.ThrowIfCancellationRequested() + } + + return $admission } diff --git a/source/Public/Get-GraphContext.ps1 b/source/Public/Get-GraphContext.ps1 index 8d9a4b1..3c09705 100644 --- a/source/Public/Get-GraphContext.ps1 +++ b/source/Public/Get-GraphContext.ps1 @@ -6,8 +6,10 @@ function Get-GraphContext { .DESCRIPTION Resolves a persisted tenant profile (by its canonical ProfileId) into an immutable GraphKit.Context object that owns a per-context token source. - Resolution performs zero network calls and never acquires a token; the - context carries a 'NotAcquired' identity state until the first + Resolution performs zero token acquisitions and no Graph call. Persisted + certificate, client-secret and fixed-bearer modes perform the local + credential resolution needed to transfer material into the compiled + source. The context carries a 'NotAcquired' identity state until the first acquisition. A caller may inject an X509Certificate2 or a token-provider scriptblock for context-only use; injected material is never persisted. @@ -29,9 +31,10 @@ function Get-GraphContext { only when a token is acquired. .PARAMETER MsalFactory - An optional scriptblock that returns a configured MSAL confidential - client application builder. Supplied for testability and by the - auth-resolution phase; it is invoked only when a token is acquired. + An optional same-runspace legacy compatibility factory. Supplying it + selects the legacy PowerShell source for every built-in mode, including + fixed bearer (where the scriptblock is not invoked). Omit it to use the + compiled runspace-neutral GraphKit.Auth source. .EXAMPLE $context = Get-GraphContext -ProfileId contoso @@ -71,6 +74,29 @@ function Get-GraphContext { throw "No profile with ProfileId '$ProfileId' exists in the profile store at '$StorePath'." } + $schema = Assert-GraphTenantProfileAuthSchema -Profile $tenantProfile + + if ([string] $tenantProfile.AuthMethod -eq 'ManagedIdentity') { + # Canonicalize once at the persisted-profile boundary. Every downstream + # generation, material/source, context, selector, and acquisition-key + # consumer receives this same clone in compiled and compatibility paths. + $canonicalProfile = $tenantProfile.Clone() + $canonicalCredential = if ($tenantProfile.Credential -is [hashtable]) { + $tenantProfile.Credential.Clone() + } + else { + @{} + } + if ([string]::IsNullOrEmpty([string] $schema.ManagedIdentityClientId)) { + $null = $canonicalCredential.Remove('ClientId') + } + else { + $canonicalCredential.ClientId = [string] $schema.ManagedIdentityClientId + } + $canonicalProfile.Credential = $canonicalCredential + $tenantProfile = $canonicalProfile + } + $cloud = Get-GraphCloudMetadata -Name ([string]$tenantProfile.Environment) $identitySelector = '' @@ -87,33 +113,31 @@ function Get-GraphContext { $authMode = 'Provider' } elseif ($null -ne $Certificate) { + if ([string]::IsNullOrWhiteSpace([string] $schema.ApplicationClientId)) { + throw "An injected certificate requires a profile with a valid application ClientId; profile '$ProfileId' uses AuthMethod '$($tenantProfile.AuthMethod)'." + } $generation = Get-GraphCredentialGeneration -TenantProfile @{ AuthMethod = 'Certificate' Credential = @{ Thumbprint = $Certificate.Thumbprint } } - $factory = $MsalFactory - if ($null -eq $factory) { + if ($null -eq $MsalFactory) { + $injectedProfile = $tenantProfile.Clone() + $injectedProfile.AuthMethod = 'Certificate' + $injectedProfile.Credential = @{ Thumbprint = $Certificate.Thumbprint } + $source = New-GraphAuthTokenSource -Profile $injectedProfile -Cloud $cloud ` + -Certificate $Certificate + } + else { # An injected X509Certificate2 is context-only and never persisted, so the # resolver simply hands the certificate straight back. - $injected = $Certificate - $factory = New-GraphMsalApplicationFactory ` - -Profile @{ - TenantId = $tenantProfile.TenantId - ClientId = $tenantProfile.ClientId - AuthMethod = 'Certificate' - Credential = @{ Thumbprint = $Certificate.Thumbprint } - } ` - -Cloud $cloud ` - -CredentialResolver { - # The resolver contract takes a profile, but this implementation - # ignores it: the certificate was supplied directly by the caller and - # is never persisted, so there is nothing to look up. - param($P) - $null = $P - [pscustomobject] @{ AuthMethod = 'Certificate'; Material = $injected } - }.GetNewClosure() + $factory = $MsalFactory + $source = [ConfidentialClientTokenSource]::new( + $factory, + 'Certificate', + [string] $cloud.Resource, + [string] $schema.ApplicationClientId, + $generation) } - $source = [ConfidentialClientTokenSource]::new($factory, 'Certificate', [string]$cloud.Resource, $tenantProfile.ClientId, $generation) $authMode = 'Certificate' } else { @@ -122,7 +146,7 @@ function Get-GraphContext { if ($authMode -eq 'ManagedIdentity') { $cred = $tenantProfile.Credential if ($null -ne $cred.ClientId -and $cred.ClientId -ne '') { - $identitySelector = [string]$cred.ClientId + $identitySelector = [string]$schema.ManagedIdentityClientId } else { $identitySelector = 'system' @@ -136,7 +160,7 @@ function Get-GraphContext { -TenantId ([string]$tenantProfile.TenantId) ` -Authority ([string]$cloud.Authority) ` -Resource ([string]$cloud.Resource) ` - -ClientId $tenantProfile.ClientId ` + -ClientId $schema.ApplicationClientId ` -AuthMode $authMode ` -IdentitySelector $identitySelector ` -Generation $source.CredentialGeneration ` @@ -144,8 +168,17 @@ function Get-GraphContext { $tenantGuid = [guid] ([string]$tenantProfile.TenantId) $clientGuid = $null - if ($null -ne $tenantProfile.ClientId -and [string]$tenantProfile.ClientId -ne '') { - $clientGuid = [guid] ([string]$tenantProfile.ClientId) + $contextClientId = if ($authMode -eq 'ManagedIdentity') { + $schema.ManagedIdentityClientId + } + elseif ($authMode -eq 'BearerToken') { + $null + } + else { + $schema.ApplicationClientId + } + if (-not [string]::IsNullOrEmpty([string] $contextClientId)) { + $clientGuid = [guid] ([string]$contextClientId) } return [PSCustomObject]@{ diff --git a/source/Public/Get-GraphObject.ps1 b/source/Public/Get-GraphObject.ps1 index e182fa6..fabe1bc 100644 --- a/source/Public/Get-GraphObject.ps1 +++ b/source/Public/Get-GraphObject.ps1 @@ -113,6 +113,7 @@ function Get-GraphObject { # 3. Resolve the descriptor. $Descriptor = Get-GraphOperation -Type $Type -Operation $Operation + $null = Assert-GraphOperationAuthMode -Context $Context -Descriptor $Descriptor $parameters = if ($PSBoundParameters.ContainsKey('Parameters') -and $null -ne $Parameters) { $Parameters } else { @{} } @@ -132,19 +133,26 @@ function Get-GraphObject { [string] $Method, [hashtable] $Headers, $Body, - [System.Threading.CancellationToken] $CancellationToken + [System.Threading.CancellationToken] $CancellationToken, + [Nullable[double]] $DeadlineSeconds ) $null = Test-GraphCredentialPolicy -Uri $Uri -Descriptor $Descriptor -Context $Context - Invoke-GraphRetry ` - -Context $Context ` - -Descriptor $Descriptor ` - -Uri $Uri ` - -Method $Method ` - -Headers $Headers ` - -Body $Body ` - -CancellationToken $CancellationToken + $retryParameters = @{ + Context = $Context + Descriptor = $Descriptor + Uri = $Uri + Method = $Method + Headers = $Headers + Body = $Body + CancellationToken = $CancellationToken + } + if ($null -ne $DeadlineSeconds) { + $retryParameters.DeadlineSeconds = [double] $DeadlineSeconds + } + + Invoke-GraphRetry @retryParameters } # 7. Execute. Paged collections page through Invoke-GraphPaging (honouring -PageCap); every @@ -193,6 +201,7 @@ function Get-GraphObject { ResourceFamily = $Descriptor.ResourceFamily RetrievedUtc = $retrievedUtc IdentityState = $Context.IdentityState + Cloud = $Context.Cloud } # The operation's declared secret-bearing properties travel with the envelope, so an export diff --git a/source/Public/Invoke-GraphBatch.ps1 b/source/Public/Invoke-GraphBatch.ps1 index f5bf350..80eec2d 100644 --- a/source/Public/Invoke-GraphBatch.ps1 +++ b/source/Public/Invoke-GraphBatch.ps1 @@ -103,17 +103,10 @@ function Invoke-GraphBatch { throw "Batch subrequest '$id' has unsupported method '$method'." } - $uri = [uri] $item.Uri - if ($null -eq $uri -or -not $uri.IsAbsoluteUri) { - throw "Batch subrequest '$id' requires an absolute Uri." - } - $replaySafe = $false $descriptor = $null - if ($method -in @('GET', 'HEAD')) { - $replaySafe = $true - } else { + if ($method -notin @('GET', 'HEAD')) { $hasWrite = $true if ([string]::IsNullOrWhiteSpace([string] $item.Type) -or [string]::IsNullOrWhiteSpace([string] $item.Operation)) { @@ -121,6 +114,17 @@ function Invoke-GraphBatch { } $descriptor = Get-GraphOperation -Type $item.Type -Operation $item.Operation + $null = Assert-GraphOperationAuthMode -Context $Context -Descriptor $descriptor + } + + $uri = [uri] $item.Uri + if ($null -eq $uri -or -not $uri.IsAbsoluteUri) { + throw "Batch subrequest '$id' requires an absolute Uri." + } + + if ($method -in @('GET', 'HEAD')) { + $replaySafe = $true + } else { if ($descriptor.ReplayPolicy -ne 'Safe') { throw "Batch subrequest '$id' is a write ($method) whose descriptor ReplayPolicy is '$($descriptor.ReplayPolicy)'; only Safe writes may be batched." } diff --git a/source/Public/Invoke-GraphOperation.ps1 b/source/Public/Invoke-GraphOperation.ps1 index 13116b5..103cb6d 100644 --- a/source/Public/Invoke-GraphOperation.ps1 +++ b/source/Public/Invoke-GraphOperation.ps1 @@ -135,6 +135,7 @@ function Invoke-GraphOperation { $Method = $Method.ToUpperInvariant() } else { $Descriptor = Get-GraphOperation -Type $Type -Operation $Operation + $null = Assert-GraphOperationAuthMode -Context $Context -Descriptor $Descriptor $parameters = if ($PSBoundParameters.ContainsKey('Parameters') -and $null -ne $Parameters) { $Parameters } else { @{} } $baseUri = [uri] ('{0}/{1}' -f $Context.GraphBaseUri.AbsoluteUri.TrimEnd('/'), $Descriptor.ApiVersion) @@ -165,19 +166,26 @@ function Invoke-GraphOperation { [string] $Method, [hashtable] $Headers, $Body, - [System.Threading.CancellationToken] $CancellationToken + [System.Threading.CancellationToken] $CancellationToken, + [Nullable[double]] $DeadlineSeconds ) $null = Test-GraphCredentialPolicy -Uri $Uri -Descriptor $Descriptor -Context $Context - Invoke-GraphRetry ` - -Context $Context ` - -Descriptor $Descriptor ` - -Uri $Uri ` - -Method $Method ` - -Headers $Headers ` - -Body $Body ` - -CancellationToken $CancellationToken + $retryParameters = @{ + Context = $Context + Descriptor = $Descriptor + Uri = $Uri + Method = $Method + Headers = $Headers + Body = $Body + CancellationToken = $CancellationToken + } + if ($null -ne $DeadlineSeconds) { + $retryParameters.DeadlineSeconds = [double] $DeadlineSeconds + } + + Invoke-GraphRetry @retryParameters } # 6. Dry-run gate for mutating operations. diff --git a/source/Public/Register-GraphTenant.ps1 b/source/Public/Register-GraphTenant.ps1 index 2ddbd14..8dd963b 100644 --- a/source/Public/Register-GraphTenant.ps1 +++ b/source/Public/Register-GraphTenant.ps1 @@ -30,8 +30,8 @@ function Register-GraphTenant { The canonical target tenant GUID. Must be a valid GUID. .PARAMETER ClientId - The application (client) GUID. May be omitted for a fixed bearer or a - system-assigned managed identity. + The application (client) GUID. Required for Certificate and ClientSecret. + It must not be supplied for ManagedIdentity or BearerToken. .PARAMETER Environment The Graph cloud: Global, China, Germany, USGov or USGovDoD. @@ -49,7 +49,10 @@ function Register-GraphTenant { token value, depending on AuthMethod. .PARAMETER SecretVersion - Optional SecretManagement version of the client secret or bearer token. + Optional version metadata for the client secret or bearer token. The + pinned SecretManagement 1.1.2 Get-Secret API has no Version parameter, + so such a profile fails before vault access today. Use a distinct secret + name for each immutable generation with the supported provider. .PARAMETER PfxPath The path to a PFX certificate file (Certificate AuthMethod, PFX shape). @@ -60,12 +63,34 @@ function Register-GraphTenant { .PARAMETER PfxSecretName The secret name holding the PFX password within that vault (PFX shape). + .PARAMETER PfxSecretVersion + Optional version metadata for the PFX password secret. The pinned + SecretManagement 1.1.2 Get-Secret API cannot resolve it; use a distinct + password secret name for each immutable generation today. + .PARAMETER CertificateName The vault certificate name (Certificate AuthMethod, vault-material shape). .PARAMETER CertificateVersion - Optional SecretManagement version of the vault certificate material. + Optional version metadata for vault certificate material. The pinned + SecretManagement 1.1.2 Get-Secret API cannot resolve it; use a distinct + certificate secret name for each immutable generation today. + + .PARAMETER CertificatePasswordVaultName + Optional SecretManagement vault holding the password for encrypted + vault certificate material. Supply it together with + CertificatePasswordSecretName. + + .PARAMETER CertificatePasswordSecretName + Optional secret name holding the password for encrypted vault + certificate material. Supply it together with + CertificatePasswordVaultName. + + .PARAMETER CertificatePasswordVersion + Optional version metadata for the vault-certificate password. The + pinned SecretManagement 1.1.2 Get-Secret API cannot resolve it; use a + distinct password secret name for each immutable generation today. .PARAMETER StoreLocation The certificate store location (Windows only) for a store-lookup @@ -82,8 +107,10 @@ function Register-GraphTenant { The certificate subject to look up in the Windows certificate store. .PARAMETER ManagedIdentityClientId - The user-assigned managed identity client GUID; omit for a - system-assigned managed identity. + Registration input persisted only as Credential.ClientId for a + user-assigned managed identity client GUID. For system-assigned identity, omit it. + It must not be supplied for any other + authentication mode. .PARAMETER StorePath Optional override for the profile store path. Defaults to @@ -104,6 +131,7 @@ function Register-GraphTenant { .EXAMPLE Register-GraphTenant -ProfileId acme -Name Acme -Kind customer ` -TenantId 3a4b5c6d-... -Environment Global -AuthMethod ClientSecret ` + -ClientId 7d6e5f44-... ` -VaultName GraphKit -SecretName acme-client-secret .EXAMPLE @@ -112,11 +140,15 @@ function Register-GraphTenant { .EXAMPLE Register-GraphTenant -ProfileId contoso -Name 'Contoso' -Kind customer ` - -TenantId 3a4b5c6d-... -AuthMethod Certificate -PfxPath ./contoso.pfx ` + -TenantId 3a4b5c6d-... -AuthMethod Certificate ` + -ClientId 7d6e5f44-... -PfxPath ./contoso.pfx ` -PfxVaultName GraphKit -PfxSecretName contoso-pfx-password #> [CmdletBinding()] [OutputType([hashtable])] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CertificatePasswordVaultName', Justification = 'This value is a SecretManagement vault selector, not credential material.')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CertificatePasswordSecretName', Justification = 'This value is a SecretManagement secret-name selector, not credential material.')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CertificatePasswordVersion', Justification = 'This value is immutable generation metadata, not credential material.')] param( [Parameter(Mandatory, Position = 0)] [string] $ProfileId, @@ -153,10 +185,18 @@ function Register-GraphTenant { [string] $PfxSecretName, + [string] $PfxSecretVersion, + [string] $CertificateName, [string] $CertificateVersion, + [string] $CertificatePasswordVaultName, + + [string] $CertificatePasswordSecretName, + + [string] $CertificatePasswordVersion, + [string] $StoreLocation, [string] $StoreName, @@ -194,13 +234,14 @@ function Register-GraphTenant { } $tenantIdString = $tenantGuid.ToString() - $clientIdString = $null - if (-not [string]::IsNullOrEmpty($ClientId)) { - $clientGuid = [guid]::Empty - if (-not [guid]::TryParse([string]$ClientId, [ref]$clientGuid)) { - throw "ClientId '$ClientId' is not a valid GUID." - } - $clientIdString = $clientGuid.ToString() + # Preserve the successor store's nullable top-level field for modes that do + # not use an application client id. An explicitly supplied blank string is + # still non-null metadata and the shared schema validator rejects it. + $clientIdString = if ($PSBoundParameters.ContainsKey('ClientId')) { + $ClientId + } + else { + $null } switch ($AuthMethod) { @@ -219,12 +260,31 @@ function Register-GraphTenant { if ([string]::IsNullOrEmpty($PfxSecretName)) { throw "Certificate PFX requires -PfxSecretName." } $credential = @{ PfxPath = $PfxPath - Password = @{ VaultName = $PfxVaultName; SecretName = $PfxSecretName } + Password = @{ + VaultName = $PfxVaultName + SecretName = $PfxSecretName + Version = $PfxSecretVersion + } } } elseif ($hasVaultCert) { if ([string]::IsNullOrEmpty($VaultName)) { throw "Vault certificate material requires -VaultName." } $credential = @{ VaultName = $VaultName; CertificateName = $CertificateName; Version = $CertificateVersion } + + $hasPasswordVault = -not [string]::IsNullOrEmpty($CertificatePasswordVaultName) + $hasPasswordName = -not [string]::IsNullOrEmpty($CertificatePasswordSecretName) + $hasPasswordVersion = $PSBoundParameters.ContainsKey('CertificatePasswordVersion') + if (($hasPasswordVault -or $hasPasswordName -or $hasPasswordVersion) -and + -not ($hasPasswordVault -and $hasPasswordName)) { + throw 'Vault certificate password parameters must include both -CertificatePasswordVaultName and -CertificatePasswordSecretName.' + } + if ($hasPasswordVault) { + $credential.Password = @{ + VaultName = $CertificatePasswordVaultName + SecretName = $CertificatePasswordSecretName + Version = $CertificatePasswordVersion + } + } } elseif ($hasStore) { # Windows-only, declared as such; never the sole supported shape. @@ -243,7 +303,33 @@ function Register-GraphTenant { $credential = @{ VaultName = $VaultName; SecretName = $SecretName; Version = $SecretVersion } } 'ManagedIdentity' { - $credential = @{ ClientId = $ManagedIdentityClientId } + $credential = @{} + if ($PSBoundParameters.ContainsKey('ManagedIdentityClientId')) { + $credential.ClientId = $ManagedIdentityClientId + } + } + } + + if ($AuthMethod -ne 'ManagedIdentity' -and + $PSBoundParameters.ContainsKey('ManagedIdentityClientId')) { + # Registration accepts the public spelling only as input. Represent a + # contradictory use as alternate nested metadata so the one persisted- + # schema validator rejects it with the same matrix used everywhere else. + $credential.ManagedIdentityClientId = $ManagedIdentityClientId + } + + $schema = Assert-GraphTenantProfileAuthSchema -Profile @{ + AuthMethod = $AuthMethod + ClientId = $clientIdString + Credential = $credential + } + $clientIdString = $schema.ApplicationClientId + if ($AuthMethod -eq 'ManagedIdentity') { + if ([string]::IsNullOrEmpty([string] $schema.ManagedIdentityClientId)) { + $null = $credential.Remove('ClientId') + } + else { + $credential.ClientId = $schema.ManagedIdentityClientId } } diff --git a/source/Public/Test-GraphTenant.ps1 b/source/Public/Test-GraphTenant.ps1 index 56c0078..63ec495 100644 --- a/source/Public/Test-GraphTenant.ps1 +++ b/source/Public/Test-GraphTenant.ps1 @@ -5,9 +5,14 @@ function Test-GraphTenant { .DESCRIPTION Performs metadata-level validation of a tenant profile: required fields - are present, the ProfileId matches its canonical regex, TenantId and - ClientId are GUIDs (or null), and Kind, AuthMethod and Environment are - known values. It never touches the network or resolves any credential. + are present, the ProfileId matches its canonical regex, TenantId is a + GUID, Kind/AuthMethod/Environment are known, and the identity selector + follows the exact authentication-mode schema. Certificate and + ClientSecret require one top-level application ClientId; ManagedIdentity + permits only Credential.ClientId for user-assigned identity; BearerToken + permits no client identity. Invalid successor metadata returns false; + re-register the profile with the canonical selector shape. It never + touches the network or resolves any credential. Accepts either a stored profile by -ProfileId or an in-memory -TenantProfile. @@ -89,5 +94,15 @@ function Test-GraphTenant { return $false } + try { + $null = Assert-GraphTenantProfileAuthSchema -Profile $TenantProfile + } + catch { + if ($_.FullyQualifiedErrorId -ceq 'GraphKit.InvalidTenantProfileAuthSchema') { + return $false + } + throw + } + return $true } diff --git a/src/GraphKit.Auth/Directory.Build.props b/src/GraphKit.Auth/Directory.Build.props new file mode 100644 index 0000000..41f89b5 --- /dev/null +++ b/src/GraphKit.Auth/Directory.Build.props @@ -0,0 +1,12 @@ + + + net8.0 + enable + enable + true + true + true + none + true + + diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs new file mode 100644 index 0000000..f618f81 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs @@ -0,0 +1,312 @@ +using System.Security; +using System.Security.Cryptography.X509Certificates; + +namespace GraphKit.Auth; + +public enum GraphAuthMode +{ + Certificate, + ClientSecret, + ManagedIdentity, + BearerToken +} + +public abstract class GraphCredential +{ + private protected GraphCredential() + { + } +} + +public sealed class CertificateCredential : GraphCredential +{ + public CertificateCredential(X509Certificate2 certificate, bool ownsMaterial) + { + ArgumentNullException.ThrowIfNull(certificate); + if (!certificate.HasPrivateKey) + { + throw new ArgumentException( + "The certificate must contain a private key so it can sign a client assertion.", + nameof(certificate)); + } + + Certificate = certificate; + OwnsMaterial = ownsMaterial; + } + + public X509Certificate2 Certificate { get; } + + public bool OwnsMaterial { get; } +} + +public sealed class ClientSecretCredential : GraphCredential +{ + public ClientSecretCredential(SecureString secret, bool ownsMaterial) + { + ArgumentNullException.ThrowIfNull(secret); + if (secret.Length == 0) + { + throw new ArgumentException("The client secret must not be empty.", nameof(secret)); + } + + Secret = secret; + OwnsMaterial = ownsMaterial; + } + + public SecureString Secret { get; } + + public bool OwnsMaterial { get; } +} + +public sealed class ManagedIdentityCredential : GraphCredential +{ + public ManagedIdentityCredential(string? userAssignedClientId) + { + if (userAssignedClientId is null) + { + return; + } + + if (string.IsNullOrWhiteSpace(userAssignedClientId) || + !Guid.TryParse(userAssignedClientId, out Guid clientId) || + clientId == Guid.Empty) + { + throw new ArgumentException( + "A user-assigned managed-identity client id must be a non-empty GUID.", + nameof(userAssignedClientId)); + } + + UserAssignedClientId = clientId.ToString("D"); + } + + public string? UserAssignedClientId { get; } +} + +public sealed class FixedBearerCredential : GraphCredential +{ + public FixedBearerCredential(string accessToken) + { + AccessToken = RequireText(accessToken, nameof(accessToken)); + } + + public string AccessToken { get; } + + private static string RequireText(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("The fixed bearer token must not be empty.", parameterName); + } + + return value; + } +} + +public sealed class GraphTokenRequest +{ + public GraphTokenRequest( + string environment, + Guid tenantId, + Uri authority, + Uri resource, + Guid? clientId, + GraphAuthMode authMode, + GraphCredential credential, + string credentialGeneration) + { + Environment = RequireText(environment, nameof(environment)); + if (tenantId == Guid.Empty) + { + throw new ArgumentException("The tenant id must be a non-empty GUID.", nameof(tenantId)); + } + + TenantId = tenantId; + Authority = RequireHttpsAbsoluteUri(authority, nameof(authority)); + Resource = RequireHttpsAbsoluteUri(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(credential); + CredentialGeneration = RequireText(credentialGeneration, nameof(credentialGeneration)); + + ValidateMode(authMode, clientId, credential); + ClientId = clientId; + AuthMode = authMode; + Credential = credential; + } + + public string Environment { get; } + + public Guid TenantId { get; } + + public Uri Authority { get; } + + public Uri Resource { get; } + + public Guid? ClientId { get; } + + public GraphAuthMode AuthMode { get; } + + public GraphCredential Credential { get; } + + public string CredentialGeneration { get; } + + private static string RequireText(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"{parameterName} must not be empty.", parameterName); + } + + return value; + } + + private static Uri RequireHttpsAbsoluteUri(Uri value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (!value.IsAbsoluteUri || + !string.Equals(value.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrEmpty(value.Host) || + !string.IsNullOrEmpty(value.UserInfo)) + { + throw new ArgumentException( + $"{parameterName} must be an absolute HTTPS URI with a host and no user information.", + parameterName); + } + + return value; + } + + private static void ValidateMode( + GraphAuthMode authMode, + Guid? clientId, + GraphCredential credential) + { + bool expectsApplicationClient = + authMode is GraphAuthMode.Certificate or GraphAuthMode.ClientSecret; + + if (expectsApplicationClient) + { + if (!clientId.HasValue || clientId.Value == Guid.Empty) + { + throw new ArgumentException( + $"Auth mode '{authMode}' requires a non-empty application client id.", + nameof(clientId)); + } + } + else if (clientId.HasValue) + { + throw new ArgumentException( + $"Auth mode '{authMode}' must not declare an application client id; its credential carries any identity selector.", + nameof(clientId)); + } + + bool discriminatorMatches = authMode switch + { + GraphAuthMode.Certificate => credential is CertificateCredential, + GraphAuthMode.ClientSecret => credential is ClientSecretCredential, + GraphAuthMode.ManagedIdentity => credential is ManagedIdentityCredential, + GraphAuthMode.BearerToken => credential is FixedBearerCredential, + _ => false + }; + + if (!discriminatorMatches) + { + throw new ArgumentException( + $"Credential type '{credential.GetType().Name}' does not match auth mode '{authMode}'.", + nameof(credential)); + } + } +} + +public sealed class GraphTokenResult +{ + public required string AccessToken { get; init; } + + public DateTimeOffset ExpiresOnUtc { get; init; } + + public DateTimeOffset ReceivedOnUtc { get; init; } + + public required string TokenType { get; init; } + + public required string[] Scopes { get; init; } + + // Intentionally mutable across the public ABI: the PowerShell tenant-binding + // pipeline stamps independently proven identity onto the exact acquired result. + // Send authority also requires GraphKit's fingerprint/generation/tenant proof + // cache, so a caller-written value is metadata rather than proof. + public string? VerifiedTenantId { get; set; } + + public required string TokenFingerprint { get; init; } + + public required string CredentialGeneration { get; init; } +} + +public sealed class GraphAuthException : Exception +{ + public GraphAuthException( + string code, + string category, + string message, + TimeSpan? retryAfter, + string? correlationId) + : base(RequireText(message, nameof(message))) + { + Code = RequireText(code, nameof(code)); + Category = RequireText(category, nameof(category)); + if (retryAfter < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(retryAfter), + retryAfter, + "RetryAfter must not be negative."); + } + + RetryAfter = retryAfter; + CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId; + } + + public string Code { get; } + + public string Category { get; } + + public TimeSpan? RetryAfter { get; } + + public string? CorrelationId { get; } + + private static string RequireText(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"{parameterName} must not be empty.", parameterName); + } + + return value; + } +} + +public interface IGraphTokenSource : IDisposable +{ + bool CanRefresh { get; } + + string AuthMode { get; } + + string Audience { get; } + + string? ClientId { get; } + + DateTimeOffset ExpiresOn { get; } + + string? VerifiedTenantId { get; } + + string CredentialGeneration { get; } + + GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation); + + void AdoptSharedResult(GraphTokenResult result, bool forceRefresh); +} + +public interface IGraphTokenSourceFactory +{ + IGraphTokenSource Create(GraphTokenRequest request); +} diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs new file mode 100644 index 0000000..2f69cb2 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -0,0 +1,750 @@ +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; + +namespace GraphKit.Auth; + +public sealed class GraphAuthHost : IDisposable +{ + public const string ContractMarker = "GraphKit.Auth.Abi/1"; + private const string FactoryTypeName = "GraphKit.Auth.GraphTokenSourceFactory"; + private const int Running = 0; + private const int ShutdownOwnerDisposingSources = 1; + private const int SourcesDisposedAwaitingDrain = 2; + private const int Finalized = 3; + private static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan MaximumShutdownTimeout = TimeSpan.FromMinutes(2); + private static readonly ConditionalWeakTable ConsumedOwnedMaterials = new(); + private static readonly object ConsumedMaterialMarker = new(); + + private readonly object _gate = new(); + private readonly HashSet _sources = []; + private readonly List _sourceDisposalFailures = []; + private readonly CancellationTokenSource _shutdown = new(); + private readonly TaskCompletionSource _finalizationCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TimeSpan _shutdownTimeout; + private IGraphTokenSourceFactory? _factory; + private GraphAuthLoadContext? _loadContext; + private Assembly? _providerAssembly; + private Type? _factoryType; + private Task? _shutdownTask; + private int _activeOperations; + private int _state; + + public GraphAuthHost(string payloadRoot, Version expectedProviderVersion) + : this(payloadRoot, expectedProviderVersion, DefaultShutdownTimeout) + { + } + + public GraphAuthHost( + string payloadRoot, + Version expectedProviderVersion, + TimeSpan shutdownTimeout) + { + ArgumentException.ThrowIfNullOrWhiteSpace(payloadRoot); + ArgumentNullException.ThrowIfNull(expectedProviderVersion); + if (shutdownTimeout <= TimeSpan.Zero || shutdownTimeout > MaximumShutdownTimeout) + { + throw new ArgumentOutOfRangeException( + nameof(shutdownTimeout), + shutdownTimeout, + $"The GraphKit.Auth shutdown timeout must be greater than zero and no more than {MaximumShutdownTimeout}."); + } + + _shutdownTimeout = shutdownTimeout; + string physicalRoot = PhysicalPath.ResolveExistingDirectory(payloadRoot); + Assembly contractsAssembly = ValidateDefaultContractsAssembly(physicalRoot); + string providerPath = Path.Combine(physicalRoot, GraphAuthLoadContext.ProviderFileName); + GraphAuthLoadContext loadContext = new( + physicalRoot, + providerPath, + expectedProviderVersion, + contractsAssembly); + LoadContextWeakReference = new WeakReference(loadContext, trackResurrection: false); + + try + { + Assembly providerAssembly = loadContext.LoadProviderAssembly(); + Type factoryType = ValidateProvider(providerAssembly, loadContext, contractsAssembly); + object? factoryObject; + try + { + factoryObject = Activator.CreateInstance(factoryType); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + CancellationToken.None, + "provider_construction_failed", + "Provider"); + } + + if (factoryObject is not IGraphTokenSourceFactory factory) + { + throw new InvalidOperationException( + $"Provider factory '{FactoryTypeName}' did not implement the exact default-context " + + $"'{typeof(IGraphTokenSourceFactory).AssemblyQualifiedName}' contract."); + } + + _loadContext = loadContext; + _providerAssembly = providerAssembly; + _factoryType = factoryType; + _factory = factory; + } + catch + { + loadContext.Unload(); + throw; + } + } + + public WeakReference LoadContextWeakReference { get; } + + public IGraphTokenSource CreateSource(GraphTokenRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + IDisposable? acceptedMaterial = GetOwnedMaterial(request.Credential); + if (acceptedMaterial is not null) + { + try + { + ConsumedOwnedMaterials.Add(acceptedMaterial, ConsumedMaterialMarker); + } + catch (ArgumentException) + { + throw new GraphAuthException( + "credential_material_consumed", + "CredentialOwnership", + "The owned credential material has already been transferred to an authentication source.", + retryAfter: null, + correlationId: null); + } + } + + bool providerFactoryInvoked = false; + try + { + IGraphTokenSourceFactory factory; + lock (_gate) + { + ThrowIfStopping(); + factory = _factory ?? + throw new ObjectDisposedException(nameof(GraphAuthHost)); + } + + IGraphTokenSource? source; + try + { + providerFactoryInvoked = true; + source = factory.Create(request); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + CancellationToken.None, + "provider_construction_failed", + "Provider"); + } + + if (source is null) + { + throw new InvalidOperationException( + "The GraphKit.Auth provider factory returned a null token source."); + } + + try + { + lock (_gate) + { + ThrowIfStopping(); + ValidateProviderSource(source); + GraphTokenSourceProxy proxy = new(this, source); + _sources.Add(proxy); + return proxy; + } + } + catch + { + try + { + source.Dispose(); + } + catch + { + throw CreateProviderDisposalFailure(); + } + + throw; + } + } + catch + { + if (acceptedMaterial is not null && !providerFactoryInvoked) + { + try + { + acceptedMaterial.Dispose(); + } + catch + { + throw new GraphAuthException( + "credential_material_cleanup_failed", + "CredentialOwnership", + "GraphKit.Auth could not clean up credential material after source construction was rejected before provider entry.", + retryAfter: null, + correlationId: null); + } + } + + throw; + } + } + + private static IDisposable? GetOwnedMaterial(GraphCredential credential) + { + return credential switch + { + CertificateCredential { OwnsMaterial: true } certificate => certificate.Certificate, + ClientSecretCredential { OwnsMaterial: true } secret => secret.Secret, + _ => null + }; + } + + public void Dispose() + { + Task shutdownTask = GetOrStartShutdown(); + try + { + if (!shutdownTask.Wait(_shutdownTimeout)) + { + return; + } + } + catch (AggregateException) + { + } + + shutdownTask.GetAwaiter().GetResult(); + } + + private Task GetOrStartShutdown() + { + TaskCompletionSource shutdownCompletion; + Task shutdownTask; + lock (_gate) + { + if (_shutdownTask is not null) + { + return _shutdownTask; + } + + shutdownCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + shutdownTask = shutdownCompletion.Task; + _shutdownTask = shutdownTask; + Volatile.Write(ref _state, ShutdownOwnerDisposingSources); + _ = shutdownTask.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + Task worker = Task.Run(() => RunShutdownAsync(shutdownCompletion)); + _ = worker.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return shutdownTask; + } + + private async Task RunShutdownAsync(TaskCompletionSource shutdownCompletion) + { + List failures = []; + try + { + try + { + await _shutdown.CancelAsync().ConfigureAwait(false); + } + catch + { + failures.Add(CreateCancellationFailure()); + } + + GraphTokenSourceProxy[] sources; + lock (_gate) + { + sources = [.. _sources]; + } + + Task[] disposalTasks = + [.. sources.Select(static source => source.DisposeForHostAsync())]; + await Task.WhenAll(disposalTasks).ConfigureAwait(false); + + lock (_gate) + { + failures.AddRange(_sourceDisposalFailures); + _sourceDisposalFailures.Clear(); + } + + Interlocked.Exchange(ref _state, SourcesDisposedAwaitingDrain); + TryFinalizeUnload(); + GraphAuthException? finalizationFailure = + await _finalizationCompletion.Task.ConfigureAwait(false); + if (finalizationFailure is not null) + { + failures.Add(finalizationFailure); + } + } + catch + { + failures.Add(CreateHostShutdownFailure()); + } + finally + { + if (failures.Count == 0) + { + shutdownCompletion.TrySetResult(null); + } + else if (failures.Count == 1) + { + shutdownCompletion.TrySetException(failures[0]); + } + else + { + shutdownCompletion.TrySetException( + new AggregateException( + "Multiple GraphKit.Auth cancellation, provider-disposal, or host-finalization failures occurred while the host was shutting down.", + failures)); + } + } + } + + internal GraphAuthOperationLease EnterOperation(CancellationToken callerCancellation) + { + ThrowIfStopping(); + Interlocked.Increment(ref _activeOperations); + + if (Volatile.Read(ref _state) != Running) + { + ExitOperation(); + throw new ObjectDisposedException(nameof(GraphAuthHost)); + } + + try + { + CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + callerCancellation, + _shutdown.Token); + return new GraphAuthOperationLease(this, linked); + } + catch + { + ExitOperation(); + throw; + } + } + + internal void CompleteSourceDisposal( + GraphTokenSourceProxy source, + GraphAuthException? failure) + { + lock (_gate) + { + _sources.Remove(source); + if (failure is not null) + { + _sourceDisposalFailures.Add(failure); + } + } + } + + private static Assembly ValidateDefaultContractsAssembly(string physicalPayloadRoot) + { + Assembly contractsAssembly = typeof(GraphAuthHost).Assembly; + AssemblyLoadContext? loadContext = AssemblyLoadContext.GetLoadContext(contractsAssembly); + if (!ReferenceEquals(loadContext, AssemblyLoadContext.Default)) + { + throw IncompatibleContracts( + $"'{contractsAssembly.FullName}' is loaded in '{loadContext?.Name ?? ""}' instead of the default context."); + } + + AssemblyName loadedIdentity = contractsAssembly.GetName(); + if (!string.Equals( + loadedIdentity.Name, + GraphAuthLoadContext.ContractsAssemblyName, + StringComparison.Ordinal)) + { + throw IncompatibleContracts( + $"the loaded contracts assembly is named '{loadedIdentity.Name}'."); + } + + string candidatePath = Path.Combine( + physicalPayloadRoot, + GraphAuthLoadContext.ContractsFileName); + string physicalCandidate = PhysicalPath.RequireFileInsideRoot( + candidatePath, + physicalPayloadRoot); + if (string.IsNullOrEmpty(contractsAssembly.Location)) + { + throw IncompatibleContracts("the loaded contracts assembly has no physical location."); + } + + string physicalLoaded; + try + { + physicalLoaded = PhysicalPath.RequireFileInsideRoot( + contractsAssembly.Location, + physicalPayloadRoot); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + throw IncompatibleContracts( + $"the loaded contracts location is not the declared package candidate: {exception.Message}"); + } + if (!string.Equals(physicalLoaded, physicalCandidate, StringComparison.Ordinal)) + { + throw IncompatibleContracts( + $"the default context contains contracts from '{physicalLoaded}', not package candidate '{physicalCandidate}'."); + } + + (AssemblyName CandidateIdentity, Guid CandidateMvid) candidateMetadata; + try + { + candidateMetadata = ReadManagedAssemblyMetadata(physicalCandidate); + } + catch (Exception exception) when ( + exception is BadImageFormatException or IOException or UnauthorizedAccessException) + { + throw IncompatibleContracts( + $"package candidate '{physicalCandidate}' cannot be inspected as a managed contracts assembly: {exception.Message}"); + } + + Guid loadedMvid = contractsAssembly.ManifestModule.ModuleVersionId; + if (!AssemblyIdentity.EqualsExactReference( + loadedIdentity, + candidateMetadata.CandidateIdentity) || + loadedMvid != candidateMetadata.CandidateMvid) + { + throw IncompatibleContracts( + $"the resident contracts identity or MVID does not match package candidate '{physicalCandidate}' " + + $"(resident MVID '{loadedMvid:D}', candidate MVID '{candidateMetadata.CandidateMvid:D}')."); + } + + return contractsAssembly; + } + + private static Type ValidateProvider( + Assembly providerAssembly, + GraphAuthLoadContext loadContext, + Assembly contractsAssembly) + { + if (!ReferenceEquals(AssemblyLoadContext.GetLoadContext(providerAssembly), loadContext)) + { + throw new InvalidOperationException( + "GraphKit.Auth provider assembly escaped its declared collectible load context."); + } + + Type? factoryType = providerAssembly.GetType( + FactoryTypeName, + throwOnError: false, + ignoreCase: false); + if (factoryType is null || + !factoryType.IsClass || + factoryType.IsAbstract || + !factoryType.IsPublic || + factoryType.GetConstructor(Type.EmptyTypes) is null || + !typeof(IGraphTokenSourceFactory).IsAssignableFrom(factoryType) || + !ReferenceEquals(factoryType.Assembly, providerAssembly)) + { + throw new InvalidOperationException( + $"Provider must expose public concrete factory '{FactoryTypeName}' with a public parameterless constructor " + + "and the exact default-context IGraphTokenSourceFactory interface."); + } + + ValidateProviderPublicSurface(providerAssembly, contractsAssembly); + return factoryType; + } + + private static void ValidateProviderPublicSurface( + Assembly providerAssembly, + Assembly contractsAssembly) + { + foreach (Type exportedType in providerAssembly.GetExportedTypes()) + { + ValidateSignatureType(exportedType.BaseType, providerAssembly, contractsAssembly); + foreach (Type interfaceType in exportedType.GetInterfaces()) + { + ValidateSignatureType(interfaceType, providerAssembly, contractsAssembly); + } + + const BindingFlags flags = + BindingFlags.Public | + BindingFlags.Instance | + BindingFlags.Static | + BindingFlags.DeclaredOnly; + foreach (MemberInfo member in exportedType.GetMembers(flags)) + { + switch (member) + { + case MethodInfo method: + ValidateSignatureType(method.ReturnType, providerAssembly, contractsAssembly); + foreach (ParameterInfo parameter in method.GetParameters()) + { + ValidateSignatureType(parameter.ParameterType, providerAssembly, contractsAssembly); + } + + break; + case ConstructorInfo constructor: + foreach (ParameterInfo parameter in constructor.GetParameters()) + { + ValidateSignatureType(parameter.ParameterType, providerAssembly, contractsAssembly); + } + + break; + case PropertyInfo property: + ValidateSignatureType(property.PropertyType, providerAssembly, contractsAssembly); + break; + case FieldInfo field: + ValidateSignatureType(field.FieldType, providerAssembly, contractsAssembly); + break; + case EventInfo eventInfo: + ValidateSignatureType(eventInfo.EventHandlerType, providerAssembly, contractsAssembly); + break; + } + } + } + } + + private static void ValidateSignatureType( + Type? type, + Assembly providerAssembly, + Assembly contractsAssembly) + { + if (type is null || type.IsGenericParameter) + { + return; + } + + if (type.HasElementType) + { + ValidateSignatureType(type.GetElementType(), providerAssembly, contractsAssembly); + return; + } + + foreach (Type argument in type.GetGenericArguments()) + { + ValidateSignatureType(argument, providerAssembly, contractsAssembly); + } + + Assembly typeAssembly = type.Assembly; + if (ReferenceEquals(typeAssembly, contractsAssembly) || + AssemblyIdentity.IsTrustedPlatformAssembly(typeAssembly)) + { + return; + } + + string detail = ReferenceEquals(typeAssembly, providerAssembly) + ? "a provider-owned type" + : $"type '{type.FullName}' from '{typeAssembly.FullName}'"; + throw new InvalidOperationException( + $"Provider public surface exposes {detail}; only framework and exact GraphKit.Auth contract types may cross the boundary."); + } + + private void ValidateProviderSource(IGraphTokenSource source) + { + Assembly? providerAssembly = Volatile.Read(ref _providerAssembly); + GraphAuthLoadContext? loadContext = Volatile.Read(ref _loadContext); + Type sourceType = source.GetType(); + if (providerAssembly is null || + loadContext is null || + !ReferenceEquals(sourceType.Assembly, providerAssembly) || + !ReferenceEquals(AssemblyLoadContext.GetLoadContext(sourceType.Assembly), loadContext) || + !typeof(IGraphTokenSource).IsAssignableFrom(sourceType)) + { + throw new InvalidOperationException( + "The GraphKit.Auth factory returned a source outside the exact provider/load-context/interface boundary."); + } + } + + private static (AssemblyName Identity, Guid ModuleVersionId) ReadManagedAssemblyMetadata( + string assemblyPath) + { + using FileStream stream = new( + assemblyPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + using PEReader peReader = new(stream, PEStreamOptions.LeaveOpen); + if (!peReader.HasMetadata) + { + throw new BadImageFormatException( + $"Assembly candidate '{assemblyPath}' has no managed metadata."); + } + + MetadataReader metadata = peReader.GetMetadataReader(); + AssemblyDefinition assemblyDefinition = metadata.GetAssemblyDefinition(); + AssemblyName identity = new(metadata.GetString(assemblyDefinition.Name)) + { + Version = assemblyDefinition.Version, + CultureName = assemblyDefinition.Culture.IsNil + ? null + : metadata.GetString(assemblyDefinition.Culture) + }; + if (!assemblyDefinition.PublicKey.IsNil) + { + identity.SetPublicKey(metadata.GetBlobBytes(assemblyDefinition.PublicKey)); + } + + ModuleDefinition moduleDefinition = metadata.GetModuleDefinition(); + return (identity, metadata.GetGuid(moduleDefinition.Mvid)); + } + + private static InvalidOperationException IncompatibleContracts(string detail) + { + return new InvalidOperationException( + $"GraphKit.Auth cannot use the contracts assembly already loaded in this process because {detail} " + + "Start a fresh PowerShell process and import only the intended GraphKit package."); + } + + private void ThrowIfStopping() + { + if (Volatile.Read(ref _state) != Running) + { + throw new ObjectDisposedException( + nameof(GraphAuthHost), + "The GraphKit.Auth host is shutting down and cannot accept new work."); + } + } + + private void ExitOperation() + { + if (Interlocked.Decrement(ref _activeOperations) == 0) + { + if (Volatile.Read(ref _state) == SourcesDisposedAwaitingDrain) + { + TryFinalizeUnload(); + } + } + } + + private void TryFinalizeUnload() + { + if (Volatile.Read(ref _activeOperations) != 0 || + Interlocked.CompareExchange( + ref _state, + Finalized, + SourcesDisposedAwaitingDrain) != SourcesDisposedAwaitingDrain) + { + return; + } + + GraphAuthException? failure = null; + GraphAuthLoadContext? loadContext; + lock (_gate) + { + _sources.Clear(); + _factory = null; + _factoryType = null; + _providerAssembly = null; + loadContext = _loadContext; + _loadContext = null; + } + + try + { + loadContext?.Unload(); + } + catch + { + failure = CreateHostShutdownFailure(); + } + + try + { + _shutdown.Dispose(); + } + catch + { + failure ??= CreateHostShutdownFailure(); + } + finally + { + _finalizationCompletion.TrySetResult(failure); + } + } + + private static GraphAuthException CreateCancellationFailure() + { + return new GraphAuthException( + "shutdown_callback_failed", + "HostLifecycle", + "A GraphKit.Auth shutdown cancellation callback failed.", + retryAfter: null, + correlationId: null); + } + + private static GraphAuthException CreateProviderDisposalFailure() + { + return new GraphAuthException( + "provider_disposal_failed", + "ProviderLifecycle", + "The isolated GraphKit.Auth provider failed while disposing a token source.", + retryAfter: null, + correlationId: null); + } + + private static GraphAuthException CreateHostShutdownFailure() + { + return new GraphAuthException( + "host_shutdown_failed", + "HostLifecycle", + "GraphKit.Auth could not finish shutting down its isolated provider context.", + retryAfter: null, + correlationId: null); + } + + internal sealed class GraphAuthOperationLease : IDisposable + { + private GraphAuthHost? _owner; + private CancellationTokenSource? _linkedCancellation; + + internal GraphAuthOperationLease( + GraphAuthHost owner, + CancellationTokenSource linkedCancellation) + { + _owner = owner; + _linkedCancellation = linkedCancellation; + } + + internal CancellationToken Cancellation => + Volatile.Read(ref _linkedCancellation)?.Token ?? + throw new ObjectDisposedException(nameof(GraphAuthOperationLease)); + + public void Dispose() + { + CancellationTokenSource? linked = Interlocked.Exchange( + ref _linkedCancellation, + null); + GraphAuthHost? owner = Interlocked.Exchange(ref _owner, null); + if (owner is null) + { + return; + } + + linked?.Dispose(); + owner.ExitOperation(); + } + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs new file mode 100644 index 0000000..621e847 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs @@ -0,0 +1,350 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace GraphKit.Auth; + +internal sealed class GraphAuthLoadContext : AssemblyLoadContext +{ + internal const string ProviderAssemblyName = "GraphKit.Auth"; + internal const string ProviderFileName = "GraphKit.Auth.dll"; + internal const string ContractsAssemblyName = "GraphKit.Auth.Contracts"; + internal const string ContractsFileName = "GraphKit.Auth.Contracts.dll"; + + private readonly AssemblyDependencyResolver _resolver; + private readonly Assembly _contractsAssembly; + private readonly string _physicalPayloadRoot; + private readonly string _providerPath; + private readonly Version _expectedProviderVersion; + + internal GraphAuthLoadContext( + string payloadRoot, + string providerPath, + Version expectedProviderVersion, + Assembly contractsAssembly) + : base($"GraphKit.Auth/{Guid.NewGuid():N}", isCollectible: true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(payloadRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(providerPath); + ArgumentNullException.ThrowIfNull(expectedProviderVersion); + ArgumentNullException.ThrowIfNull(contractsAssembly); + + _physicalPayloadRoot = PhysicalPath.ResolveExistingDirectory(payloadRoot); + _providerPath = PhysicalPath.RequireFileInsideRoot(providerPath, _physicalPayloadRoot); + _expectedProviderVersion = expectedProviderVersion; + _contractsAssembly = contractsAssembly; + + ValidateProviderIdentity(_providerPath); + _resolver = new AssemblyDependencyResolver(_providerPath); + } + + internal Assembly LoadProviderAssembly() + { + Assembly provider = LoadFromAssemblyPath(_providerPath); + ValidateProviderIdentity(provider.GetName()); + if (!ReferenceEquals(GetLoadContext(provider), this)) + { + throw new FileLoadException( + $"Provider '{provider.FullName}' did not load into the declared GraphKit.Auth collectible context.", + _providerPath); + } + + return provider; + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + ArgumentNullException.ThrowIfNull(assemblyName); + AssemblyName contractsIdentity = _contractsAssembly.GetName(); + if (string.Equals( + assemblyName.Name, + ContractsAssemblyName, + StringComparison.Ordinal)) + { + if (!AssemblyIdentity.EqualsExactReference(assemblyName, contractsIdentity)) + { + throw new FileLoadException( + $"Provider requested incompatible contracts identity '{assemblyName.FullName}'. " + + $"The default context contains '{contractsIdentity.FullName}'. Start a fresh PowerShell process with one GraphKit.Auth ABI."); + } + + if (!ReferenceEquals(GetLoadContext(_contractsAssembly), Default)) + { + throw new FileLoadException( + "GraphKit.Auth.Contracts must be loaded in the default AssemblyLoadContext."); + } + + return _contractsAssembly; + } + + string? resolvedPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (resolvedPath is null) + { + Assembly? trustedPlatformAssembly = + AssemblyIdentity.ResolveTrustedPlatformAssemblyReference(assemblyName); + if (trustedPlatformAssembly is not null) + { + return trustedPlatformAssembly; + } + + throw new FileNotFoundException( + $"The isolated GraphKit.Auth dependency '{assemblyName.FullName}' is absent from the declared payload root.", + assemblyName.Name); + } + + string physicalPath = PhysicalPath.RequireFileInsideRoot(resolvedPath, _physicalPayloadRoot); + AssemblyName resolvedIdentity = AssemblyName.GetAssemblyName(physicalPath); + if (string.Equals( + resolvedIdentity.Name, + ContractsAssemblyName, + StringComparison.Ordinal)) + { + throw new FileLoadException( + $"Refusing a second GraphKit.Auth.Contracts copy at '{physicalPath}'. " + + "The provider must use the exact default-context contracts assembly.", + physicalPath); + } + + if (!AssemblyIdentity.EqualsExactReference(assemblyName, resolvedIdentity)) + { + throw new FileLoadException( + $"Dependency resolver returned '{resolvedIdentity.FullName}' for requested identity '{assemblyName.FullName}'.", + physicalPath); + } + + return LoadFromAssemblyPath(physicalPath); + } + + protected override nint LoadUnmanagedDll(string unmanagedDllName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(unmanagedDllName); + string? resolvedPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (resolvedPath is null) + { + throw new DllNotFoundException( + $"The isolated GraphKit.Auth native dependency '{unmanagedDllName}' is absent from the declared payload root."); + } + + string physicalPath = PhysicalPath.RequireFileInsideRoot(resolvedPath, _physicalPayloadRoot); + return LoadUnmanagedDllFromPath(physicalPath); + } + + private void ValidateProviderIdentity(string providerPath) + { + AssemblyName identity = AssemblyName.GetAssemblyName(providerPath); + ValidateProviderIdentity(identity); + } + + private void ValidateProviderIdentity(AssemblyName identity) + { + if (!string.Equals(identity.Name, ProviderAssemblyName, StringComparison.Ordinal)) + { + throw new FileLoadException( + $"The GraphKit.Auth payload contains provider assembly '{identity.Name}', not '{ProviderAssemblyName}'.", + _providerPath); + } + + if (identity.Version != _expectedProviderVersion) + { + throw new FileLoadException( + $"The GraphKit.Auth provider version '{identity.Version}' does not match declared version '{_expectedProviderVersion}'.", + _providerPath); + } + } +} + +internal static class AssemblyIdentity +{ + private static readonly Lazy TrustedPlatformAssemblyIdentities = + new(LoadTrustedPlatformAssemblyIdentities, LazyThreadSafetyMode.ExecutionAndPublication); + + internal static bool EqualsExactReference(AssemblyName requested, AssemblyName actual) + { + return string.Equals(requested.Name, actual.Name, StringComparison.Ordinal) && + requested.Version == actual.Version && + string.Equals( + requested.CultureName ?? string.Empty, + actual.CultureName ?? string.Empty, + StringComparison.Ordinal) && + requested.GetPublicKeyToken().AsSpan().SequenceEqual(actual.GetPublicKeyToken()); + } + + internal static bool IsTrustedPlatformAssemblyReference(AssemblyName identity) + { + return TrustedPlatformAssemblyIdentities.Value.Any( + trusted => EqualsExactReference(identity, trusted)); + } + + internal static Assembly? ResolveTrustedPlatformAssemblyReference(AssemblyName reference) + { + bool trustedSimpleName = TrustedPlatformAssemblyIdentities.Value.Any( + trusted => string.Equals( + reference.Name, + trusted.Name, + StringComparison.Ordinal)); + if (!trustedSimpleName) + { + return null; + } + + try + { + Assembly resolved = AssemblyLoadContext.Default.LoadFromAssemblyName(reference); + return IsTrustedPlatformAssembly(resolved) ? resolved : null; + } + catch (Exception exception) when ( + exception is FileNotFoundException or FileLoadException or BadImageFormatException) + { + return null; + } + } + + internal static bool IsTrustedPlatformAssembly(Assembly assembly) + { + return ReferenceEquals( + AssemblyLoadContext.GetLoadContext(assembly), + AssemblyLoadContext.Default) && + IsTrustedPlatformAssemblyReference(assembly.GetName()); + } + + private static AssemblyName[] LoadTrustedPlatformAssemblyIdentities() + { + string? trustedPlatformAssemblies = + AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (string.IsNullOrWhiteSpace(trustedPlatformAssemblies)) + { + return []; + } + + List identities = []; + foreach (string path in trustedPlatformAssemblies.Split( + Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries)) + { + try + { + identities.Add(AssemblyName.GetAssemblyName(path)); + } + catch (Exception exception) when ( + exception is BadImageFormatException or IOException or UnauthorizedAccessException) + { + // An unreadable TPA entry is not trusted. The fallback remains fail-closed. + } + } + + return [.. identities]; + } +} + +internal static class PhysicalPath +{ + internal static string ResolveExistingDirectory(string path) + { + string fullPath = Path.GetFullPath(path); + if (!Directory.Exists(fullPath)) + { + throw new DirectoryNotFoundException( + $"The declared GraphKit.Auth payload root '{fullPath}' does not exist."); + } + + return ResolveExistingPath(fullPath); + } + + internal static string RequireFileInsideRoot(string filePath, string physicalRoot) + { + string fullPath = Path.GetFullPath(filePath); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException( + $"The declared GraphKit.Auth payload file '{fullPath}' does not exist.", + fullPath); + } + + string resolvedPath = ResolveExistingPath(fullPath); + string resolvedRoot = ResolveExistingDirectory(physicalRoot); + if (!IsDescendant(resolvedPath, resolvedRoot)) + { + throw new FileLoadException( + $"GraphKit.Auth payload file '{fullPath}' resolves physically outside declared root '{resolvedRoot}'.", + fullPath); + } + + return resolvedPath; + } + + private static string ResolveExistingPath(string path) + { + string fullPath = Path.GetFullPath(path); + string? pathRoot = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(pathRoot)) + { + throw new IOException($"Path '{fullPath}' has no filesystem root."); + } + + string current = new DirectoryInfo(pathRoot).FullName; + string remainder = fullPath[pathRoot.Length..]; + string[] components = remainder.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + foreach (string component in components) + { + FileSystemInfo info = ResolveActualChild(current, component); + current = info.FullName; + + FileSystemInfo? target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target is not null) + { + current = Path.GetFullPath(target.FullName); + } + } + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(current)); + } + + private static bool IsDescendant(string candidate, string root) + { + string normalizedRoot = Path.TrimEndingDirectorySeparator(root); + if (string.Equals(candidate, normalizedRoot, StringComparison.Ordinal)) + { + return false; + } + + string prefix = normalizedRoot + Path.DirectorySeparatorChar; + return candidate.StartsWith(prefix, StringComparison.Ordinal); + } + + private static FileSystemInfo ResolveActualChild( + string physicalParent, + string requestedName) + { + DirectoryInfo parent = new(physicalParent); + FileSystemInfo[] entries = parent.GetFileSystemInfos(); + FileSystemInfo? exact = entries.SingleOrDefault( + entry => string.Equals(entry.Name, requestedName, StringComparison.Ordinal)); + if (exact is not null) + { + return exact; + } + + string requestedPath = Path.Combine(physicalParent, requestedName); + if (!Directory.Exists(requestedPath) && !File.Exists(requestedPath)) + { + throw new FileNotFoundException( + $"Cannot resolve physical path because '{requestedPath}' does not exist.", + requestedPath); + } + + FileSystemInfo[] aliases = [.. entries.Where( + entry => string.Equals( + entry.Name, + requestedName, + StringComparison.OrdinalIgnoreCase))]; + if (aliases.Length != 1) + { + throw new IOException( + $"Cannot resolve the filesystem spelling of '{requestedPath}' unambiguously."); + } + + return aliases[0]; + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj new file mode 100644 index 0000000..dbfd9d7 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj @@ -0,0 +1,9 @@ + + + GraphKit.Auth.Contracts + GraphKit.Auth + 1.0.0.0 + 1.0.0.0 + false + + diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs new file mode 100644 index 0000000..b66823f --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -0,0 +1,356 @@ +namespace GraphKit.Auth; + +internal sealed class GraphTokenSourceProxy : IGraphTokenSource +{ + private readonly TaskCompletionSource _disposalCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private IGraphTokenSource? _inner; + private IGraphTokenSource? _retiredInner; + private GraphAuthHost? _owner; + private WeakReference? _retirementOwner; + private int _activeOperations; + private int _disposeState; + private int _hostNotificationState; + + internal GraphTokenSourceProxy( + GraphAuthHost owner, + IGraphTokenSource inner) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public bool CanRefresh => Read(source => source.CanRefresh); + + public string AuthMode => Read(source => source.AuthMode); + + public string Audience => Read(source => source.Audience); + + public string? ClientId => Read(source => source.ClientId); + + public DateTimeOffset ExpiresOn => Read(source => source.ExpiresOn); + + public string? VerifiedTenantId => Read(source => source.VerifiedTenantId); + + public string CredentialGeneration => Read(source => source.CredentialGeneration); + + public GraphTokenResult Acquire( + bool forceRefresh, + CancellationToken cancellation) + { + using ProxyOperation operation = BeginOperation(cancellation); + try + { + return operation.Inner.Acquire(forceRefresh, operation.Cancellation); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + operation.Cancellation, + "provider_failure", + "Provider"); + } + } + + public void AdoptSharedResult(GraphTokenResult result, bool forceRefresh) + { + using ProxyOperation operation = BeginOperation(CancellationToken.None); + try + { + operation.Inner.AdoptSharedResult(result, forceRefresh); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + operation.Cancellation, + "provider_failure", + "Provider"); + } + } + + public void Dispose() + { + Task completion = StartDisposal(); + if (completion.IsCompletedSuccessfully && + completion.Result is GraphAuthException failure) + { + throw failure; + } + } + + internal Task DisposeForHostAsync() => StartDisposal(); + + private Task StartDisposal() + { + if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) + { + return _disposalCompletion.Task; + } + + GraphAuthHost? owner = Interlocked.Exchange(ref _owner, null); + if (owner is not null) + { + Volatile.Write(ref _retirementOwner, new WeakReference(owner)); + } + + IGraphTokenSource? inner = Interlocked.Exchange(ref _inner, null); + Volatile.Write(ref _retiredInner, inner); + DisposeRetiredInnerWhenIdle(); + return _disposalCompletion.Task; + } + + private TResult Read(Func reader) + { + using ProxyOperation operation = BeginOperation(CancellationToken.None); + try + { + return reader(operation.Inner); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + operation.Cancellation, + "provider_failure", + "Provider"); + } + } + + private ProxyOperation BeginOperation(CancellationToken callerCancellation) + { + if (Volatile.Read(ref _disposeState) != 0) + { + throw new ObjectDisposedException(nameof(GraphTokenSourceProxy)); + } + + GraphAuthHost owner = Volatile.Read(ref _owner) ?? + throw new ObjectDisposedException(nameof(GraphTokenSourceProxy)); + GraphAuthHost.GraphAuthOperationLease hostLease = owner.EnterOperation( + callerCancellation); + Interlocked.Increment(ref _activeOperations); + try + { + if (Volatile.Read(ref _disposeState) != 0) + { + throw new ObjectDisposedException(nameof(GraphTokenSourceProxy)); + } + + IGraphTokenSource inner = Volatile.Read(ref _inner) ?? + throw new ObjectDisposedException(nameof(GraphTokenSourceProxy)); + return new ProxyOperation(this, inner, hostLease); + } + catch + { + try + { + ExitOperation(); + } + finally + { + hostLease.Dispose(); + } + + throw; + } + } + + private void ExitOperation() + { + if (Interlocked.Decrement(ref _activeOperations) == 0) + { + DisposeRetiredInnerWhenIdle(); + } + } + + private void DisposeRetiredInnerWhenIdle() + { + if (Volatile.Read(ref _disposeState) == 0 || + Volatile.Read(ref _activeOperations) != 0) + { + return; + } + + IGraphTokenSource? retired = Interlocked.Exchange(ref _retiredInner, null); + if (retired is null) + { + return; + } + + GraphAuthException? failure = null; + try + { + retired.Dispose(); + } + catch + { + failure = CreateProviderDisposalFailure(); + } + + NotifyHost(failure); + _disposalCompletion.TrySetResult(failure); + } + + private void NotifyHost(GraphAuthException? failure) + { + WeakReference? retirementOwner = Interlocked.Exchange( + ref _retirementOwner, + null); + if (Interlocked.CompareExchange(ref _hostNotificationState, 1, 0) != 0 || + retirementOwner is null || + !retirementOwner.TryGetTarget(out GraphAuthHost? owner)) + { + return; + } + + owner.CompleteSourceDisposal(this, failure); + } + + private static GraphAuthException CreateProviderDisposalFailure() + { + return new GraphAuthException( + "provider_disposal_failed", + "ProviderLifecycle", + "The isolated GraphKit.Auth provider failed while disposing a token source.", + retryAfter: null, + correlationId: null); + } + + private sealed class ProxyOperation : IDisposable + { + private GraphTokenSourceProxy? _proxy; + private GraphAuthHost.GraphAuthOperationLease? _hostLease; + + internal ProxyOperation( + GraphTokenSourceProxy proxy, + IGraphTokenSource inner, + GraphAuthHost.GraphAuthOperationLease hostLease) + { + _proxy = proxy; + Inner = inner; + _hostLease = hostLease; + } + + internal IGraphTokenSource Inner { get; } + + internal CancellationToken Cancellation => + Volatile.Read(ref _hostLease)?.Cancellation ?? + throw new ObjectDisposedException(nameof(ProxyOperation)); + + public void Dispose() + { + GraphTokenSourceProxy? proxy = Interlocked.Exchange(ref _proxy, null); + GraphAuthHost.GraphAuthOperationLease? hostLease = Interlocked.Exchange( + ref _hostLease, + null); + if (proxy is null) + { + return; + } + + try + { + proxy.ExitOperation(); + } + finally + { + hostLease?.Dispose(); + } + } + } +} + +internal static class ProviderBoundaryFailure +{ + private const string CleanupFailureDataKey = + "GraphKit.Auth.ProviderConstructionCleanupFailed"; + private const int MaximumSafeFieldLength = 128; + private const string SafeMessage = + "The isolated GraphKit.Auth provider could not complete the requested operation."; + private const string CancellationMessage = + "The GraphKit.Auth provider operation was canceled."; + + internal static Exception Recreate( + Exception providerFailure, + CancellationToken effectiveCancellation, + string unexpectedCode, + string unexpectedCategory) + { + ArgumentNullException.ThrowIfNull(providerFailure); + if (providerFailure is OperationCanceledException) + { + return PreserveSafeMarkers(providerFailure, new OperationCanceledException( + CancellationMessage, + innerException: null, + effectiveCancellation)); + } + + if (providerFailure is GraphAuthException graphFailure) + { + return PreserveSafeMarkers(providerFailure, new GraphAuthException( + SafeToken(graphFailure.Code, unexpectedCode), + SafeToken(graphFailure.Category, unexpectedCategory), + SafeMessage, + graphFailure.RetryAfter is { } retryAfter && retryAfter >= TimeSpan.Zero + ? retryAfter + : null, + SafeCorrelation(graphFailure.CorrelationId) ?? string.Empty)); + } + + return PreserveSafeMarkers(providerFailure, new GraphAuthException( + unexpectedCode, + unexpectedCategory, + SafeMessage, + retryAfter: null, + correlationId: null)); + } + + private static T PreserveSafeMarkers(Exception providerFailure, T recreatedFailure) + where T : Exception + { + try + { + if (providerFailure.Data[CleanupFailureDataKey] is bool cleanupFailed && cleanupFailed) + { + recreatedFailure.Data[CleanupFailureDataKey] = true; + } + } + catch + { + // Data is virtual. Ignore a provider-owned implementation rather than letting + // metadata inspection replace the already-sanitized boundary failure. + } + return recreatedFailure; + } + + private static string SafeToken(string value, string fallback) + { + return IsSafeValue(value, allowColon: false) ? value : fallback; + } + + private static string? SafeCorrelation(string? value) + { + return IsSafeValue(value, allowColon: true) ? value : null; + } + + private static bool IsSafeValue(string? value, bool allowColon) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > MaximumSafeFieldLength) + { + return false; + } + + foreach (char character in value) + { + if (!char.IsAsciiLetterOrDigit(character) && + character is not '_' and not '-' and not '.' && + (!allowColon || character != ':')) + { + return false; + } + } + + return true; + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/packages.lock.json b/src/GraphKit.Auth/GraphKit.Auth.Contracts/packages.lock.json new file mode 100644 index 0000000..807ab82 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + "net8.0": {} + } +} \ No newline at end of file diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj new file mode 100644 index 0000000..5489348 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj @@ -0,0 +1,27 @@ + + + GraphKit.Auth.Tests + GraphKit.Auth.Tests + false + true + Major + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs new file mode 100644 index 0000000..cbd3356 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs @@ -0,0 +1,1042 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit; + +namespace GraphKit.Auth.Tests; + +public sealed class GraphTokenSourceParityTests +{ + private const string Runner = "xunit-compiled"; + private const string MatrixSha256 = + "c6953120ea3a29966acabf671a193e7ff51b38d561fb0028a2a585177dea0eb0"; + private static readonly DateTimeOffset InjectedNow = + DateTimeOffset.Parse( + "2026-08-31T12:00:00+00:00", + CultureInfo.InvariantCulture, + DateTimeStyles.None); + + public static IEnumerable SemanticRows => + ParityMatrix.LoadFixture().Rows.Select(static row => new object[] { row }); + + public static IEnumerable MalformedCases => + ParityMatrix.MalformedCaseIds.Select(static id => new object[] { id }); + + [Theory] + [MemberData(nameof(SemanticRows))] + public async Task CompiledRunnerMatchesLiteralMatrix(ParityRow row) + { + ParityMatrix matrix = ParityMatrix.LoadFixture(); + Assert.Equal(MatrixSha256, matrix.Sha256); + Assert.Equal(16, matrix.Rows.Count); + Assert.Equal(16, matrix.Rows.Select(static candidate => candidate.Id).Distinct().Count()); + Assert.Contains(row.Id, ParityMatrix.RequiredRowIds, StringComparer.Ordinal); + Assert.Equal(Runner, row.Runners[0]); + Assert.Equal("pester-legacy", row.Runners[1]); + + ExpectedParity expected = row.ExpectedByRunner[Runner]; + ActualParity actual = await RunCompiledAsync(row); + + Assert.Equal(expected.CanRefresh, actual.CanRefresh); + Assert.Equal(expected.AuthMode, actual.AuthMode); + Assert.Equal(expected.Audience, actual.Audience); + Assert.Equal(expected.ClientId, actual.ClientId); + Assert.Equal(expected.CredentialGeneration, actual.CredentialGeneration); + Assert.Equal(expected.SourceExpiresOnUtc.Literal, FormatTimestamp(actual.SourceExpiresOnUtc)); + Assert.Equal(expected.SourceVerifiedTenantId, actual.SourceVerifiedTenantId); + Assert.Equal(expected.TokenSequence, actual.Results.Select(static result => result.AccessToken)); + Assert.Equal( + expected.ExpiriesOnUtc.Select(static timestamp => timestamp.Literal), + actual.Results.Select(static result => FormatTimestamp(result.ExpiresOnUtc))); + Assert.Equal(expected.TokenTypes, actual.Results.Select(static result => result.TokenType)); + Assert.Equal( + expected.OrderedScopes.Select(static scopes => string.Join('\u001f', scopes)), + actual.Results.Select(static result => string.Join('\u001f', result.Scopes))); + Assert.Equal(expected.TenantProofs, actual.Results.Select(static result => result.VerifiedTenantId)); + Assert.Equal(expected.Fingerprints, actual.Results.Select(static result => result.TokenFingerprint)); + Assert.Equal(expected.Generations, actual.Results.Select(static result => result.CredentialGeneration)); + AssertReceivedTimeRule(expected.ReceivedTimeRule, row, actual.Results); + Assert.Equal(expected.ApplicationConstructionCount, actual.ApplicationConstructionCount); + Assert.Equal(expected.ProviderAcquisitionCount, actual.ProviderAcquisitionCount); + Assert.Equal(expected.ForceFlags, actual.ForceFlags); + AssertReferenceIdentity(expected.ReferenceIdentity, actual.Results, actual.AdoptedResult); + Assert.Equal(expected.FailureKind, actual.FailureKind); + Assert.Equal(expected.CacheState, actual.CacheState); + Assert.Equal(expected.FinalFlightRegistryCount, actual.FinalFlightRegistryCount); + } + + [Theory] + [MemberData(nameof(MalformedCases))] + public void CompiledLoaderRejectsMalformedCaseIndependently(string mutationId) + { + string valid = File.ReadAllText(ParityMatrix.FixturePath, Encoding.UTF8); + string malformed = ParityMatrix.Mutate(valid, mutationId); + + InvalidDataException failure = Assert.Throws(() => + ParityMatrix.Parse(malformed)); + + string expectedDiagnostic = mutationId switch + { + "duplicate-row-id" => "duplicate row id", + "missing-required-property" => "missing required property", + "invalid-runner-call-layer" => "invalid runner call layer", + "missing-runner-expectation" => "missing required property 'pester-legacy'", + _ => mutationId + }; + Assert.Contains(expectedDiagnostic, failure.Message, StringComparison.Ordinal); + } + + private static async Task RunCompiledAsync(ParityRow row) + { + AssertDeclarativeInputContract(row); + var clock = new GraphTokenSourceTests.FakeClock(InjectedNow); + var applications = 0; + var attempt = 0; + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + var queue = new ConcurrentQueue( + GetScenarioTokens(row).Zip( + row.Input.ExpiresOnUtc, + (token, expiry) => Result(token, expiry.Value, InjectedNow, "task7-generation"))); + var client = new GraphTokenSourceTests.FakeTokenClient((forceRefresh, cancellation) => + { + int current = Interlocked.Increment(ref attempt); + if (row.Id == "acquisition-failure-fanout-retry" && current == 1) + { + entered.Set(); + release.Wait(cancellation); + if (!queue.TryDequeue(out _)) + { + throw new InvalidOperationException("No Task 7 failure attempt remains."); + } + throw new GraphAuthException( + "task7_failure", + "Fixture", + "safe task7 acquisition failure", + retryAfter: null, + correlationId: null); + } + + cancellation.ThrowIfCancellationRequested(); + if (!queue.TryDequeue(out GraphTokenResult? result)) + { + throw new InvalidOperationException("No Task 7 compiled parity result remains."); + } + + return result; + }); + var owned = new List(); + GraphTokenRequest request = CreateRequest(row, owned); + var factory = new GraphTokenSourceFactory( + (_, _) => + { + Interlocked.Increment(ref applications); + return client; + }, + clock.GetUtcNow); + GraphTokenSource source = Assert.IsType(factory.Create(request)); + var results = new List(); + GraphTokenResult? adopted = null; + string? failureKind = null; + try + { + switch (row.Id) + { + case "construction-certificate": + case "construction-client-secret": + case "construction-managed-identity": + case "construction-bearer-token": + break; + + case "ordinary-cache-hit": + case "expired-result-refresh": + case "ordinary-forced-ordinary": + case "fingerprint-certificate": + case "fingerprint-client-secret": + case "fingerprint-managed-identity": + case "fingerprint-bearer-token": + foreach (bool forceRefresh in row.Input.ForceFlags) + { + results.Add(source.Acquire(forceRefresh, CancellationToken.None)); + } + break; + + case "acquisition-failure-fanout-retry": + bool initialForceRefresh = row.Input.ForceFlags[0]; + Task[] callers = Enumerable.Range(0, 4) + .Select(_ => Task.Run(() => + source.Acquire(initialForceRefresh, CancellationToken.None))) + .ToArray(); + bool leaderEntered = entered.Wait(TimeSpan.FromSeconds(5)); + bool allWaitersObserved = leaderEntered && SpinWait.SpinUntil( + () => source.OrdinaryFlightWaiterCount == callers.Length, + TimeSpan.FromSeconds(5)); + release.Set(); + GraphAuthException[] failures = await Task.WhenAll(callers.Select(async caller => + await Assert.ThrowsAsync(async () => await caller))) + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(leaderEntered); + Assert.True(allWaitersObserved); + Assert.All(failures, static failure => Assert.Equal("task7_failure", failure.Code)); + failureKind = "AcquisitionFailure"; + results.Add(source.Acquire(row.Input.ForceFlags[1], CancellationToken.None)); + break; + + case "caller-cancellation-no-cache": + using (var cancellation = new CancellationTokenSource()) + { + if (row.Input.CancelCaller) + { + cancellation.Cancel(); + } + await Assert.ThrowsAnyAsync(() => Task.Run(() => + source.Acquire(row.Input.ForceFlags[0], cancellation.Token))); + } + failureKind = "Canceled"; + break; + + case "fixed-bearer-cache-force-refusal": + results.Add(source.Acquire(row.Input.ForceFlags[0], CancellationToken.None)); + results.Add(source.Acquire(row.Input.ForceFlags[1], CancellationToken.None)); + Assert.Throws(() => + source.Acquire(row.Input.ForceFlags[2], CancellationToken.None)); + failureKind = "RefreshRefused"; + break; + + case "adoption-generation-mismatch": + adopted = AdoptedResult(row.Input); + Assert.Throws(() => + source.AdoptSharedResult(adopted, row.Input.ForceFlags[0])); + failureKind = "GenerationMismatch"; + break; + + case "adoption-valid": + adopted = AdoptedResult(row.Input); + source.AdoptSharedResult(adopted, row.Input.ForceFlags[0]); + results.Add(source.Acquire(row.Input.ForceFlags[0], CancellationToken.None)); + break; + + default: + throw new InvalidOperationException($"Unhandled Task 7 parity row '{row.Id}'."); + } + + return new ActualParity( + source.CanRefresh, + source.AuthMode, + source.Audience, + source.ClientId, + source.CredentialGeneration, + source.ExpiresOn, + source.VerifiedTenantId, + results, + adopted, + applications, + client.AcquireCount, + client.ForceRefreshValues, + failureKind, + source.HasCachedResult ? "Populated" : "Empty", + CountSourceFlights(source)); + } + finally + { + release.Set(); + source.Dispose(); + foreach (IDisposable material in owned) + { + material.Dispose(); + } + } + } + + private static GraphTokenRequest CreateRequest(ParityRow row, List owned) + { + GraphAuthMode mode = Enum.Parse(row.AuthMode, ignoreCase: false); + GraphCredential credential; + Guid? clientId; + switch (mode) + { + case GraphAuthMode.Certificate: + X509Certificate2 certificate = CreateCertificate(); + owned.Add(certificate); + credential = new CertificateCredential(certificate, ownsMaterial: false); + clientId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + break; + case GraphAuthMode.ClientSecret: + SecureString secret = GraphTokenSourceTests.SecureStringFixture.Create("task7-secret"); + owned.Add(secret); + credential = new ClientSecretCredential(secret, ownsMaterial: false); + clientId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + break; + case GraphAuthMode.ManagedIdentity: + credential = new ManagedIdentityCredential( + "00000000-0000-0000-0000-000000000003"); + clientId = null; + break; + case GraphAuthMode.BearerToken: + credential = new FixedBearerCredential(GetScenarioTokens(row)[0]); + clientId = null; + break; + default: + throw new InvalidOperationException($"Unhandled Task 7 auth mode '{mode}'."); + } + + return new GraphTokenRequest( + "Global", + Guid.Parse("00000000-0000-0000-0000-000000000001"), + new Uri("https://login.microsoftonline.com"), + new Uri("https://graph.microsoft.com"), + clientId, + mode, + credential, + "task7-generation"); + } + + private static IReadOnlyList GetScenarioTokens(ParityRow row) => + row.Scenario == "fingerprint" + ? [row.Input.FingerprintInput!] + : row.Input.Tokens; + + private static void AssertDeclarativeInputContract(ParityRow row) + { + Assert.Equal(row.Id == "caller-cancellation-no-cache", row.Input.CancelCaller); + + bool fingerprintScenario = row.Scenario == "fingerprint"; + Assert.Equal(fingerprintScenario, row.Input.FingerprintInput is not null); + if (fingerprintScenario) + { + Assert.False(string.IsNullOrEmpty(row.Input.FingerprintInput)); + Assert.Equal(row.Input.FingerprintInput, Assert.Single(row.Input.Tokens)); + } + + bool[] forceFlags = row.Id switch + { + "construction-certificate" or + "construction-client-secret" or + "construction-managed-identity" or + "construction-bearer-token" => [], + "ordinary-cache-hit" or + "expired-result-refresh" or + "acquisition-failure-fanout-retry" => [false, false], + "ordinary-forced-ordinary" => [false, true, false], + "caller-cancellation-no-cache" or + "fingerprint-certificate" or + "fingerprint-client-secret" or + "fingerprint-managed-identity" or + "fingerprint-bearer-token" or + "adoption-generation-mismatch" or + "adoption-valid" => [false], + "fixed-bearer-cache-force-refusal" => [false, false, true], + _ => throw new InvalidOperationException( + $"Unhandled Task 7 input contract row '{row.Id}'.") + }; + Assert.Equal(forceFlags, row.Input.ForceFlags); + + if (row.Id == "acquisition-failure-fanout-retry") + { + Assert.Equal(["task7-failure", "task7-recovered"], row.Input.Tokens); + Assert.Equal( + ["2099-04-01T00:00:00+00:00", "2099-04-01T00:00:00+00:00"], + row.Input.ExpiresOnUtc.Select(static expiry => expiry.Literal)); + } + } + + private static X509Certificate2 CreateCertificate() + { + using RSA rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=GraphKit.Auth Task 7 parity", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(1)); + } + + private static GraphTokenResult Result( + string token, + DateTimeOffset expiry, + DateTimeOffset received, + string generation, + string? tenantProof = null) + { + GraphTokenResult result = TokenResultFactory.Create( + token, + expiry, + received, + "https://graph.microsoft.com/.default", + generation); + result.VerifiedTenantId = tenantProof; + return result; + } + + private static GraphTokenResult AdoptedResult(ParityInput input) + { + return Result( + input.AdoptToken!, + input.AdoptExpiresOnUtc!.Value.Value, + input.AdoptReceivedOnUtc!.Value.Value, + input.AdoptGeneration!, + input.AdoptTenantProof); + } + + private static int CountSourceFlights(GraphTokenSource source) + { + const System.Reflection.BindingFlags flags = + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; + return new[] { "_ordinaryFlight", "_forcedFlight" } + .Count(name => typeof(GraphTokenSource).GetField(name, flags)!.GetValue(source) is not null); + } + + private static void AssertReceivedTimeRule( + string rule, + ParityRow row, + IReadOnlyList results) + { + switch (rule) + { + case "None": + Assert.Empty(results); + break; + case "InjectedClock": + Assert.All(results, static result => Assert.Equal(InjectedNow, result.ReceivedOnUtc)); + break; + case "LiteralAdopted": + Assert.All(results, result => Assert.Equal( + row.Input.AdoptReceivedOnUtc!.Value.Literal, + FormatTimestamp(result.ReceivedOnUtc))); + break; + default: + throw new InvalidOperationException($"Unexpected compiled received-time rule '{rule}'."); + } + } + + private static string FormatTimestamp(DateTimeOffset value) => + value.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); + + private static void AssertReferenceIdentity( + string rule, + IReadOnlyList results, + GraphTokenResult? adopted) + { + switch (rule) + { + case "None": + Assert.Empty(results); + break; + case "Single": + Assert.Single(results); + break; + case "AllSame": + Assert.NotEmpty(results); + Assert.All(results, result => Assert.Same(results[0], result)); + break; + case "AllDistinct": + Assert.Equal(results.Count, results.Distinct(ReferenceEqualityComparer.Instance).Count()); + break; + case "SecondAndThirdSame": + Assert.Equal(3, results.Count); + Assert.NotSame(results[0], results[1]); + Assert.Same(results[1], results[2]); + break; + case "AdoptedAndReturnedSame": + Assert.NotNull(adopted); + Assert.Single(results); + Assert.Same(adopted, results[0]); + break; + default: + throw new InvalidOperationException($"Unexpected reference rule '{rule}'."); + } + } + + private sealed record ActualParity( + bool CanRefresh, + string AuthMode, + string Audience, + string? ClientId, + string CredentialGeneration, + DateTimeOffset SourceExpiresOnUtc, + string? SourceVerifiedTenantId, + IReadOnlyList Results, + GraphTokenResult? AdoptedResult, + int ApplicationConstructionCount, + int ProviderAcquisitionCount, + IReadOnlyList ForceFlags, + string? FailureKind, + string CacheState, + int FinalFlightRegistryCount); +} + +public sealed record ParityRow( + string Id, + string[] Runners, + string Scenario, + string AuthMode, + IReadOnlyDictionary CallLayerByRunner, + ParityInput Input, + IReadOnlyDictionary ExpectedByRunner) +{ + public override string ToString() => Id; +} + +public sealed record ParityInput( + string[] Tokens, + ExactTimestamp[] ExpiresOnUtc, + bool[] ForceFlags, + bool CancelCaller, + string? FingerprintInput, + string? AdoptToken, + string? AdoptGeneration, + ExactTimestamp? AdoptReceivedOnUtc, + ExactTimestamp? AdoptExpiresOnUtc, + string? AdoptTenantProof); + +public sealed record ExpectedParity( + bool CanRefresh, + string AuthMode, + string Audience, + string? ClientId, + string CredentialGeneration, + ExactTimestamp SourceExpiresOnUtc, + string? SourceVerifiedTenantId, + string[] TokenSequence, + ExactTimestamp[] ExpiriesOnUtc, + string[] TokenTypes, + string[][] OrderedScopes, + string?[] TenantProofs, + string[] Fingerprints, + string[] Generations, + string ReceivedTimeRule, + int ApplicationConstructionCount, + int ProviderAcquisitionCount, + bool[] ForceFlags, + string ReferenceIdentity, + string? FailureKind, + string CacheState, + int FinalFlightRegistryCount); + +public readonly record struct ExactTimestamp(string Literal, DateTimeOffset Value); + +public sealed class ParityMatrix +{ + private const int SchemaVersion = 1; + private static readonly string[] RootFields = ["schemaVersion", "rowCount", "rows"]; + private static readonly string[] RowFields = + ["id", "runners", "scenario", "authMode", "callLayerByRunner", "input", "expectedByRunner"]; + private static readonly string[] InputFields = + [ + "tokens", "expiresOnUtc", "forceFlags", "cancelCaller", "fingerprintInput", + "adoptToken", "adoptGeneration", "adoptReceivedOnUtc", "adoptExpiresOnUtc", + "adoptTenantProof" + ]; + private static readonly string[] ExpectedFields = + [ + "canRefresh", "authMode", "audience", "clientId", "credentialGeneration", + "sourceExpiresOnUtc", "sourceVerifiedTenantId", "tokenSequence", "expiriesOnUtc", + "tokenTypes", "orderedScopes", "tenantProofs", "fingerprints", "generations", + "receivedTimeRule", "applicationConstructionCount", "providerAcquisitionCount", + "forceFlags", "referenceIdentity", "failureKind", "cacheState", + "finalFlightRegistryCount" + ]; + private static readonly IReadOnlyDictionary + RowContracts = new Dictionary(StringComparer.Ordinal) + { + ["construction-certificate"] = ("construction", "Certificate", "construction-only", "construction-only"), + ["construction-client-secret"] = ("construction", "ClientSecret", "construction-only", "construction-only"), + ["construction-managed-identity"] = ("construction", "ManagedIdentity", "construction-only", "construction-only"), + ["construction-bearer-token"] = ("construction", "BearerToken", "construction-only", "construction-only"), + ["ordinary-cache-hit"] = ("cache-hit", "Certificate", "direct-source", "direct-source"), + ["expired-result-refresh"] = ("expiry-refresh", "ClientSecret", "direct-source", "direct-source"), + ["ordinary-forced-ordinary"] = ("force-partition", "ManagedIdentity", "direct-source", "direct-source"), + ["acquisition-failure-fanout-retry"] = ("failure-fanout-retry", "Certificate", "compiled-internal-source-flight", "legacy-production-outer-keyed-flight"), + ["caller-cancellation-no-cache"] = ("caller-cancellation", "ClientSecret", "direct-source", "direct-source"), + ["fixed-bearer-cache-force-refusal"] = ("fixed-bearer", "BearerToken", "direct-source", "direct-source"), + ["fingerprint-certificate"] = ("fingerprint", "Certificate", "direct-source", "direct-source"), + ["fingerprint-client-secret"] = ("fingerprint", "ClientSecret", "direct-source", "direct-source"), + ["fingerprint-managed-identity"] = ("fingerprint", "ManagedIdentity", "direct-source", "direct-source"), + ["fingerprint-bearer-token"] = ("fingerprint", "BearerToken", "direct-source", "direct-source"), + ["adoption-generation-mismatch"] = ("adoption-mismatch", "Certificate", "direct-source", "direct-source"), + ["adoption-valid"] = ("adoption-valid", "ManagedIdentity", "direct-source", "direct-source") + }; + + public static readonly string[] RequiredRowIds = RowContracts.Keys.ToArray(); + public static readonly string[] MalformedCaseIds = + [ + "unsupported-schema-version", + "incorrect-row-count", + "duplicate-row-id", + "missing-required-row-id", + "unknown-property", + "missing-required-property", + "duplicate-json-property", + "invalid-runner-call-layer", + "missing-runner-expectation" + ]; + + private ParityMatrix(string sha256, IReadOnlyList rows) + { + Sha256 = sha256; + Rows = rows; + } + + public string Sha256 { get; } + + public IReadOnlyList Rows { get; } + + public static string FixturePath => Path.Combine( + AppContext.BaseDirectory, + "Fixtures", + "GraphKitAuthParityCases.json"); + + public static ParityMatrix LoadFixture() + { + byte[] bytes = File.ReadAllBytes(FixturePath); + return Parse(Encoding.UTF8.GetString(bytes), + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant()); + } + + public static ParityMatrix Parse(string json) => + Parse(json, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant()); + + public static string Mutate(string validJson, string mutationId) + { + if (mutationId == "duplicate-json-property") + { + return validJson.Replace( + "\"schemaVersion\": 1,", + "\"schemaVersion\": 1, \"schemaVersion\": 1,", + StringComparison.Ordinal); + } + + JsonNode root = JsonNode.Parse(validJson) ?? throw new InvalidDataException("mutation source is null"); + JsonObject rootObject = root.AsObject(); + JsonArray rows = rootObject["rows"]!.AsArray(); + switch (mutationId) + { + case "unsupported-schema-version": + rootObject["schemaVersion"] = 2; + break; + case "incorrect-row-count": + rootObject["rowCount"] = 15; + break; + case "duplicate-row-id": + rows[1]!["id"] = rows[0]!["id"]!.GetValue(); + break; + case "missing-required-row-id": + rows[0]!["id"] = "replacement-row-id"; + break; + case "unknown-property": + rows[0]!["unexpected"] = true; + break; + case "missing-required-property": + rows[0]!.AsObject().Remove("scenario"); + break; + case "invalid-runner-call-layer": + rows[0]!["callLayerByRunner"]!["xunit-compiled"] = "direct-source"; + break; + case "missing-runner-expectation": + rows[0]!["expectedByRunner"]!.AsObject().Remove("pester-legacy"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(mutationId), mutationId, "Unknown malformed case."); + } + + return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + private static ParityMatrix Parse(string json, string sha256) + { + string mutationHint = DetectMutationHint(json); + try + { + using JsonDocument document = JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow + }); + JsonElement root = document.RootElement; + RequireKind(root, JsonValueKind.Object, "root"); + RejectDuplicateProperties(root, "root"); + RequireExactFields(root, RootFields, "root"); + RequireInt(root, "schemaVersion", SchemaVersion); + RequireInt(root, "rowCount", 16); + JsonElement rowsElement = RequireProperty(root, "rows", JsonValueKind.Array); + if (rowsElement.GetArrayLength() != 16) + { + throw new InvalidDataException("rows must contain exactly 16 items"); + } + + var rows = new List(16); + var seen = new HashSet(StringComparer.Ordinal); + foreach (JsonElement element in rowsElement.EnumerateArray()) + { + RequireKind(element, JsonValueKind.Object, "row"); + RejectDuplicateProperties(element, "row"); + RequireExactFields(element, RowFields, "row"); + string id = RequireString(element, "id"); + if (!seen.Add(id)) + { + throw new InvalidDataException($"duplicate row id '{id}'"); + } + + if (!RowContracts.TryGetValue(id, out var contract)) + { + throw new InvalidDataException($"unknown row id '{id}'"); + } + + string[] runners = RequireStringArray(element, "runners"); + if (!runners.SequenceEqual(["xunit-compiled", "pester-legacy"], StringComparer.Ordinal)) + { + throw new InvalidDataException($"row '{id}' has an invalid runner set or order"); + } + + string scenario = RequireString(element, "scenario"); + string authMode = RequireString(element, "authMode"); + if (!string.Equals(scenario, contract.Scenario, StringComparison.Ordinal) || + !string.Equals(authMode, contract.Mode, StringComparison.Ordinal)) + { + throw new InvalidDataException($"row '{id}' scenario or auth mode is invalid"); + } + + JsonElement layers = RequireProperty(element, "callLayerByRunner", JsonValueKind.Object); + RejectDuplicateProperties(layers, $"row '{id}' callLayerByRunner"); + RequireExactFields(layers, ["xunit-compiled", "pester-legacy"], $"row '{id}' callLayerByRunner"); + var callLayers = new Dictionary(StringComparer.Ordinal) + { + ["xunit-compiled"] = RequireString(layers, "xunit-compiled"), + ["pester-legacy"] = RequireString(layers, "pester-legacy") + }; + if (!string.Equals(callLayers["xunit-compiled"], contract.XunitLayer, StringComparison.Ordinal) || + !string.Equals(callLayers["pester-legacy"], contract.PesterLayer, StringComparison.Ordinal)) + { + throw new InvalidDataException($"row '{id}' has an invalid runner call layer"); + } + + ParityInput input = ParseInput(RequireProperty(element, "input", JsonValueKind.Object), id); + IReadOnlyDictionary expected = ParseExpectedByRunner( + RequireProperty(element, "expectedByRunner", JsonValueKind.Object), id); + rows.Add(new ParityRow(id, runners, scenario, authMode, callLayers, input, expected)); + } + + string? missing = RequiredRowIds.FirstOrDefault(id => !seen.Contains(id)); + if (missing is not null) + { + throw new InvalidDataException($"missing required row id '{missing}'"); + } + + return new ParityMatrix(sha256, rows); + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + throw new InvalidDataException($"{mutationHint}: {exception.Message}", exception); + } + } + + private static ParityInput ParseInput(JsonElement input, string id) + { + RejectDuplicateProperties(input, $"row '{id}' input"); + RequireExactFields(input, InputFields, $"row '{id}' input"); + return new ParityInput( + RequireStringArray(input, "tokens"), + RequireDateArray(input, "expiresOnUtc"), + RequireBoolArray(input, "forceFlags"), + RequireBoolean(input, "cancelCaller"), + RequireNullableString(input, "fingerprintInput"), + RequireNullableString(input, "adoptToken"), + RequireNullableString(input, "adoptGeneration"), + RequireNullableDate(input, "adoptReceivedOnUtc"), + RequireNullableDate(input, "adoptExpiresOnUtc"), + RequireNullableString(input, "adoptTenantProof")); + } + + private static IReadOnlyDictionary ParseExpectedByRunner( + JsonElement expectedByRunner, + string id) + { + RejectDuplicateProperties(expectedByRunner, $"row '{id}' expectedByRunner"); + RequireExactFields( + expectedByRunner, + ["xunit-compiled", "pester-legacy"], + $"row '{id}' expectedByRunner"); + return new Dictionary(StringComparer.Ordinal) + { + ["xunit-compiled"] = ParseExpected( + RequireProperty(expectedByRunner, "xunit-compiled", JsonValueKind.Object), + id, + "xunit-compiled"), + ["pester-legacy"] = ParseExpected( + RequireProperty(expectedByRunner, "pester-legacy", JsonValueKind.Object), + id, + "pester-legacy") + }; + } + + private static ExpectedParity ParseExpected(JsonElement value, string id, string runner) + { + string location = $"row '{id}' expectedByRunner.{runner}"; + RejectDuplicateProperties(value, location); + RequireExactFields(value, ExpectedFields, location); + return new ExpectedParity( + RequireBoolean(value, "canRefresh"), + RequireString(value, "authMode"), + RequireString(value, "audience"), + RequireNullableString(value, "clientId"), + RequireString(value, "credentialGeneration"), + RequireDate(value, "sourceExpiresOnUtc"), + RequireNullableString(value, "sourceVerifiedTenantId"), + RequireStringArray(value, "tokenSequence"), + RequireDateArray(value, "expiriesOnUtc"), + RequireStringArray(value, "tokenTypes"), + RequireStringMatrix(value, "orderedScopes"), + RequireNullableStringArray(value, "tenantProofs"), + RequireStringArray(value, "fingerprints"), + RequireStringArray(value, "generations"), + RequireString(value, "receivedTimeRule"), + RequireNonNegativeInt(value, "applicationConstructionCount"), + RequireNonNegativeInt(value, "providerAcquisitionCount"), + RequireBoolArray(value, "forceFlags"), + RequireString(value, "referenceIdentity"), + RequireNullableString(value, "failureKind"), + RequireString(value, "cacheState"), + RequireNonNegativeInt(value, "finalFlightRegistryCount")); + } + + private static string DetectMutationHint(string json) + { + if (json.Contains("\"schemaVersion\": 2", StringComparison.Ordinal)) return "unsupported-schema-version"; + if (json.Contains("\"rowCount\": 15", StringComparison.Ordinal)) return "incorrect-row-count"; + if (json.Contains("replacement-row-id", StringComparison.Ordinal)) return "missing-required-row-id"; + if (json.Contains("\"unexpected\"", StringComparison.Ordinal)) return "unknown-property"; + if (json.Contains("\"schemaVersion\": 1, \"schemaVersion\"", StringComparison.Ordinal)) return "duplicate-json-property"; + return "malformed-matrix"; + } + + private static void RejectDuplicateProperties(JsonElement element, string location) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException($"{location} has duplicate JSON property '{property.Name}'"); + } + + RejectDuplicateProperties(property.Value, $"{location}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{location}[{index++}]"); + } + } + } + + private static void RequireExactFields(JsonElement element, string[] expected, string location) + { + string[] actual = element.EnumerateObject().Select(static property => property.Name).ToArray(); + string? unknown = actual.FirstOrDefault(name => !expected.Contains(name, StringComparer.Ordinal)); + if (unknown is not null) + { + throw new InvalidDataException($"{location} has unknown property '{unknown}'"); + } + + string? missing = expected.FirstOrDefault(name => !actual.Contains(name, StringComparer.Ordinal)); + if (missing is not null) + { + throw new InvalidDataException($"{location} is missing required property '{missing}'"); + } + } + + private static JsonElement RequireProperty(JsonElement element, string name, JsonValueKind kind) + { + if (!element.TryGetProperty(name, out JsonElement value) || value.ValueKind != kind) + { + throw new InvalidDataException($"property '{name}' must be {kind}"); + } + + return value; + } + + private static void RequireKind(JsonElement element, JsonValueKind kind, string location) + { + if (element.ValueKind != kind) + { + throw new InvalidDataException($"{location} must be {kind}"); + } + } + + private static void RequireInt(JsonElement element, string name, int expected) + { + JsonElement value = RequireProperty(element, name, JsonValueKind.Number); + if (!value.TryGetInt32(out int actual) || actual != expected) + { + throw new InvalidDataException($"property '{name}' must equal {expected}"); + } + } + + private static int RequireNonNegativeInt(JsonElement element, string name) + { + JsonElement value = RequireProperty(element, name, JsonValueKind.Number); + if (!value.TryGetInt32(out int actual) || actual < 0) + { + throw new InvalidDataException($"property '{name}' must be a non-negative integer"); + } + + return actual; + } + + private static string RequireString(JsonElement element, string name) + { + JsonElement value = RequireProperty(element, name, JsonValueKind.String); + string? result = value.GetString(); + if (string.IsNullOrEmpty(result)) + { + throw new InvalidDataException($"property '{name}' must be a non-empty string"); + } + + return result; + } + + private static string? RequireNullableString(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement value)) + { + throw new InvalidDataException($"property '{name}' is required"); + } + + if (value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(value.GetString())) + { + throw new InvalidDataException($"property '{name}' must be null or a non-empty string"); + } + + return value.GetString(); + } + + private static bool RequireBoolean(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement value) || + value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + throw new InvalidDataException($"property '{name}' must be boolean"); + } + + return value.GetBoolean(); + } + + private static ExactTimestamp RequireDate(JsonElement element, string name) + { + string value = RequireString(element, name); + return ParseDate(value, $"property '{name}'"); + } + + private static ExactTimestamp? RequireNullableDate(JsonElement element, string name) + { + string? value = RequireNullableString(element, name); + if (value is null) return null; + return ParseDate(value, $"property '{name}'"); + } + + private static string[] RequireStringArray(JsonElement element, string name) + { + JsonElement array = RequireProperty(element, name, JsonValueKind.Array); + return array.EnumerateArray().Select((item, index) => + { + if (item.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(item.GetString())) + { + throw new InvalidDataException($"property '{name}[{index}]' must be a non-empty string"); + } + return item.GetString()!; + }).ToArray(); + } + + private static string?[] RequireNullableStringArray(JsonElement element, string name) + { + JsonElement array = RequireProperty(element, name, JsonValueKind.Array); + return array.EnumerateArray().Select((item, index) => + { + if (item.ValueKind == JsonValueKind.Null) return null; + if (item.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(item.GetString())) + { + throw new InvalidDataException($"property '{name}[{index}]' must be null or a non-empty string"); + } + return item.GetString(); + }).ToArray(); + } + + private static bool[] RequireBoolArray(JsonElement element, string name) + { + JsonElement array = RequireProperty(element, name, JsonValueKind.Array); + return array.EnumerateArray().Select((item, index) => + { + if (item.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + throw new InvalidDataException($"property '{name}[{index}]' must be boolean"); + } + return item.GetBoolean(); + }).ToArray(); + } + + private static ExactTimestamp[] RequireDateArray(JsonElement element, string name) + { + JsonElement array = RequireProperty(element, name, JsonValueKind.Array); + return array.EnumerateArray().Select((item, index) => + { + if (item.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(item.GetString())) + { + throw new InvalidDataException( + $"property '{name}[{index}]' must be an exact invariant timestamp"); + } + return ParseDate(item.GetString()!, $"property '{name}[{index}]'"); + }).ToArray(); + } + + private static ExactTimestamp ParseDate(string literal, string location) + { + const string format = "yyyy-MM-dd'T'HH:mm:sszzz"; + if (literal.Length != 25 || + !literal.EndsWith("+00:00", StringComparison.Ordinal) || + !DateTimeOffset.TryParseExact( + literal, + format, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTimeOffset parsed)) + { + throw new InvalidDataException( + $"{location} must use exact yyyy-MM-ddTHH:mm:ss+00:00 timestamp syntax"); + } + + return new ExactTimestamp(literal, parsed); + } + + private static string[][] RequireStringMatrix(JsonElement element, string name) + { + JsonElement array = RequireProperty(element, name, JsonValueKind.Array); + return array.EnumerateArray().Select((item, index) => + { + if (item.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException($"property '{name}[{index}]' must be an array"); + } + return item.EnumerateArray().Select((nested, nestedIndex) => + { + if (nested.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(nested.GetString())) + { + throw new InvalidDataException($"property '{name}[{index}][{nestedIndex}]' must be a non-empty string"); + } + return nested.GetString()!; + }).ToArray(); + }).ToArray(); + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs new file mode 100644 index 0000000..546fc55 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs @@ -0,0 +1,651 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace GraphKit.Auth.Tests; + +public sealed class GraphTokenSourceTests +{ + private static readonly DateTimeOffset InitialNow = + new(2026, 8, 31, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void ConstructionDoesNotAcquireAToken() + { + var clock = new FakeClock(InitialNow); + var client = new FakeTokenClient((_, _) => + throw new InvalidOperationException("acquisition must remain lazy")); + + using var source = CreateRefreshableSource(client, clock); + + Assert.Equal(0, client.AcquireCount); + Assert.Equal(DateTimeOffset.MinValue, source.ExpiresOn); + Assert.Null(source.VerifiedTenantId); + } + + [Fact] + public void OrdinaryAcquireReusesAValidCachedResult() + { + var clock = new FakeClock(InitialNow); + var client = FakeTokenClient.Sequence( + Result("first", InitialNow, InitialNow.AddHours(1)), + Result("unexpected", InitialNow.AddMinutes(1), InitialNow.AddHours(2))); + using var source = CreateRefreshableSource(client, clock); + + GraphTokenResult first = source.Acquire(false, CancellationToken.None); + clock.Advance(TimeSpan.FromMinutes(10)); + GraphTokenResult second = source.Acquire(false, CancellationToken.None); + + Assert.Same(first, second); + Assert.Equal("first", second.AccessToken); + Assert.Equal(1, client.AcquireCount); + } + + [Theory] + [InlineData(600, 60, 1.7647058823529411)] + [InlineData(3600, 300, 8.823529411764707)] + [InlineData(7200, 300, 8.823529411764707)] + public void AdaptiveRefreshUsesTheBoundedLifetimeSkew( + int lifetimeSeconds, + int expectedBaseSkewSeconds, + double expectedSpreadSeconds) + { + var clock = new FakeClock(InitialNow); + GraphTokenResult first = Result( + "adaptive", + InitialNow, + InitialNow.AddSeconds(lifetimeSeconds)); + var client = FakeTokenClient.Sequence( + first, + Result("refreshed", InitialNow.AddSeconds(1), InitialNow.AddHours(4))); + using var source = CreateRefreshableSource(client, clock); + + source.Acquire(false, CancellationToken.None); + clock.UtcNow = first.ExpiresOnUtc + .AddSeconds(-(expectedBaseSkewSeconds + expectedSpreadSeconds)) + .AddMilliseconds(-1); + Assert.Equal("adaptive", source.Acquire(false, CancellationToken.None).AccessToken); + + clock.UtcNow = first.ExpiresOnUtc + .AddSeconds(-(expectedBaseSkewSeconds + expectedSpreadSeconds)) + .AddMilliseconds(1); + Assert.Equal("refreshed", source.Acquire(false, CancellationToken.None).AccessToken); + Assert.Equal(2, client.AcquireCount); + } + + [Fact] + public void FingerprintDerivedSpreadRefreshesEarlierAndDeterministically() + { + var clock = new FakeClock(InitialNow); + GraphTokenResult first = Result("spread-token", InitialNow, InitialNow.AddMinutes(10)); + var client = FakeTokenClient.Sequence( + first, + Result("replacement", InitialNow.AddSeconds(1), InitialNow.AddHours(1))); + using var source = CreateRefreshableSource(client, clock); + + source.Acquire(false, CancellationToken.None); + const double expectedSpreadSeconds = 1.4588235294117646; + clock.UtcNow = first.ExpiresOnUtc.AddSeconds(-(60 + expectedSpreadSeconds)); + + Assert.Equal("replacement", source.Acquire(false, CancellationToken.None).AccessToken); + } + + [Fact] + public void ForcedRefreshReplacesAnOlderCachedResult() + { + var clock = new FakeClock(InitialNow); + var client = FakeTokenClient.Sequence( + Result("first", InitialNow, InitialNow.AddHours(1)), + Result("second", InitialNow.AddSeconds(1), InitialNow.AddHours(2))); + using var source = CreateRefreshableSource(client, clock); + + Assert.Equal("first", source.Acquire(false, CancellationToken.None).AccessToken); + Assert.Equal("second", source.Acquire(true, CancellationToken.None).AccessToken); + Assert.Equal("second", source.Acquire(false, CancellationToken.None).AccessToken); + Assert.Equal(new[] { false, true }, client.ForceRefreshValues); + } + + [Fact] + public async Task OrdinaryAndForcedAcquisitionsUseSeparateFlights() + { + var clock = new FakeClock(InitialNow); + using var release = new ManualResetEventSlim(false); + using var twoEntered = new CountdownEvent(2); + var client = new FakeTokenClient((force, cancellation) => + { + twoEntered.Signal(); + release.Wait(cancellation); + return Result( + force ? "forced" : "ordinary", + InitialNow, + InitialNow.AddHours(force ? 2 : 1)); + }); + using var source = CreateRefreshableSource(client, clock); + + Task ordinary = Task.Run(() => + source.Acquire(false, CancellationToken.None)); + Task forced = Task.Run(() => + source.Acquire(true, CancellationToken.None)); + + Assert.True(twoEntered.Wait(TimeSpan.FromSeconds(5))); + Assert.Equal(2, client.AcquireCount); + release.Set(); + GraphTokenResult[] results = await Task.WhenAll(ordinary, forced); + + Assert.Contains(results, result => result.AccessToken == "ordinary"); + Assert.Contains(results, result => result.AccessToken == "forced"); + } + + [Fact] + public void SameTickForcedResultWinsOverOrdinaryAdoption() + { + var clock = new FakeClock(InitialNow); + using var source = CreateRefreshableSource( + FakeTokenClient.Sequence(Result("unused", InitialNow, InitialNow.AddHours(1))), + clock); + GraphTokenResult forced = Result("forced", InitialNow, InitialNow.AddMinutes(5)); + GraphTokenResult ordinary = Result("ordinary", InitialNow, InitialNow.AddHours(2)); + + source.AdoptSharedResult(forced, true); + source.AdoptSharedResult(ordinary, false); + + Assert.Equal("forced", source.Acquire(false, CancellationToken.None).AccessToken); + } + + [Fact] + public void SameTickSameModePrefersTheLaterExpiry() + { + var clock = new FakeClock(InitialNow); + using var source = CreateRefreshableSource( + FakeTokenClient.Sequence(Result("unused", InitialNow, InitialNow.AddHours(1))), + clock); + + source.AdoptSharedResult(Result("short", InitialNow, InitialNow.AddMinutes(10)), false); + source.AdoptSharedResult(Result("long", InitialNow, InitialNow.AddHours(2)), false); + + Assert.Equal("long", source.Acquire(false, CancellationToken.None).AccessToken); + } + + [Fact] + public void NewerAcquisitionOrderWinsAcrossModes() + { + var clock = new FakeClock(InitialNow); + using var source = CreateRefreshableSource( + FakeTokenClient.Sequence(Result("unused", InitialNow, InitialNow.AddHours(1))), + clock); + source.AdoptSharedResult(Result("forced", InitialNow, InitialNow.AddHours(2)), true); + source.AdoptSharedResult( + Result("newer", InitialNow.AddTicks(1), InitialNow.AddHours(1)), + false); + + Assert.Equal("newer", source.Acquire(false, CancellationToken.None).AccessToken); + } + + [Fact] + public async Task ConcurrentCallersShareOneAcquisition() + { + var clock = new FakeClock(InitialNow); + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + var client = new FakeTokenClient((_, cancellation) => + { + entered.Set(); + release.Wait(cancellation); + return Result("shared", InitialNow, InitialNow.AddHours(1)); + }); + using var source = CreateRefreshableSource(client, clock); + + Task[] callers = Enumerable.Range(0, 12) + .Select(_ => Task.Run(() => source.Acquire(false, CancellationToken.None))) + .ToArray(); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + Assert.True(SpinWait.SpinUntil( + () => source.OrdinaryFlightWaiterCount == callers.Length, + TimeSpan.FromSeconds(5))); + release.Set(); + GraphTokenResult[] results = await Task.WhenAll(callers); + + Assert.Equal(1, client.AcquireCount); + Assert.All(results, result => Assert.Equal("shared", result.AccessToken)); + } + + [Fact] + public async Task FailedAcquisitionFansOutAndACompletedFailureCanRetry() + { + var clock = new FakeClock(InitialNow); + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + var attempt = 0; + var client = new FakeTokenClient((_, cancellation) => + { + int current = Interlocked.Increment(ref attempt); + if (current == 1) + { + entered.Set(); + release.Wait(cancellation); + throw new GraphAuthException( + "fixture_failure", + "Fixture", + "safe fixture failure", + null, + null); + } + + return Result("recovered", InitialNow.AddSeconds(1), InitialNow.AddHours(1)); + }); + using var source = CreateRefreshableSource(client, clock); + + Task[] callers = Enumerable.Range(0, 8) + .Select(_ => Task.Run(() => source.Acquire(false, CancellationToken.None))) + .ToArray(); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + Assert.True(SpinWait.SpinUntil( + () => source.OrdinaryFlightWaiterCount == callers.Length, + TimeSpan.FromSeconds(5))); + release.Set(); + + GraphAuthException[] failures = await Task.WhenAll(callers.Select(async caller => + await Assert.ThrowsAsync(async () => await caller))); + Assert.All(failures, failure => Assert.Equal("fixture_failure", failure.Code)); + Assert.Equal(1, client.AcquireCount); + Assert.Equal("recovered", source.Acquire(false, CancellationToken.None).AccessToken); + Assert.Equal(2, client.AcquireCount); + } + + [Fact] + public async Task CancellingAFollowerDoesNotPoisonTheLiveLeader() + { + var clock = new FakeClock(InitialNow); + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + var client = new FakeTokenClient((_, cancellation) => + { + entered.Set(); + release.Wait(cancellation); + return Result("leader-result", InitialNow, InitialNow.AddHours(1)); + }); + using var source = CreateRefreshableSource(client, clock); + Task leader = Task.Run(() => + source.Acquire(false, CancellationToken.None)); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + using var followerCancellation = new CancellationTokenSource(); + Task follower = Task.Run(() => + source.Acquire(false, followerCancellation.Token)); + Assert.True(SpinWait.SpinUntil( + () => source.OrdinaryFlightWaiterCount == 2, + TimeSpan.FromSeconds(5))); + + followerCancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => await follower); + release.Set(); + + Assert.Equal("leader-result", (await leader).AccessToken); + Assert.Equal(1, client.AcquireCount); + } + + [Fact] + public async Task CancelledLeaderDoesNotPoisonALiveFollower() + { + var clock = new FakeClock(InitialNow); + using var firstEntered = new ManualResetEventSlim(false); + var attempt = 0; + var client = new FakeTokenClient((_, cancellation) => + { + int current = Interlocked.Increment(ref attempt); + if (current == 1) + { + firstEntered.Set(); + cancellation.WaitHandle.WaitOne(); + cancellation.ThrowIfCancellationRequested(); + } + + return Result("replacement", InitialNow.AddSeconds(1), InitialNow.AddHours(1)); + }); + using var source = CreateRefreshableSource(client, clock); + using var leaderCancellation = new CancellationTokenSource(); + Task leader = Task.Run(() => + source.Acquire(false, leaderCancellation.Token)); + Assert.True(firstEntered.Wait(TimeSpan.FromSeconds(5))); + Task follower = Task.Run(() => + source.Acquire(false, CancellationToken.None)); + Assert.True(SpinWait.SpinUntil( + () => source.OrdinaryFlightWaiterCount == 2, + TimeSpan.FromSeconds(5))); + + leaderCancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await leader); + Assert.Equal("replacement", (await follower).AccessToken); + Assert.Equal(2, client.AcquireCount); + } + + [Fact] + public void CompletedCancelledFlightCanRetry() + { + var clock = new FakeClock(InitialNow); + var attempt = 0; + var client = new FakeTokenClient((_, cancellation) => + { + if (Interlocked.Increment(ref attempt) == 1) + { + cancellation.WaitHandle.WaitOne(); + cancellation.ThrowIfCancellationRequested(); + } + + return Result("retried", InitialNow.AddSeconds(1), InitialNow.AddHours(1)); + }); + using var source = CreateRefreshableSource(client, clock); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.ThrowsAny(() => + source.Acquire(false, cancellation.Token)); + Assert.Equal("retried", source.Acquire(false, CancellationToken.None).AccessToken); + Assert.Equal(2, client.AcquireCount); + } + + [Fact] + public void AcquiredResultFromAnotherGenerationIsRejected() + { + var clock = new FakeClock(InitialNow); + var client = FakeTokenClient.Sequence( + Result("wrong", InitialNow, InitialNow.AddHours(1), generation: "generation-2")); + using var source = CreateRefreshableSource(client, clock); + + InvalidOperationException failure = Assert.Throws(() => + source.Acquire(false, CancellationToken.None)); + + Assert.Contains("credential generation", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(DateTimeOffset.MinValue, source.ExpiresOn); + } + + [Fact] + public void AdoptedResultFromAnotherGenerationIsRejected() + { + var clock = new FakeClock(InitialNow); + using var source = CreateRefreshableSource( + FakeTokenClient.Sequence(Result("unused", InitialNow, InitialNow.AddHours(1))), + clock); + + InvalidOperationException failure = Assert.Throws(() => + source.AdoptSharedResult( + Result("wrong", InitialNow, InitialNow.AddHours(1), generation: "generation-2"), + false)); + + Assert.Contains("credential generation", failure.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AdoptionCarriesTenantProofAndExpiryIntoSourceState() + { + var clock = new FakeClock(InitialNow); + using var source = CreateRefreshableSource( + FakeTokenClient.Sequence(Result("unused", InitialNow, InitialNow.AddHours(1))), + clock); + GraphTokenResult adopted = Result("adopted", InitialNow, InitialNow.AddHours(2)); + adopted.VerifiedTenantId = "verified-tenant"; + + source.AdoptSharedResult(adopted, false); + + Assert.Equal(adopted.ExpiresOnUtc, source.ExpiresOn); + Assert.Equal("verified-tenant", source.VerifiedTenantId); + Assert.Same(adopted, source.Acquire(false, CancellationToken.None)); + } + + [Fact] + public void FixedBearerCachesOneExplicitResultAndCannotRefresh() + { + var clock = new FakeClock(InitialNow); + GraphTokenRequest request = BearerRequest("fixed-bearer"); + using var source = new GraphTokenSource(request, client: null, clock.GetUtcNow); + + GraphTokenResult first = source.Acquire(false, CancellationToken.None); + clock.Advance(TimeSpan.FromDays(1)); + GraphTokenResult second = source.Acquire(false, CancellationToken.None); + + Assert.False(source.CanRefresh); + Assert.Equal("BearerToken", source.AuthMode); + Assert.Null(source.ClientId); + Assert.Equal(DateTimeOffset.MinValue, first.ExpiresOnUtc); + Assert.Equal(InitialNow, first.ReceivedOnUtc); + Assert.Equal(new[] { "https://graph.microsoft.com/.default" }, first.Scopes); + Assert.Equal(Fingerprint("fixed-bearer"), first.TokenFingerprint); + Assert.Same(first, second); + Assert.Throws(() => + source.Acquire(true, CancellationToken.None)); + } + + [Fact] + public void FixedBearerHonorsFrameworkCancellationBeforeReturningTheToken() + { + using var source = new GraphTokenSource( + BearerRequest("fixed-bearer"), + client: null, + new FakeClock(InitialNow).GetUtcNow); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.ThrowsAny(() => + source.Acquire(false, cancellation.Token)); + } + + [Fact] + public void SourceIdentityComesOnlyFromTheImmutableRequest() + { + var clock = new FakeClock(InitialNow); + GraphTokenRequest request = SecretRequest(); + using var source = new GraphTokenSource( + request, + FakeTokenClient.Sequence(Result("token", InitialNow, InitialNow.AddHours(1))), + clock.GetUtcNow); + + Assert.True(source.CanRefresh); + Assert.Equal("ClientSecret", source.AuthMode); + Assert.Equal("https://graph.microsoft.com/", source.Audience); + Assert.Equal(request.ClientId?.ToString("D"), source.ClientId); + Assert.Equal("generation-1", source.CredentialGeneration); + } + + [Fact] + public void SourceRejectsEveryUseAfterDisposalAndClearsReferences() + { + var clock = new FakeClock(InitialNow); + var client = FakeTokenClient.Sequence( + Result("cached", InitialNow, InitialNow.AddHours(1))); + var source = CreateRefreshableSource(client, clock); + source.Acquire(false, CancellationToken.None); + + source.Dispose(); + source.Dispose(); + + Assert.Throws(() => _ = source.CanRefresh); + Assert.Throws(() => + source.Acquire(false, CancellationToken.None)); + Assert.Throws(() => + source.AdoptSharedResult(Result("later", InitialNow, InitialNow.AddHours(2)), false)); + Assert.False(source.HasCachedResult); + Assert.False(source.HasClientReference); + Assert.False(source.HasCredentialReference); + Assert.Equal(1, client.DisposeCount); + } + + [Fact] + public void FixedBearerDisposalClearsTokenCredentialAndCacheReferences() + { + var clock = new FakeClock(InitialNow); + var source = new GraphTokenSource( + BearerRequest("fixed-bearer-sensitive-value"), + client: null, + clock.GetUtcNow); + source.Acquire(false, CancellationToken.None); + + source.Dispose(); + + Assert.False(source.HasCachedResult); + Assert.False(source.HasCredentialReference); + Assert.Null(typeof(GraphTokenSource).GetField( + "_fixedBearer", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(source)); + Assert.Null(typeof(GraphTokenSource).GetField( + "_credentialReference", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(source)); + } + + [Fact] + public void ProviderWritesNoTokenOrSecretToConsoleOrTrace() + { + const string secretValue = "never-write-this-secret"; + const string tokenValue = "never-write-this-token"; + var clock = new FakeClock(InitialNow); + using var source = new GraphTokenSource( + BearerRequest(tokenValue), + client: null, + clock.GetUtcNow); + using var consoleOutput = new StringWriter(); + using var consoleError = new StringWriter(); + using var traceOutput = new StringWriter(); + using var traceListener = new TextWriterTraceListener(traceOutput); + TextWriter originalOutput = Console.Out; + TextWriter originalError = Console.Error; + try + { + Console.SetOut(consoleOutput); + Console.SetError(consoleError); + Trace.Listeners.Add(traceListener); + source.Acquire(false, CancellationToken.None); + _ = new ClientSecretCredential(SecureStringFixture.Create(secretValue), false); + Trace.Flush(); + } + finally + { + Trace.Listeners.Remove(traceListener); + Console.SetOut(originalOutput); + Console.SetError(originalError); + } + + string emitted = consoleOutput.ToString() + consoleError + traceOutput; + Assert.DoesNotContain(secretValue, emitted, StringComparison.Ordinal); + Assert.DoesNotContain(tokenValue, emitted, StringComparison.Ordinal); + Assert.Equal(string.Empty, emitted); + } + + private static GraphTokenSource CreateRefreshableSource( + ITokenClient client, + FakeClock clock) + { + return new GraphTokenSource(SecretRequest(), client, clock.GetUtcNow); + } + + internal static GraphTokenRequest SecretRequest( + ClientSecretCredential? credential = null, + string generation = "generation-1") + { + return new GraphTokenRequest( + "Global", + Guid.Parse("00000000-0000-0000-0000-000000000001"), + new Uri("https://login.microsoftonline.com"), + new Uri("https://graph.microsoft.com"), + Guid.Parse("00000000-0000-0000-0000-000000000002"), + GraphAuthMode.ClientSecret, + credential ?? new ClientSecretCredential(SecureStringFixture.Create("fixture-secret"), false), + generation); + } + + internal static GraphTokenRequest BearerRequest(string token) + { + return new GraphTokenRequest( + "Global", + Guid.Parse("00000000-0000-0000-0000-000000000001"), + new Uri("https://login.microsoftonline.com"), + new Uri("https://graph.microsoft.com/"), + null, + GraphAuthMode.BearerToken, + new FixedBearerCredential(token), + "generation-1"); + } + + internal static GraphTokenResult Result( + string token, + DateTimeOffset received, + DateTimeOffset expires, + string generation = "generation-1") + { + return new GraphTokenResult + { + AccessToken = token, + ExpiresOnUtc = expires, + ReceivedOnUtc = received, + TokenType = "Bearer", + Scopes = ["https://graph.microsoft.com/.default"], + TokenFingerprint = Fingerprint(token), + CredentialGeneration = generation + }; + } + + internal static string Fingerprint(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))) + .ToLowerInvariant(); + } + + internal sealed class FakeClock(DateTimeOffset utcNow) + { + internal DateTimeOffset UtcNow { get; set; } = utcNow; + + internal DateTimeOffset GetUtcNow() => UtcNow; + + internal void Advance(TimeSpan duration) => UtcNow += duration; + } + + internal sealed class FakeTokenClient( + Func acquire) : ITokenClient + { + private readonly ConcurrentQueue _forceRefreshValues = new(); + private int _acquireCount; + private int _disposeCount; + + internal int AcquireCount => Volatile.Read(ref _acquireCount); + + internal int DisposeCount => Volatile.Read(ref _disposeCount); + + internal bool[] ForceRefreshValues => _forceRefreshValues.ToArray(); + + public GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation) + { + Interlocked.Increment(ref _acquireCount); + _forceRefreshValues.Enqueue(forceRefresh); + return acquire(forceRefresh, cancellation); + } + + public void Dispose() => Interlocked.Increment(ref _disposeCount); + + internal static FakeTokenClient Sequence(params GraphTokenResult[] results) + { + var queue = new ConcurrentQueue(results); + return new FakeTokenClient((_, _) => + queue.TryDequeue(out GraphTokenResult? result) + ? result + : throw new InvalidOperationException("No fake token result remains.")); + } + } + + internal static class SecureStringFixture + { + internal static System.Security.SecureString Create(string value) + { + var secure = new System.Security.SecureString(); + foreach (char character in value) + { + secure.AppendChar(character); + } + + secure.MakeReadOnly(); + return secure; + } + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs new file mode 100644 index 0000000..74a3549 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs @@ -0,0 +1,868 @@ +using System.Net.Http.Headers; +using System.Reflection; +using System.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Identity.Client; +using Xunit; + +namespace GraphKit.Auth.Tests; + +public sealed class OwnershipTests +{ + private const string CleanupFailureDataKey = + "GraphKit.Auth.ProviderConstructionCleanupFailed"; + private static readonly DateTimeOffset InitialNow = + new(2026, 8, 31, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void PublicFactoryCreatesAllModesWithoutAcquiringAndNeverBuildsMsalForBearer() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + var constructedModes = new List(); + var clients = new List(); + var factory = new GraphTokenSourceFactory( + (request, _) => + { + constructedModes.Add(request.AuthMode); + var client = GraphTokenSourceTests.FakeTokenClient.Sequence( + GraphTokenSourceTests.Result( + request.AuthMode.ToString(), + InitialNow, + InitialNow.AddHours(1))); + clients.Add(client); + return client; + }, + clock.GetUtcNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + GraphTokenRequest[] requests = + [ + CertificateRequest(certificate, ownsMaterial: false), + GraphTokenSourceTests.SecretRequest(), + ManagedIdentityRequest(null), + GraphTokenSourceTests.BearerRequest("fixed") + ]; + + IGraphTokenSource[] sources = requests.Select(factory.Create).ToArray(); + try + { + Assert.Equal( + new[] + { + GraphAuthMode.Certificate, + GraphAuthMode.ClientSecret, + GraphAuthMode.ManagedIdentity + }, + constructedModes); + Assert.Equal(3, clients.Count); + Assert.All(clients, client => Assert.Equal(0, client.AcquireCount)); + Assert.Equal(4, sources.Distinct(ReferenceEqualityComparer.Instance).Count()); + Assert.Equal( + new[] { "Certificate", "ClientSecret", "ManagedIdentity", "BearerToken" }, + sources.Select(source => source.AuthMode)); + } + finally + { + foreach (IGraphTokenSource source in sources) + { + source.Dispose(); + } + } + } + + [Fact] + public void PublicProviderSurfaceContainsOnlyTheParameterlessFactory() + { + Type[] exported = typeof(GraphTokenSourceFactory).Assembly.GetExportedTypes(); + + Type factory = Assert.Single(exported); + Assert.Equal("GraphKit.Auth.GraphTokenSourceFactory", factory.FullName); + ConstructorInfo constructor = Assert.Single(factory.GetConstructors( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + Assert.Empty(constructor.GetParameters()); + MethodInfo create = Assert.Single(factory.GetMethods( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + Assert.Equal(nameof(IGraphTokenSourceFactory.Create), create.Name); + ParameterInfo parameter = Assert.Single(create.GetParameters()); + Assert.Equal(typeof(GraphTokenRequest), parameter.ParameterType); + Assert.Equal(typeof(IGraphTokenSource), create.ReturnType); + Assert.False(create.IsStatic); + Assert.Empty(factory.GetFields( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)); + Assert.Empty(factory.GetProperties( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)); + Assert.Empty(factory.GetEvents( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)); + Assert.Equal(new[] { typeof(IGraphTokenSourceFactory) }, factory.GetInterfaces()); + Assert.Equal(new Version(1, 0, 0, 0), factory.Assembly.GetName().Version); + } + + [Fact] + public void FactoryCreatesOneRealMsalApplicationPerRefreshableSource() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using System.Security.SecureString secret = + GraphTokenSourceTests.SecureStringFixture.Create("fixture-secret"); + GraphTokenRequest[] requests = + [ + CertificateRequest(certificate, ownsMaterial: false), + GraphTokenSourceTests.SecretRequest(new ClientSecretCredential(secret, false)), + ManagedIdentityRequest(null), + ManagedIdentityRequest("00000000-0000-0000-0000-000000000099") + ]; + + var clients = requests + .Select(request => MsalTokenClient.Create(request, clock.GetUtcNow)) + .ToArray(); + try + { + Assert.Equal(4, clients.Select(client => client.ApplicationIdentity).Distinct().Count()); + Assert.All(clients, client => Assert.Equal(0, client.AcquireCount)); + Assert.Equal( + new[] + { + "ConfidentialClientApplication", + "ConfidentialClientApplication", + "ManagedIdentityApplication", + "ManagedIdentityApplication" + }, + clients.Select(client => client.ApplicationKind)); + } + finally + { + foreach (MsalTokenClient client in clients) + { + client.Dispose(); + } + } + + FieldInfo confidential = typeof(MsalTokenClient).GetField( + "_confidentialApplication", + BindingFlags.Instance | BindingFlags.NonPublic)!; + FieldInfo managed = typeof(MsalTokenClient).GetField( + "_managedIdentityApplication", + BindingFlags.Instance | BindingFlags.NonPublic)!; + Assert.All(clients, client => + { + Assert.Null(confidential.GetValue(client)); + Assert.Null(managed.GetValue(client)); + Assert.Throws(() => _ = client.ApplicationIdentity); + }); + } + + [Fact] + public void ConfidentialAuthorityAppendsTenantAndScopeHasExactlyOneDefaultSuffix() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + GraphTokenRequest request = CertificateRequest( + certificate, + ownsMaterial: false, + authority: "https://login.microsoftonline.com/", + resource: "https://graph.microsoft.com/.default"); + using MsalTokenClient client = MsalTokenClient.Create(request, clock.GetUtcNow); + + Assert.Equal( + "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000001", + client.Authority); + Assert.Equal("https://graph.microsoft.com/.default", client.Scope); + } + + [Fact] + public void ManagedIdentityUsesSystemOrUserAssignedSelector() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using MsalTokenClient system = MsalTokenClient.Create( + ManagedIdentityRequest(null), + clock.GetUtcNow); + using MsalTokenClient user = MsalTokenClient.Create( + ManagedIdentityRequest("00000000-0000-0000-0000-000000000099"), + clock.GetUtcNow); + + Assert.Null(system.ManagedIdentityClientId); + Assert.Equal( + "00000000-0000-0000-0000-000000000099", + user.ManagedIdentityClientId); + Assert.Equal("https://graph.microsoft.com/.default", system.Scope); + } + + [Theory] + [InlineData(GraphAuthMode.Certificate)] + [InlineData(GraphAuthMode.ClientSecret)] + public void OwnedCredentialMaterialIsDisposedExactlyOnce(GraphAuthMode mode) + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using System.Security.SecureString secret = + GraphTokenSourceTests.SecureStringFixture.Create("fixture-secret"); + GraphTokenRequest request = mode == GraphAuthMode.Certificate + ? CertificateRequest(certificate, ownsMaterial: true) + : GraphTokenSourceTests.SecretRequest(new ClientSecretCredential(secret, true)); + int disposalCount = 0; + IDisposable? disposedMaterial = null; + var factory = new GraphTokenSourceFactory( + (_, _) => GraphTokenSourceTests.FakeTokenClient.Sequence( + GraphTokenSourceTests.Result("unused", InitialNow, InitialNow.AddHours(1))), + clock.GetUtcNow, + material => + { + disposedMaterial = material; + Interlocked.Increment(ref disposalCount); + material.Dispose(); + }); + IGraphTokenSource source = factory.Create(request); + + source.Dispose(); + source.Dispose(); + + Assert.Equal(1, disposalCount); + Assert.Same( + mode == GraphAuthMode.Certificate ? certificate : secret, + disposedMaterial); + } + + [Theory] + [InlineData(GraphAuthMode.Certificate)] + [InlineData(GraphAuthMode.ClientSecret)] + public void CallerOwnedCredentialMaterialIsNeverDisposed(GraphAuthMode mode) + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using System.Security.SecureString secret = + GraphTokenSourceTests.SecureStringFixture.Create("fixture-secret"); + GraphTokenRequest request = mode == GraphAuthMode.Certificate + ? CertificateRequest(certificate, ownsMaterial: false) + : GraphTokenSourceTests.SecretRequest(new ClientSecretCredential(secret, false)); + int disposalCount = 0; + var factory = new GraphTokenSourceFactory( + (_, _) => GraphTokenSourceTests.FakeTokenClient.Sequence( + GraphTokenSourceTests.Result("unused", InitialNow, InitialNow.AddHours(1))), + clock.GetUtcNow, + _ => Interlocked.Increment(ref disposalCount)); + + IGraphTokenSource source = factory.Create(request); + source.Dispose(); + + Assert.Equal(0, disposalCount); + Assert.True(certificate.HasPrivateKey); + Assert.True(secret.Length > 0); + + using IGraphTokenSource reused = factory.Create(request); + reused.Dispose(); + Assert.Equal(0, disposalCount); + Assert.True(certificate.HasPrivateKey); + Assert.True(secret.Length > 0); + } + + [Theory] + [InlineData(GraphAuthMode.Certificate)] + [InlineData(GraphAuthMode.ClientSecret)] + public void OwnedCredentialMaterialCannotBeTransferredTwiceAcrossFactories(GraphAuthMode mode) + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using SecureString secret = GraphTokenSourceTests.SecureStringFixture.Create("fixture-secret"); + GraphTokenRequest firstRequest = OwnedRequest(mode, certificate, secret); + GraphTokenRequest duplicateRequest = OwnedRequest(mode, certificate, secret); + int disposalCount = 0; + GraphTokenSourceFactory CreateFactory() => new( + (_, _) => GraphTokenSourceTests.FakeTokenClient.Sequence( + GraphTokenSourceTests.Result("unused", InitialNow, InitialNow.AddHours(1))), + clock.GetUtcNow, + material => + { + Interlocked.Increment(ref disposalCount); + material.Dispose(); + }); + var firstFactory = CreateFactory(); + var secondFactory = CreateFactory(); + IGraphTokenSource first = firstFactory.Create(firstRequest); + + GraphAuthException duplicate = Assert.Throws(() => + secondFactory.Create(duplicateRequest)); + first.Dispose(); + first.Dispose(); + + Assert.Equal("credential_material_consumed", duplicate.Code); + Assert.Equal("CredentialOwnership", duplicate.Category); + Assert.Equal(1, disposalCount); + } + + [Theory] + [InlineData(GraphAuthMode.Certificate)] + [InlineData(GraphAuthMode.ClientSecret)] + public async Task ConcurrentOwnedCredentialReuseHasOneWinnerAndOneDisposal(GraphAuthMode mode) + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using SecureString secret = GraphTokenSourceTests.SecureStringFixture.Create("fixture-secret"); + GraphTokenRequest firstRequest = OwnedRequest(mode, certificate, secret); + GraphTokenRequest duplicateRequest = OwnedRequest(mode, certificate, secret); + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + int disposalCount = 0; + GraphTokenSourceFactory CreateFactory() => new( + (_, _) => + { + entered.Set(); + release.Wait(); + return GraphTokenSourceTests.FakeTokenClient.Sequence( + GraphTokenSourceTests.Result("unused", InitialNow, InitialNow.AddHours(1))); + }, + clock.GetUtcNow, + material => + { + Interlocked.Increment(ref disposalCount); + material.Dispose(); + }); + var firstFactory = CreateFactory(); + var secondFactory = CreateFactory(); + + Task first = Task.Run(() => CaptureCreate(firstFactory, firstRequest)); + Task? second = null; + Exception? observationFailure = null; + bool duplicateRejectedBeforeWinnerCompleted = false; + try + { + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + second = Task.Run(() => CaptureCreate(secondFactory, duplicateRequest)); + _ = await second.WaitAsync(TimeSpan.FromSeconds(5)); + duplicateRejectedBeforeWinnerCompleted = !first.IsCompleted; + } + catch (Exception exception) + { + observationFailure = exception; + } + finally + { + release.Set(); + } + + var pending = second is null ? new[] { first } : new[] { first, second }; + CreateOutcome[] outcomes = await Task.WhenAll(pending).WaitAsync(TimeSpan.FromSeconds(5)); + foreach (IGraphTokenSource source in outcomes + .Where(outcome => outcome.Source is not null) + .Select(outcome => outcome.Source!)) + { + source.Dispose(); + } + + if (observationFailure is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(observationFailure) + .Throw(); + } + + Assert.True(duplicateRejectedBeforeWinnerCompleted); + Assert.Single(outcomes, outcome => outcome.Source is not null); + GraphAuthException failure = Assert.Single(outcomes + .Where(outcome => outcome.Failure is not null) + .Select(outcome => outcome.Failure!)); + Assert.Equal("credential_material_consumed", failure.Code); + Assert.Equal(1, disposalCount); + } + + [Theory] + [InlineData(GraphAuthMode.Certificate)] + [InlineData(GraphAuthMode.ClientSecret)] + public void FailedOwnedTransferRemainsConsumedAndIsDisposedExactlyOnce(GraphAuthMode mode) + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using SecureString secret = GraphTokenSourceTests.SecureStringFixture.Create("fixture-secret"); + GraphTokenRequest firstRequest = OwnedRequest(mode, certificate, secret); + GraphTokenRequest duplicateRequest = OwnedRequest(mode, certificate, secret); + int disposalCount = 0; + Action dispose = material => + { + Interlocked.Increment(ref disposalCount); + material.Dispose(); + }; + var failingFactory = new GraphTokenSourceFactory( + (_, _) => throw new InvalidOperationException("construction failure"), + clock.GetUtcNow, + dispose); + var retryFactory = new GraphTokenSourceFactory( + (_, _) => GraphTokenSourceTests.FakeTokenClient.Sequence( + GraphTokenSourceTests.Result("unused", InitialNow, InitialNow.AddHours(1))), + clock.GetUtcNow, + dispose); + + GraphAuthException construction = Assert.Throws(() => + failingFactory.Create(firstRequest)); + GraphAuthException reused = Assert.Throws(() => + retryFactory.Create(duplicateRequest)); + + Assert.Equal("provider_construction_failed", construction.Code); + Assert.Equal("credential_material_consumed", reused.Code); + Assert.Equal(1, disposalCount); + } + + [Fact] + public void FactoryFailureDisposesOnlyTransferredMaterial() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 owned = CertificateFixture.Create(); + using X509Certificate2 callerOwned = CertificateFixture.Create(); + var disposed = new List(); + var factory = new GraphTokenSourceFactory( + (_, _) => throw new InvalidOperationException("factory-sensitive-detail"), + clock.GetUtcNow, + material => + { + disposed.Add(material); + material.Dispose(); + }); + + GraphAuthException ownedFailure = Assert.Throws(() => + factory.Create(CertificateRequest(owned, ownsMaterial: true))); + GraphAuthException callerFailure = Assert.Throws(() => + factory.Create(CertificateRequest(callerOwned, ownsMaterial: false))); + + Assert.Single(disposed); + Assert.Same(owned, disposed[0]); + Assert.DoesNotContain("factory-sensitive-detail", ownedFailure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("factory-sensitive-detail", callerFailure.Message, StringComparison.Ordinal); + Assert.True(callerOwned.HasPrivateKey); + } + + [Fact] + public void CleanupFailureDoesNotReplaceSanitizedConstructionFailure() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 owned = CertificateFixture.Create(); + var factory = new GraphTokenSourceFactory( + (_, _) => throw new InvalidOperationException("construction-sensitive-detail"), + clock.GetUtcNow, + _ => throw new InvalidOperationException("cleanup-sensitive-detail")); + + GraphAuthException failure = Assert.Throws(() => + factory.Create(CertificateRequest(owned, ownsMaterial: true))); + + Assert.Equal("provider_construction_failed", failure.Code); + Assert.Equal("Provider", failure.Category); + Assert.True(failure.Data[CleanupFailureDataKey] is true); + Assert.DoesNotContain("construction-sensitive-detail", failure.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("cleanup-sensitive-detail", failure.ToString(), StringComparison.Ordinal); + failure.Data["provider-owned-data"] = new ProviderOwnedObject(); + + Exception boundaryFailure = RecreateAtProviderBoundary(failure); + GraphAuthException boundaryGraphFailure = Assert.IsType(boundaryFailure); + Assert.Equal("provider_construction_failed", boundaryGraphFailure.Code); + Assert.Equal("Provider", boundaryGraphFailure.Category); + Assert.True(boundaryFailure.Data[CleanupFailureDataKey] is true); + Assert.Single(boundaryFailure.Data); + } + + [Fact] + public void CleanupFailureDoesNotReplaceConstructionCancellation() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 owned = CertificateFixture.Create(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var expected = new OperationCanceledException( + "construction-sensitive-detail", + cancellation.Token); + var factory = new GraphTokenSourceFactory( + (_, _) => throw expected, + clock.GetUtcNow, + _ => throw new InvalidOperationException("cleanup-sensitive-detail")); + + OperationCanceledException failure = Assert.Throws(() => + factory.Create(CertificateRequest(owned, ownsMaterial: true))); + + Assert.Same(expected, failure); + Assert.True(failure.Data[CleanupFailureDataKey] is true); + + Exception boundaryFailure = RecreateAtProviderBoundary(failure); + Assert.IsType(boundaryFailure); + Assert.True(boundaryFailure.Data[CleanupFailureDataKey] is true); + } + + [Fact] + public void CleanupFailureDoesNotReplaceGraphAuthConstructionFailure() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 owned = CertificateFixture.Create(); + var expected = new GraphAuthException( + "fixture_failure", + "Fixture", + "fixture-safe-message", + retryAfter: null, + correlationId: null); + var factory = new GraphTokenSourceFactory( + (_, _) => throw expected, + clock.GetUtcNow, + _ => throw new InvalidOperationException("cleanup-sensitive-detail")); + + GraphAuthException failure = Assert.Throws(() => + factory.Create(CertificateRequest(owned, ownsMaterial: true))); + + Assert.Same(expected, failure); + Assert.True(failure.Data[CleanupFailureDataKey] is true); + + Exception boundaryFailure = RecreateAtProviderBoundary(failure); + GraphAuthException boundaryGraphFailure = Assert.IsType(boundaryFailure); + Assert.Equal("fixture_failure", boundaryGraphFailure.Code); + Assert.Equal("Fixture", boundaryGraphFailure.Category); + Assert.True(boundaryFailure.Data[CleanupFailureDataKey] is true); + } + + [Theory] + [InlineData(GraphAuthMode.Certificate)] + [InlineData(GraphAuthMode.ClientSecret)] + public async Task DisposalCancelsAndDrainsActiveAcquisitionBeforeOwnedMaterial( + GraphAuthMode mode) + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using SecureString secret = GraphTokenSourceTests.SecureStringFixture.Create("fixture-secret"); + using var entered = new ManualResetEventSlim(false); + using var emergencyRelease = new ManualResetEventSlim(false); + var order = new List(); + var client = new GraphTokenSourceTests.FakeTokenClient((_, cancellation) => + { + lock (order) + { + order.Add("acquire-entered"); + } + entered.Set(); + + try + { + int completed = WaitHandle.WaitAny( + new[] { cancellation.WaitHandle, emergencyRelease.WaitHandle }); + if (completed == 1) + { + throw new OperationCanceledException( + "Task 7 fixture emergency release ended a blocked acquisition."); + } + lock (order) + { + order.Add("cancellation-observed"); + } + cancellation.ThrowIfCancellationRequested(); + throw new InvalidOperationException("Task 7 acquisition resumed without cancellation."); + } + finally + { + lock (order) + { + order.Add("acquire-exited"); + } + } + }); + var source = new GraphTokenSource( + OwnedRequest(mode, certificate, secret), + client, + clock.GetUtcNow, + material => + { + lock (order) + { + order.Add("material-disposed"); + } + + material.Dispose(); + }); + Task? acquire = null; + Task? dispose = null; + try + { + acquire = Task.Run(() => + source.Acquire(false, CancellationToken.None)); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + + dispose = Task.Run(source.Dispose); + await Assert.ThrowsAnyAsync(async () => + await acquire.WaitAsync(TimeSpan.FromSeconds(5))); + await dispose.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal( + new[] + { + "acquire-entered", + "cancellation-observed", + "acquire-exited", + "material-disposed" + }, + order); + Assert.Equal(1, client.AcquireCount); + Assert.Equal(1, client.DisposeCount); + } + finally + { + emergencyRelease.Set(); + dispose ??= Task.Run(source.Dispose); + await ObserveBoundedAsync(acquire); + await ObserveBoundedAsync(dispose); + } + + static async Task ObserveBoundedAsync(Task? task) + { + if (task is null) + { + return; + } + + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch + { + // The owning assertions above validate the normal outcome. This + // cleanup observer only prevents a failed mutation from leaking. + } + } + } + + [Fact] + public void MsalFailureIsConvertedToSanitizedGraphAuthException() + { + const string sensitive = "msal-sensitive-token-or-secret"; + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + var msal = new MsalServiceException("temporarily_unavailable", sensitive); + msal.Data["provider-object"] = new ProviderOwnedObject(); + var client = new GraphTokenSourceTests.FakeTokenClient((_, _) => throw msal); + using var source = new GraphTokenSource( + GraphTokenSourceTests.SecretRequest(), + client, + clock.GetUtcNow); + + GraphAuthException failure = Assert.Throws(() => + source.Acquire(false, CancellationToken.None)); + + Assert.Equal("temporarily_unavailable", failure.Code); + Assert.Equal("Service", failure.Category); + Assert.DoesNotContain(sensitive, failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("Msal", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Null(failure.InnerException); + Assert.Empty(failure.Data); + Assert.All( + failure.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance), + property => Assert.DoesNotContain( + "Microsoft.Identity.Client", + property.PropertyType.AssemblyQualifiedName ?? string.Empty, + StringComparison.Ordinal)); + string publicValues = string.Join( + "|", + failure.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.GetIndexParameters().Length == 0) + .Select(property => property.GetValue(failure)?.ToString())); + Assert.DoesNotContain("Microsoft.Identity.Client", publicValues, StringComparison.Ordinal); + Assert.DoesNotContain(nameof(ProviderOwnedObject), publicValues, StringComparison.Ordinal); + Assert.DoesNotContain(sensitive, publicValues, StringComparison.Ordinal); + } + + [Fact] + public void MsalFailureMapsCorrelationAndDeltaRetryAfter() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + var msal = ServiceFailure( + new RetryConditionHeaderValue(TimeSpan.FromSeconds(17)), + "safe-correlation-123"); + + GraphAuthException failure = AcquireFailure(msal, clock); + + Assert.Equal("safe-correlation-123", failure.CorrelationId); + Assert.Equal(TimeSpan.FromSeconds(17), failure.RetryAfter); + } + + [Fact] + public void MsalFailureMapsDateRetryAfterUsingTheInjectedClock() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + var msal = ServiceFailure( + new RetryConditionHeaderValue(InitialNow.AddMinutes(4)), + correlationId: null); + + GraphAuthException failure = AcquireFailure(msal, clock); + + Assert.Equal(TimeSpan.FromMinutes(4), failure.RetryAfter); + } + + [Fact] + public void MsalFailureClampsPastDateRetryAfterToZero() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + var msal = ServiceFailure( + new RetryConditionHeaderValue(InitialNow.AddMinutes(-1)), + correlationId: null); + + GraphAuthException failure = AcquireFailure(msal, clock); + + Assert.Equal(TimeSpan.Zero, failure.RetryAfter); + } + + [Fact] + public void FrameworkCancellationRemainsOperationCanceledException() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + var client = new GraphTokenSourceTests.FakeTokenClient((_, cancellation) => + throw new OperationCanceledException(cancellation)); + using var source = new GraphTokenSource( + GraphTokenSourceTests.SecretRequest(), + client, + clock.GetUtcNow); + + Assert.Throws(() => + source.Acquire(false, CancellationToken.None)); + } + + [Fact] + public void ProviderOwnedFailureIsSanitizedWithoutLeakingItsTypeOrData() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + var providerFailure = new ProviderOwnedException(); + var client = new GraphTokenSourceTests.FakeTokenClient((_, _) => throw providerFailure); + using var source = new GraphTokenSource( + GraphTokenSourceTests.SecretRequest(), + client, + clock.GetUtcNow); + + GraphAuthException failure = Assert.Throws(() => + source.Acquire(false, CancellationToken.None)); + + Assert.Equal("provider_failure", failure.Code); + Assert.Equal("Provider", failure.Category); + Assert.Null(failure.InnerException); + Assert.Empty(failure.Data); + Assert.DoesNotContain(nameof(ProviderOwnedException), failure.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("provider-sensitive-detail", failure.ToString(), StringComparison.Ordinal); + } + + private static GraphTokenRequest CertificateRequest( + X509Certificate2 certificate, + bool ownsMaterial, + string authority = "https://login.microsoftonline.com", + string resource = "https://graph.microsoft.com") + { + return new GraphTokenRequest( + "Global", + Guid.Parse("00000000-0000-0000-0000-000000000001"), + new Uri(authority), + new Uri(resource), + Guid.Parse("00000000-0000-0000-0000-000000000002"), + GraphAuthMode.Certificate, + new CertificateCredential(certificate, ownsMaterial), + "generation-1"); + } + + private static GraphTokenRequest ManagedIdentityRequest(string? userAssignedClientId) + { + return new GraphTokenRequest( + "Global", + Guid.Parse("00000000-0000-0000-0000-000000000001"), + new Uri("https://login.microsoftonline.com"), + new Uri("https://graph.microsoft.com/"), + null, + GraphAuthMode.ManagedIdentity, + new ManagedIdentityCredential(userAssignedClientId), + "generation-1"); + } + + private static GraphTokenRequest OwnedRequest( + GraphAuthMode mode, + X509Certificate2 certificate, + SecureString secret) + { + return mode == GraphAuthMode.Certificate + ? CertificateRequest(certificate, ownsMaterial: true) + : GraphTokenSourceTests.SecretRequest(new ClientSecretCredential(secret, true)); + } + + private static CreateOutcome CaptureCreate( + GraphTokenSourceFactory factory, + GraphTokenRequest request) + { + try + { + return new CreateOutcome(factory.Create(request), null); + } + catch (GraphAuthException exception) + { + return new CreateOutcome(null, exception); + } + } + + private static Exception RecreateAtProviderBoundary(Exception failure) + { + Type boundaryType = typeof(GraphAuthHost).Assembly.GetType( + "GraphKit.Auth.ProviderBoundaryFailure", + throwOnError: true)!; + MethodInfo recreate = boundaryType.GetMethod( + "Recreate", + BindingFlags.Static | BindingFlags.NonPublic) ?? + throw new InvalidOperationException("ProviderBoundaryFailure.Recreate was not found."); + return (Exception)(recreate.Invoke( + null, + [failure, CancellationToken.None, "provider_construction_failed", "Provider"]) ?? + throw new InvalidOperationException("ProviderBoundaryFailure.Recreate returned null.")); + } + + private static MsalServiceException ServiceFailure( + RetryConditionHeaderValue retryAfter, + string? correlationId) + { + var exception = new MsalServiceException( + "temporarily_unavailable", + "msal-sensitive-detail") + { + CorrelationId = correlationId + }; + var response = new HttpResponseMessage(); + response.Headers.RetryAfter = retryAfter; + exception.Headers = response.Headers; + return exception; + } + + private static GraphAuthException AcquireFailure( + MsalServiceException exception, + GraphTokenSourceTests.FakeClock clock) + { + using var source = new GraphTokenSource( + GraphTokenSourceTests.SecretRequest(), + new GraphTokenSourceTests.FakeTokenClient((_, _) => throw exception), + clock.GetUtcNow); + return Assert.Throws(() => + source.Acquire(false, CancellationToken.None)); + } + + private sealed record CreateOutcome( + IGraphTokenSource? Source, + GraphAuthException? Failure); + + private sealed class ProviderOwnedObject + { + } + + private sealed class ProviderOwnedException : Exception + { + internal ProviderOwnedException() + : base("provider-sensitive-detail", new InvalidOperationException("inner-sensitive-detail")) + { + Data["provider-data"] = new ProviderOwnedObject(); + } + } + + private static class CertificateFixture + { + internal static X509Certificate2 Create() + { + using RSA rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=GraphKit.Auth deterministic unit test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(1)); + } + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/packages.lock.json b/src/GraphKit.Auth/GraphKit.Auth.Tests/packages.lock.json new file mode 100644 index 0000000..11e13c9 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/packages.lock.json @@ -0,0 +1,157 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.8.2, )", + "resolved": "2.8.2", + "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.82.1", + "contentHash": "OI+RC+h0JkHhIajhrdQ012s9csOMeiooPbI820JAJ29QwIBI4cTFnCoowpaF2yoUASisF8xAIATstYWuwa+aOw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "graphkit.auth": { + "type": "Project", + "dependencies": { + "GraphKit.Auth.Contracts": "[1.0.0, )", + "Microsoft.Identity.Client": "[4.82.1, )" + } + }, + "graphkit.auth.contracts": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/GraphKit.Auth/GraphKit.Auth.sln b/src/GraphKit.Auth/GraphKit.Auth.sln new file mode 100644 index 0000000..a6bd584 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.sln @@ -0,0 +1,30 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphKit.Auth.Contracts", "GraphKit.Auth.Contracts\GraphKit.Auth.Contracts.csproj", "{A1A5DC18-8823-4AA1-BB0D-6F96E19E13C0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphKit.Auth", "GraphKit.Auth\GraphKit.Auth.csproj", "{B2B6ED29-9934-4BB2-CC1E-70A7F20F24D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphKit.Auth.Tests", "GraphKit.Auth.Tests\GraphKit.Auth.Tests.csproj", "{C3C7FE3A-AA45-4CC3-DD2F-81B8A31035E2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1A5DC18-8823-4AA1-BB0D-6F96E19E13C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1A5DC18-8823-4AA1-BB0D-6F96E19E13C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1A5DC18-8823-4AA1-BB0D-6F96E19E13C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1A5DC18-8823-4AA1-BB0D-6F96E19E13C0}.Release|Any CPU.Build.0 = Release|Any CPU + {B2B6ED29-9934-4BB2-CC1E-70A7F20F24D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2B6ED29-9934-4BB2-CC1E-70A7F20F24D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2B6ED29-9934-4BB2-CC1E-70A7F20F24D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2B6ED29-9934-4BB2-CC1E-70A7F20F24D1}.Release|Any CPU.Build.0 = Release|Any CPU + {C3C7FE3A-AA45-4CC3-DD2F-81B8A31035E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3C7FE3A-AA45-4CC3-DD2F-81B8A31035E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3C7FE3A-AA45-4CC3-DD2F-81B8A31035E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3C7FE3A-AA45-4CC3-DD2F-81B8A31035E2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj b/src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj new file mode 100644 index 0000000..14a3ded --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj @@ -0,0 +1,21 @@ + + + GraphKit.Auth + GraphKit.Auth + 1.0.0.0 + 1.0.0.0 + false + false + false + + + + + + + + false + runtime + + + diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs new file mode 100644 index 0000000..317264e --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs @@ -0,0 +1,563 @@ +using System.Globalization; + +namespace GraphKit.Auth; + +internal interface ITokenClient : IDisposable +{ + GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation); +} + +internal sealed class GraphTokenSource : IGraphTokenSource +{ + private readonly object _cacheGate = new(); + private readonly object _flightGate = new(); + private readonly object _drainGate = new(); + private readonly Func _utcNow; + private readonly Action _disposeMaterial; + private readonly CancellationTokenSource _disposalCancellation = new(); + private readonly ManualResetEventSlim _operationsDrained = new(initialState: true); + private readonly string _authMode; + private readonly string _audience; + private readonly string? _clientId; + private readonly string _credentialGeneration; + private ITokenClient? _client; + private GraphCredential? _credentialReference; + private IDisposable? _ownedMaterial; + private string? _fixedBearer; + private GraphTokenResult? _cachedResult; + private bool _cachedResultWasForceRefresh; + private TokenFlight? _ordinaryFlight; + private TokenFlight? _forcedFlight; + private int _activeOperations; + private int _disposeState; + + internal GraphTokenSource( + GraphTokenRequest request, + ITokenClient? client, + Func utcNow, + Action? disposeMaterial = null) + { + ArgumentNullException.ThrowIfNull(request); + _utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow)); + _disposeMaterial = disposeMaterial ?? (static material => material.Dispose()); + bool fixedBearer = request.AuthMode == GraphAuthMode.BearerToken; + if (fixedBearer != (client is null)) + { + throw new ArgumentException( + fixedBearer + ? "A fixed-bearer source must not construct an authentication client." + : "A refreshable source requires exactly one authentication client.", + nameof(client)); + } + + _authMode = request.AuthMode.ToString(); + _audience = request.Resource.AbsoluteUri; + _clientId = request.AuthMode == GraphAuthMode.ManagedIdentity + ? ((ManagedIdentityCredential)request.Credential).UserAssignedClientId + : request.ClientId?.ToString("D"); + _credentialGeneration = request.CredentialGeneration; + _client = client; + _credentialReference = request.Credential; + _ownedMaterial = GraphTokenSourceFactory.GetTransferredMaterial(request.Credential); + if (request.Credential is FixedBearerCredential bearer) + { + _fixedBearer = bearer.AccessToken; + } + } + + public bool CanRefresh => Read(() => _client is not null); + + public string AuthMode => Read(() => _authMode); + + public string Audience => Read(() => _audience); + + public string? ClientId => Read(() => _clientId); + + public DateTimeOffset ExpiresOn => Read(() => + { + lock (_cacheGate) + { + return _cachedResult?.ExpiresOnUtc ?? DateTimeOffset.MinValue; + } + }); + + public string? VerifiedTenantId => Read(() => + { + lock (_cacheGate) + { + return _cachedResult?.VerifiedTenantId; + } + }); + + public string CredentialGeneration => Read(() => _credentialGeneration); + + internal int OrdinaryFlightWaiterCount + { + get + { + lock (_flightGate) + { + return _ordinaryFlight?.WaiterCount ?? 0; + } + } + } + + internal bool HasCachedResult => Volatile.Read(ref _cachedResult) is not null; + + internal bool HasClientReference => Volatile.Read(ref _client) is not null; + + internal bool HasCredentialReference => + Volatile.Read(ref _credentialReference) is not null || + Volatile.Read(ref _fixedBearer) is not null || + Volatile.Read(ref _ownedMaterial) is not null; + + public GraphTokenResult Acquire( + bool forceRefresh, + CancellationToken cancellation) + { + using OperationLease operation = BeginOperation(cancellation); + if (_client is null) + { + return AcquireFixedBearer(forceRefresh, operation.Cancellation); + } + + if (!forceRefresh && TryGetValidCachedResult(out GraphTokenResult? cached)) + { + return cached!; + } + + return AcquireRefreshable(forceRefresh, cancellation, operation.Cancellation); + } + + public void AdoptSharedResult(GraphTokenResult result, bool forceRefresh) + { + using OperationLease operation = BeginOperation(CancellationToken.None); + ArgumentNullException.ThrowIfNull(result); + ValidateGeneration(result); + CacheResult(result, forceRefresh); + } + + public void Dispose() + { + if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) + { + return; + } + + try + { + _disposalCancellation.Cancel(); + } + catch + { + // Cancellation callbacks are provider implementation details. Cleanup + // continues and only a sanitized lifecycle failure may cross the ABI. + } + + bool operationsRemain; + lock (_drainGate) + { + operationsRemain = _activeOperations != 0; + } + if (operationsRemain) + { + _operationsDrained.Wait(); + } + + ITokenClient? client = Interlocked.Exchange(ref _client, null); + IDisposable? ownedMaterial = Interlocked.Exchange(ref _ownedMaterial, null); + Interlocked.Exchange(ref _credentialReference, null); + Interlocked.Exchange(ref _fixedBearer, null); + lock (_cacheGate) + { + _cachedResult = null; + _cachedResultWasForceRefresh = false; + } + + lock (_flightGate) + { + _ordinaryFlight = null; + _forcedFlight = null; + } + + bool cleanupFailed = false; + try + { + client?.Dispose(); + } + catch + { + cleanupFailed = true; + } + + if (ownedMaterial is not null) + { + try + { + _disposeMaterial(ownedMaterial); + } + catch + { + cleanupFailed = true; + } + } + + _disposalCancellation.Dispose(); + lock (_drainGate) + { + _operationsDrained.Dispose(); + } + Volatile.Write(ref _disposeState, 2); + + if (cleanupFailed) + { + throw new GraphAuthException( + "provider_disposal_failed", + "ProviderLifecycle", + "The isolated authentication provider failed while disposing a token source.", + retryAfter: null, + correlationId: null); + } + } + + private GraphTokenResult AcquireFixedBearer( + bool forceRefresh, + CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + if (forceRefresh) + { + throw new InvalidOperationException( + "A fixed bearer token cannot be refreshed. Supply a new token source instead."); + } + + lock (_cacheGate) + { + if (_cachedResult is not null) + { + return _cachedResult; + } + + string bearer = _fixedBearer ?? + throw new ObjectDisposedException(nameof(GraphTokenSource)); + _cachedResult = TokenResultFactory.Create( + bearer, + DateTimeOffset.MinValue, + _utcNow(), + MsalTokenClient.GetScope(_audience), + _credentialGeneration); + return _cachedResult; + } + } + + private GraphTokenResult AcquireRefreshable( + bool forceRefresh, + CancellationToken callerCancellation, + CancellationToken operationCancellation) + { + while (true) + { + TokenFlight flight; + bool leader; + lock (_flightGate) + { + ref TokenFlight? slot = ref forceRefresh + ? ref _forcedFlight + : ref _ordinaryFlight; + if (slot is null || slot.Completion.Task.IsCompleted) + { + slot = new TokenFlight(); + leader = true; + } + else + { + leader = false; + } + + flight = slot; + flight.AddWaiter(); + } + + try + { + if (leader) + { + ExecuteFlight( + flight, + forceRefresh, + callerCancellation, + operationCancellation); + } + + try + { + return flight.Completion.Task + .WaitAsync(operationCancellation) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) when ( + !leader && + !operationCancellation.IsCancellationRequested && + flight.LeaderCallerWasCancelled) + { + RemoveFlightIfCurrent(flight, forceRefresh); + continue; + } + } + finally + { + flight.RemoveWaiter(); + } + } + } + + private void ExecuteFlight( + TokenFlight flight, + bool forceRefresh, + CancellationToken callerCancellation, + CancellationToken operationCancellation) + { + try + { + GraphTokenResult result; + try + { + ITokenClient client = Volatile.Read(ref _client) ?? + throw new ObjectDisposedException(nameof(GraphTokenSource)); + result = client.Acquire(forceRefresh, operationCancellation) ?? + throw new InvalidOperationException( + "The isolated authentication client returned no token result."); + } + catch (OperationCanceledException exception) + { + flight.LeaderCallerWasCancelled = callerCancellation.IsCancellationRequested; + flight.Completion.TrySetException(exception); + return; + } + catch (GraphAuthException exception) + { + flight.Completion.TrySetException(exception); + return; + } + catch (Exception exception) + { + flight.Completion.TrySetException( + ProviderFailureSanitizer.Create( + exception, + "provider_failure", + "Provider", + _utcNow)); + return; + } + + try + { + ValidateGeneration(result); + CacheResult(result, forceRefresh); + flight.Completion.TrySetResult(result); + } + catch (InvalidOperationException exception) + { + flight.Completion.TrySetException(exception); + } + } + finally + { + RemoveFlightIfCurrent(flight, forceRefresh); + } + } + + private void RemoveFlightIfCurrent(TokenFlight flight, bool forceRefresh) + { + lock (_flightGate) + { + ref TokenFlight? slot = ref forceRefresh + ? ref _forcedFlight + : ref _ordinaryFlight; + if (ReferenceEquals(slot, flight)) + { + slot = null; + } + } + } + + private bool TryGetValidCachedResult(out GraphTokenResult? result) + { + lock (_cacheGate) + { + result = _cachedResult; + if (result is null || result.ExpiresOnUtc <= DateTimeOffset.MinValue) + { + result = null; + return false; + } + + DateTimeOffset refreshAt = result.ExpiresOnUtc - GetRefreshSkew(result); + if (refreshAt > _utcNow()) + { + return true; + } + + result = null; + return false; + } + } + + private static TimeSpan GetRefreshSkew(GraphTokenResult result) + { + double lifetimeSeconds = result.ReceivedOnUtc > DateTimeOffset.MinValue + ? Math.Max(0, (result.ExpiresOnUtc - result.ReceivedOnUtc).TotalSeconds) + : 0; + double baseSeconds = Math.Min(300, Math.Max(60, lifetimeSeconds * 0.1)); + double spreadSeconds = 0; + string fingerprint = result.TokenFingerprint; + if (fingerprint.Length >= 2 && + byte.TryParse( + fingerprint.AsSpan(0, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out byte bucket)) + { + spreadSeconds = baseSeconds * 0.1 * (bucket / 255d); + } + + return TimeSpan.FromSeconds(baseSeconds + spreadSeconds); + } + + private void CacheResult(GraphTokenResult result, bool forceRefresh) + { + lock (_cacheGate) + { + GraphTokenResult? current = _cachedResult; + bool replace = current is null || result.ReceivedOnUtc > current.ReceivedOnUtc; + if (!replace && + current is not null && + result.ReceivedOnUtc == current.ReceivedOnUtc) + { + replace = (forceRefresh && !_cachedResultWasForceRefresh) || + (forceRefresh == _cachedResultWasForceRefresh && + result.ExpiresOnUtc > current.ExpiresOnUtc); + } + + if (replace) + { + _cachedResult = result; + _cachedResultWasForceRefresh = forceRefresh; + } + } + } + + private void ValidateGeneration(GraphTokenResult result) + { + if (!string.Equals( + result.CredentialGeneration, + _credentialGeneration, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Refusing a token result from a different credential generation."); + } + } + + private TResult Read(Func read) + { + ThrowIfDisposed(); + return read(); + } + + private OperationLease BeginOperation(CancellationToken callerCancellation) + { + lock (_drainGate) + { + ThrowIfDisposed(); + _activeOperations++; + if (_activeOperations == 1) + { + _operationsDrained.Reset(); + } + } + + try + { + ThrowIfDisposed(); + CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + callerCancellation, + _disposalCancellation.Token); + return new OperationLease(this, linked); + } + catch + { + ExitOperation(); + throw; + } + } + + private void ExitOperation() + { + lock (_drainGate) + { + _activeOperations--; + if (_activeOperations == 0) + { + _operationsDrained.Set(); + } + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeState) != 0, + this); + } + + private sealed class TokenFlight + { + private int _waiterCount; + + internal TaskCompletionSource Completion { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal bool LeaderCallerWasCancelled { get; set; } + + internal int WaiterCount => Volatile.Read(ref _waiterCount); + + internal void AddWaiter() => Interlocked.Increment(ref _waiterCount); + + internal void RemoveWaiter() => Interlocked.Decrement(ref _waiterCount); + } + + private sealed class OperationLease : IDisposable + { + private GraphTokenSource? _owner; + private CancellationTokenSource? _linkedCancellation; + + internal OperationLease( + GraphTokenSource owner, + CancellationTokenSource linkedCancellation) + { + _owner = owner; + _linkedCancellation = linkedCancellation; + } + + internal CancellationToken Cancellation => + Volatile.Read(ref _linkedCancellation)?.Token ?? + throw new ObjectDisposedException(nameof(OperationLease)); + + public void Dispose() + { + CancellationTokenSource? linked = Interlocked.Exchange( + ref _linkedCancellation, + null); + GraphTokenSource? owner = Interlocked.Exchange(ref _owner, null); + if (owner is null) + { + return; + } + + linked?.Dispose(); + owner.ExitOperation(); + } + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs new file mode 100644 index 0000000..4e5bb6e --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs @@ -0,0 +1,163 @@ +using System.Runtime.CompilerServices; + +namespace GraphKit.Auth; + +public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory +{ + private const string CleanupFailureDataKey = + "GraphKit.Auth.ProviderConstructionCleanupFailed"; + private static readonly ConditionalWeakTable + ConsumedOwnedMaterials = new(); + private static readonly object ConsumedMaterialMarker = new(); + private readonly Func, ITokenClient> _clientFactory; + private readonly Func _utcNow; + private readonly Action _disposeMaterial; + + public GraphTokenSourceFactory() + : this( + static (request, utcNow) => MsalTokenClient.Create(request, utcNow), + static () => DateTimeOffset.UtcNow, + static material => material.Dispose()) + { + } + + internal GraphTokenSourceFactory( + Func, ITokenClient> clientFactory, + Func utcNow, + Action? disposeMaterial = null) + { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + _utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow)); + _disposeMaterial = disposeMaterial ?? (static material => material.Dispose()); + } + + public IGraphTokenSource Create(GraphTokenRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + IDisposable? transferredMaterial = null; + IDisposable? requestedTransfer = GetTransferredMaterial(request.Credential); + if (requestedTransfer is not null) + { + try + { + ConsumedOwnedMaterials.Add( + requestedTransfer, + ConsumedMaterialMarker); + } + catch (ArgumentException) + { + throw new GraphAuthException( + "credential_material_consumed", + "CredentialOwnership", + "The owned credential material has already been transferred to an authentication source.", + retryAfter: null, + correlationId: null); + } + + transferredMaterial = requestedTransfer; + } + + ITokenClient? client = null; + try + { + if (request.AuthMode != GraphAuthMode.BearerToken) + { + client = _clientFactory(request, _utcNow) ?? + throw new InvalidOperationException( + "The isolated authentication client factory returned no client."); + } + + var source = new GraphTokenSource( + request, + client, + _utcNow, + _disposeMaterial); + client = null; + transferredMaterial = null; + return source; + } + catch (OperationCanceledException exception) + { + if (CleanupFailedTransfer(client, transferredMaterial)) + { + MarkCleanupFailure(exception); + } + throw; + } + catch (GraphAuthException exception) + { + if (CleanupFailedTransfer(client, transferredMaterial)) + { + MarkCleanupFailure(exception); + } + throw; + } + catch (Exception exception) + { + bool cleanupFailed = CleanupFailedTransfer(client, transferredMaterial); + GraphAuthException failure = ProviderFailureSanitizer.Create( + exception, + "provider_construction_failed", + "Provider", + _utcNow); + if (cleanupFailed) + { + MarkCleanupFailure(failure); + } + throw failure; + } + } + + private bool CleanupFailedTransfer( + ITokenClient? client, + IDisposable? transferredMaterial) + { + bool cleanupFailed = false; + try + { + client?.Dispose(); + } + catch + { + cleanupFailed = true; + } + + if (transferredMaterial is not null) + { + try + { + _disposeMaterial(transferredMaterial); + } + catch + { + cleanupFailed = true; + } + } + + return cleanupFailed; + } + + private static void MarkCleanupFailure(Exception primaryFailure) + { + try + { + primaryFailure.Data[CleanupFailureDataKey] = true; + } + catch + { + // A provider-owned cancellation subtype can override Data. Its metadata must + // never be able to replace the primary cancellation while recording cleanup. + } + } + + internal static IDisposable? GetTransferredMaterial(GraphCredential credential) + { + return credential switch + { + CertificateCredential { OwnsMaterial: true } certificate => certificate.Certificate, + ClientSecretCredential { OwnsMaterial: true } secret => secret.Secret, + _ => null + }; + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs b/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs new file mode 100644 index 0000000..8a918f1 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs @@ -0,0 +1,388 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; + +namespace GraphKit.Auth; + +internal sealed class MsalTokenClient : ITokenClient +{ + private readonly Func _utcNow; + private readonly string _scope; + private readonly string _credentialGeneration; + private IConfidentialClientApplication? _confidentialApplication; + private IManagedIdentityApplication? _managedIdentityApplication; + private int _acquireCount; + private int _disposeState; + + private MsalTokenClient( + IConfidentialClientApplication application, + string authority, + string scope, + string credentialGeneration, + Func utcNow) + { + _confidentialApplication = application; + Authority = authority; + _scope = scope; + _credentialGeneration = credentialGeneration; + _utcNow = utcNow; + ApplicationKind = "ConfidentialClientApplication"; + } + + private MsalTokenClient( + IManagedIdentityApplication application, + string? managedIdentityClientId, + string scope, + string credentialGeneration, + Func utcNow) + { + _managedIdentityApplication = application; + ManagedIdentityClientId = managedIdentityClientId; + _scope = scope; + _credentialGeneration = credentialGeneration; + _utcNow = utcNow; + ApplicationKind = "ManagedIdentityApplication"; + } + + internal string? Authority { get; } + + internal string Scope => _scope; + + internal string? ManagedIdentityClientId { get; } + + internal string ApplicationKind { get; } + + internal int AcquireCount => Volatile.Read(ref _acquireCount); + + internal object ApplicationIdentity => + (object?)Volatile.Read(ref _confidentialApplication) ?? + Volatile.Read(ref _managedIdentityApplication) ?? + throw new ObjectDisposedException(nameof(MsalTokenClient)); + + internal static MsalTokenClient Create( + GraphTokenRequest request, + Func utcNow) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(utcNow); + string scope = GetScope(request.Resource.AbsoluteUri); + + try + { + return request.AuthMode switch + { + GraphAuthMode.Certificate => CreateCertificate(request, scope, utcNow), + GraphAuthMode.ClientSecret => CreateClientSecret(request, scope, utcNow), + GraphAuthMode.ManagedIdentity => CreateManagedIdentity(request, scope, utcNow), + GraphAuthMode.BearerToken => throw new InvalidOperationException( + "A fixed bearer token must not construct an authentication client."), + _ => throw new InvalidOperationException("The authentication mode is unsupported.") + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (GraphAuthException) + { + throw; + } + catch (Exception exception) + { + throw ProviderFailureSanitizer.Create( + exception, + "provider_construction_failed", + "Provider", + utcNow); + } + } + + public GraphTokenResult Acquire( + bool forceRefresh, + CancellationToken cancellation) + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeState) != 0, + this); + Interlocked.Increment(ref _acquireCount); + + try + { + AuthenticationResult result; + IConfidentialClientApplication? confidential = + Volatile.Read(ref _confidentialApplication); + if (confidential is not null) + { + result = confidential + .AcquireTokenForClient([_scope]) + .WithForceRefresh(forceRefresh) + .ExecuteAsync(cancellation) + .GetAwaiter() + .GetResult(); + } + else + { + IManagedIdentityApplication managed = + Volatile.Read(ref _managedIdentityApplication) ?? + throw new ObjectDisposedException(nameof(MsalTokenClient)); + result = managed + .AcquireTokenForManagedIdentity(_scope) + .WithForceRefresh(forceRefresh) + .ExecuteAsync(cancellation) + .GetAwaiter() + .GetResult(); + } + + return TokenResultFactory.Create( + result.AccessToken, + result.ExpiresOn, + _utcNow(), + result.Scopes, + _credentialGeneration); + } + catch (OperationCanceledException) + { + throw; + } + catch (GraphAuthException) + { + throw; + } + catch (Exception exception) + { + throw ProviderFailureSanitizer.Create( + exception, + "provider_failure", + "Provider", + _utcNow); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposeState, 1) != 0) + { + return; + } + + Interlocked.Exchange(ref _confidentialApplication, null); + Interlocked.Exchange(ref _managedIdentityApplication, null); + } + + internal static string GetScope(string resource) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resource); + string normalized = resource.TrimEnd('/'); + const string suffix = "/.default"; + while (normalized.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[..^suffix.Length].TrimEnd('/'); + } + + return normalized + suffix; + } + + private static MsalTokenClient CreateCertificate( + GraphTokenRequest request, + string scope, + Func utcNow) + { + var credential = (CertificateCredential)request.Credential; + string authority = GetTenantAuthority(request); + IConfidentialClientApplication application = ConfidentialClientApplicationBuilder + .Create(request.ClientId!.Value.ToString("D")) + .WithAuthority(authority) + .WithCertificate(credential.Certificate) + .Build(); + return new MsalTokenClient( + application, + authority, + scope, + request.CredentialGeneration, + utcNow); + } + + private static MsalTokenClient CreateClientSecret( + GraphTokenRequest request, + string scope, + Func utcNow) + { + var credential = (ClientSecretCredential)request.Credential; + string authority = GetTenantAuthority(request); + nint secretPointer = Marshal.SecureStringToGlobalAllocUnicode(credential.Secret); + try + { + string secret = Marshal.PtrToStringUni(secretPointer) ?? + throw new InvalidOperationException( + "The transferred client secret could not be read."); + IConfidentialClientApplication application = ConfidentialClientApplicationBuilder + .Create(request.ClientId!.Value.ToString("D")) + .WithAuthority(authority) + .WithClientSecret(secret) + .Build(); + return new MsalTokenClient( + application, + authority, + scope, + request.CredentialGeneration, + utcNow); + } + finally + { + Marshal.ZeroFreeGlobalAllocUnicode(secretPointer); + } + } + + private static MsalTokenClient CreateManagedIdentity( + GraphTokenRequest request, + string scope, + Func utcNow) + { + var credential = (ManagedIdentityCredential)request.Credential; + ManagedIdentityId identity = credential.UserAssignedClientId is null + ? ManagedIdentityId.SystemAssigned + : ManagedIdentityId.WithUserAssignedClientId(credential.UserAssignedClientId); + IManagedIdentityApplication application = ManagedIdentityApplicationBuilder + .Create(identity) + .Build(); + return new MsalTokenClient( + application, + credential.UserAssignedClientId, + scope, + request.CredentialGeneration, + utcNow); + } + + private static string GetTenantAuthority(GraphTokenRequest request) + { + return request.Authority.AbsoluteUri.TrimEnd('/') + "/" + + request.TenantId.ToString("D"); + } +} + +internal static class TokenResultFactory +{ + internal static GraphTokenResult Create( + string accessToken, + DateTimeOffset expiresOnUtc, + DateTimeOffset receivedOnUtc, + string scope, + string credentialGeneration) => + Create( + accessToken, + expiresOnUtc, + receivedOnUtc, + [scope], + credentialGeneration); + + internal static GraphTokenResult Create( + string accessToken, + DateTimeOffset expiresOnUtc, + DateTimeOffset receivedOnUtc, + IEnumerable scopes, + string credentialGeneration) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accessToken); + ArgumentNullException.ThrowIfNull(scopes); + string[] grantedScopes = [.. scopes]; + byte[] bearerBytes = Encoding.UTF8.GetBytes(accessToken); + try + { + string fingerprint = Convert.ToHexString(SHA256.HashData(bearerBytes)) + .ToLowerInvariant(); + return new GraphTokenResult + { + AccessToken = accessToken, + ExpiresOnUtc = expiresOnUtc, + ReceivedOnUtc = receivedOnUtc, + TokenType = "Bearer", + Scopes = grantedScopes, + VerifiedTenantId = null, + TokenFingerprint = fingerprint, + CredentialGeneration = credentialGeneration + }; + } + finally + { + CryptographicOperations.ZeroMemory(bearerBytes); + } + } +} + +internal static class ProviderFailureSanitizer +{ + internal static GraphAuthException Create( + Exception exception, + string defaultCode, + string defaultCategory, + Func? utcNow = null) + { + ArgumentNullException.ThrowIfNull(exception); + if (exception is GraphAuthException graphAuthException) + { + return graphAuthException; + } + + string code = defaultCode; + string category = defaultCategory; + string? correlationId = null; + TimeSpan? retryAfter = null; + if (exception is MsalException msalException) + { + code = string.IsNullOrWhiteSpace(msalException.ErrorCode) + ? "authentication_failed" + : msalException.ErrorCode; + correlationId = string.IsNullOrWhiteSpace(msalException.CorrelationId) + ? null + : msalException.CorrelationId; + category = msalException switch + { + MsalUiRequiredException => "UiRequired", + MsalServiceException => "Service", + MsalClientException => "Client", + _ => "Authentication" + }; + + if (msalException is MsalServiceException serviceException) + { + retryAfter = GetRetryAfter( + serviceException, + utcNow ?? (static () => DateTimeOffset.UtcNow)); + } + } + + return new GraphAuthException( + code, + category, + "The isolated authentication provider could not complete token acquisition.", + retryAfter, + correlationId); + } + + private static TimeSpan? GetRetryAfter( + MsalServiceException exception, + Func utcNow) + { + if (exception.Headers?.RetryAfter is null) + { + return null; + } + + TimeSpan? retryAfter = exception.Headers.RetryAfter.Delta; + if (retryAfter is null && exception.Headers.RetryAfter.Date is DateTimeOffset date) + { + retryAfter = date - utcNow(); + } + + if (retryAfter < TimeSpan.Zero) + { + return TimeSpan.Zero; + } + + return retryAfter; + } +} diff --git a/src/GraphKit.Auth/GraphKit.Auth/packages.lock.json b/src/GraphKit.Auth/GraphKit.Auth/packages.lock.json new file mode 100644 index 0000000..6746150 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/packages.lock.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.Identity.Client": { + "type": "Direct", + "requested": "[4.82.1, )", + "resolved": "4.82.1", + "contentHash": "OI+RC+h0JkHhIajhrdQ012s9csOMeiooPbI820JAJ29QwIBI4cTFnCoowpaF2yoUASisF8xAIATstYWuwa+aOw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.ValueTuple": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "graphkit.auth.contracts": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 new file mode 100644 index 0000000..0369a82 --- /dev/null +++ b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 @@ -0,0 +1,492 @@ +BeforeAll { + $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $built = Get-ChildItem (Join-Path $repoRoot 'output/module/GraphKit') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -eq $built) { + throw 'GraphKit is not built. Run ./build.ps1 -Tasks build first, then re-run the tests.' + } + $script:BuiltManifest = Join-Path $built.FullName 'GraphKit.psd1' + Import-Module $script:BuiltManifest -Force -ErrorAction Stop + + if ($null -eq ('GraphKit.Tests.LifecycleBlockingHandler' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphKit.Tests +{ + public sealed class LifecycleBlockingHandler : HttpMessageHandler + { + public const string ContractMarker = "GraphKit.Task8.LifecycleSenderFixture/2"; + private int _disposeCount; + private int _sendCount; + + public int DisposeCount { get { return _disposeCount; } } + public int SendCount { get { return _sendCount; } } + public CancellationToken SeenToken { get; private set; } + public TaskCompletionSource Started { get; } = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Exited { get; } = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _sendCount); + SeenToken = cancellationToken; + Started.TrySetResult(true); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("The blocking test handler resumed without cancellation."); + } + finally + { + Exited.TrySetResult(true); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Interlocked.Increment(ref _disposeCount); + } + base.Dispose(disposing); + } + } + + public sealed class LifecycleCompletionCancellingHandler : HttpMessageHandler + { + private int _disposeCount; + private int _sendCount; + + public int DisposeCount { get { return _disposeCount; } } + public int SendCount { get { return _sendCount; } } + public CancellationToken SeenToken { get; private set; } + public CancellationTokenSource CompletionCancellation { get; set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _sendCount); + SeenToken = cancellationToken; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new CompletionCancellingContent(CompletionCancellation) + }); + } + + private sealed class CompletionCancellingContent : HttpContent + { + private static readonly byte[] Body = Encoding.UTF8.GetBytes("{\"value\":[]}"); + private readonly CancellationTokenSource _cancellation; + + public CompletionCancellingContent(CancellationTokenSource cancellation) + { + _cancellation = cancellation; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + return SerializeAndCancel(stream); + } + + protected override Task SerializeToStreamAsync( + Stream stream, + TransportContext context, + CancellationToken cancellationToken) + { + return SerializeAndCancel(stream); + } + + private Task SerializeAndCancel(Stream stream) + { + stream.Write(Body, 0, Body.Length); + _cancellation.Cancel(); + return Task.CompletedTask; + } + + protected override bool TryComputeLength(out long length) + { + length = Body.Length; + return true; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Interlocked.Increment(ref _disposeCount); + } + base.Dispose(disposing); + } + } + + public sealed class LifecycleCleanupProbe : IDisposable + { + private readonly string _name; + private readonly LifecycleBlockingHandler _handler; + private readonly ConcurrentQueue _order; + private int _disposeCount; + private int _preconditionsSatisfied; + + public LifecycleCleanupProbe( + string name, + LifecycleBlockingHandler handler, + ConcurrentQueue order) + { + _name = name; + _handler = handler; + _order = order; + } + + public int DisposeCount => Volatile.Read(ref _disposeCount); + public bool PreconditionsSatisfied => Volatile.Read(ref _preconditionsSatisfied) != 0; + + public void Dispose() + { + if (_handler.SeenToken.IsCancellationRequested && _handler.Exited.Task.IsCompleted) + Volatile.Write(ref _preconditionsSatisfied, 1); + _order.Enqueue(_name); + Interlocked.Increment(ref _disposeCount); + } + } +} +'@ + } + $handlerType = 'GraphKit.Tests.LifecycleBlockingHandler' -as [type] + $handlerMarker = if ($null -ne $handlerType) { + $handlerType.GetField('ContractMarker') + } + else { + $null + } + if ($null -eq $handlerMarker -or + [string] $handlerMarker.GetRawConstantValue() -cne + 'GraphKit.Task8.LifecycleSenderFixture/2') { + throw ( + 'The process-global lifecycle sender fixture is stale. ' + + 'Run this file in a fresh PowerShell process.' + ) + } +} + +Describe 'Send-GraphHttpRequest module lifecycle adapter' { + It 'links module cancellation into token acquisition and releases the lease on a hard auth failure' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $source = [pscustomobject] @{ + LifecycleState = $state + SawCancellation = $false + } + $source | Add-Member -MemberType ScriptMethod -Name Acquire -Value { + param([bool] $ForceRefresh, [System.Threading.CancellationToken] $CancellationToken) + $this.LifecycleState.ShutdownCts.Cancel() + $this.SawCancellation = $CancellationToken.IsCancellationRequested + throw 'token-acquire-sentinel' + } + + try { + $caught = $null + try { + InModuleScope GraphKit -Parameters @{ State = $state; Source = $source } { + param($State, $Source) + Send-GraphHttpRequest -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` + -Method GET -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $Source -LifecycleState $State + } + } + catch { + $caught = $_.Exception + } + + $caught | Should -Not -BeNullOrEmpty + $caught.Message | Should -Match 'token-acquire-sentinel' + $source.SawCancellation | Should -BeTrue + $state.ActiveOperations | Should -Be 0 + $state.Drained.IsSet | Should -BeTrue + } + finally { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + } + } + + It 'marks module cancellation raised during token acquisition for retry classification' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $source = [pscustomobject] @{ + LifecycleState = $state + } + $source | Add-Member -MemberType ScriptMethod -Name Acquire -Value { + param([bool] $ForceRefresh, [System.Threading.CancellationToken] $CancellationToken) + $this.LifecycleState.ShutdownCts.Cancel() + $CancellationToken.ThrowIfCancellationRequested() + } + + try { + $failure = $null + try { + InModuleScope GraphKit -Parameters @{ State = $state; Source = $source } { + param($State, $Source) + Send-GraphHttpRequest -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` + -Method GET -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $Source -LifecycleState $State + } + } + catch { + $failure = $_.Exception + } + + $isCancellation = $false + $isMarked = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + } + if ($candidate.Data['GraphKit.OperationCancellation'] -eq $true) { + $isMarked = $true + } + $candidate = $candidate.InnerException + } + + $failure | Should -Not -BeNullOrEmpty + $isCancellation | Should -BeTrue + $isMarked | Should -BeTrue + $state.ShutdownCts.IsCancellationRequested | Should -BeTrue + $state.ActiveOperations | Should -Be 0 + $state.Drained.IsSet | Should -BeTrue + } + finally { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + } + } + + It 'normalizes a clean response that races module shutdown before it can become success' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $handler = [GraphKit.Tests.LifecycleCompletionCancellingHandler]::new() + $handler.CompletionCancellation = $state.ShutdownCts + $client = [System.Net.Http.HttpClient]::new($handler, $false) + $factory = { + param([int] $ConnectTimeoutSeconds) + [pscustomobject] @{ + Client = $client + OwnedByGraphKit = $false + } + }.GetNewClosure() + + try { + $result = InModuleScope GraphKit -Parameters @{ + State = $state + Factory = $factory + } { + param($State, $Factory) + Send-GraphHttpRequest -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` + -Method GET -CredentialPolicy None -LifecycleState $State ` + -HttpClientFactory $Factory + } + + $handler.SendCount | Should -Be 1 + $state.ShutdownCts.IsCancellationRequested | Should -BeTrue + $result.ResponseReceived | Should -BeTrue + $result.StatusCode | Should -Be 200 + $result.TransportException | Should -Not -BeNullOrEmpty + $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue + $state.ActiveOperations | Should -Be 0 + $state.Drained.IsSet | Should -BeTrue + $handler.DisposeCount | Should -Be 0 + } + finally { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + $client.Dispose() + } + } + + It 'cancels an in-flight physical send, drains it, and leaves an injected client caller-owned' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $handler = [GraphKit.Tests.LifecycleBlockingHandler]::new() + $client = [System.Net.Http.HttpClient]::new($handler, $true) + $cleanupOrder = [Collections.Concurrent.ConcurrentQueue[string]]::new() + $hostCleanup = [GraphKit.Tests.LifecycleCleanupProbe]::new( + 'host', $handler, $cleanupOrder) + $sourceCleanup = [GraphKit.Tests.LifecycleCleanupProbe]::new( + 'source', $handler, $cleanupOrder) + InModuleScope GraphKit -Parameters @{ + State = $state + HostCleanup = $hostCleanup + SourceCleanup = $sourceCleanup + } { + param($State, $HostCleanup, $SourceCleanup) + $null = Register-GraphModuleOwnedResource ` + -State $State -Resource $HostCleanup -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource ` + -State $State -Resource $SourceCleanup -OwnedByGraphKit:$true + } + $registered = @($state.OwnedResources) + $registered.Count | Should -Be 2 + [object]::ReferenceEquals($registered[0], $hostCleanup) | Should -BeTrue + [object]::ReferenceEquals($registered[1], $sourceCleanup) | Should -BeTrue + $sendRunspace = $null + $stopRunspace = $null + $sendPipeline = $null + $stopPipeline = $null + $sendAsync = $null + $stopAsync = $null + $sendReceived = $false + $stopReceived = $false + + try { + # Start-ThreadJob shares one process-global throttle whose capacity is + # changed by unrelated tests. Prepare both workers synchronously so + # this test measures sender shutdown rather than ambient job scheduling. + $sendRunspace = [runspacefactory]::CreateRunspace() + $stopRunspace = [runspacefactory]::CreateRunspace() + foreach ($workerRunspace in @($sendRunspace, $stopRunspace)) { + $workerRunspace.ThreadOptions = + [System.Management.Automation.Runspaces.PSThreadOptions]::UseNewThread + $workerRunspace.Open() + + $initializer = [powershell]::Create() + $initializer.Runspace = $workerRunspace + try { + $null = $initializer.AddCommand('Import-Module'). + AddParameter('Name', $script:BuiltManifest). + AddParameter('Force', $true). + AddParameter('ErrorAction', 'Stop').Invoke() + if ($initializer.HadErrors) { + $messages = @($initializer.Streams.Error | ForEach-Object { + $_.Exception.Message + }) -join '; ' + throw "Dedicated lifecycle worker failed to import GraphKit: $messages" + } + } + finally { + $initializer.Dispose() + } + } + + $sendPipeline = [powershell]::Create() + $sendPipeline.Runspace = $sendRunspace + $null = $sendPipeline.AddScript({ + param($State, $Client) + & (Get-Module GraphKit) { + param($LifecycleState, $InjectedClient) + $factory = { + param([int] $ConnectTimeoutSeconds) + [pscustomobject] @{ + Client = $InjectedClient + OwnedByGraphKit = $false + } + }.GetNewClosure() + Send-GraphHttpRequest -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` + -Method GET -CredentialPolicy None -LifecycleState $LifecycleState ` + -HttpClientFactory $factory -TimeoutHeadersSeconds 30 -TimeoutBodySeconds 30 + } $State $Client + }).AddArgument($state).AddArgument($client) + $sendAsync = $sendPipeline.BeginInvoke() + + $handler.Started.Task.Wait(5000) | Should -BeTrue ` + -Because 'dedicated runspace setup completed before the timed physical-send assertion' + $state.ActiveOperations | Should -Be 1 + + $stopPipeline = [powershell]::Create() + $stopPipeline.Runspace = $stopRunspace + $null = $stopPipeline.AddScript({ + param($State) + & (Get-Module GraphKit) { + param($LifecycleState) + Stop-GraphModule -State $LifecycleState + } $State + }).AddArgument($state) + $stopAsync = $stopPipeline.BeginInvoke() + + if (-not $stopAsync.AsyncWaitHandle.WaitOne(5000)) { + $client.CancelPendingRequests() + throw 'Stop-GraphModule did not cancel and drain the in-flight sender within five seconds.' + } + + $stopReceived = $true + $null = $stopPipeline.EndInvoke($stopAsync) + $sendAsync.AsyncWaitHandle.WaitOne(10000) | Should -BeTrue + $sendReceived = $true + $result = @($sendPipeline.EndInvoke($sendAsync)) + $result.Count | Should -Be 1 + $result = $result[0] + + $handler.SendCount | Should -Be 1 + $handler.SeenToken.IsCancellationRequested | Should -BeTrue + $handler.Exited.Task.IsCompleted | Should -BeTrue + $result.TransportException | Should -Not -BeNullOrEmpty + $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue + $state.WaitForCleanup(5000) | Should -BeTrue + $state.ActiveOperations | Should -Be 0 + $state.CleanupComplete | Should -BeTrue + $state.OwnedResources.Count | Should -Be 0 + @($state.GetFailures()).Count | Should -Be 0 + @($cleanupOrder.ToArray()) | Should -Be @('source', 'host') + $sourceCleanup.DisposeCount | Should -Be 1 + $hostCleanup.DisposeCount | Should -Be 1 + $sourceCleanup.PreconditionsSatisfied | Should -BeTrue + $hostCleanup.PreconditionsSatisfied | Should -BeTrue + $handler.DisposeCount | Should -Be 0 + { $client.CancelPendingRequests() } | Should -Not -Throw + } + finally { + try { $client.CancelPendingRequests() } catch { } + if ($null -ne $sendAsync -and -not $sendReceived) { + if ($sendAsync.AsyncWaitHandle.WaitOne(10000)) { + try { $null = $sendPipeline.EndInvoke($sendAsync) } catch { } + } + else { + try { $sendPipeline.Stop() } catch { } + } + } + if ($null -ne $stopAsync -and -not $stopReceived) { + if ($stopAsync.AsyncWaitHandle.WaitOne(10000)) { + try { $null = $stopPipeline.EndInvoke($stopAsync) } catch { } + } + else { + try { $stopPipeline.Stop() } catch { } + } + } + try { $client.Dispose() } catch { } + if ($null -ne $sendPipeline) { $sendPipeline.Dispose() } + if ($null -ne $stopPipeline) { $stopPipeline.Dispose() } + if ($null -ne $sendRunspace) { + try { $sendRunspace.Close() } catch { } + $sendRunspace.Dispose() + } + if ($null -ne $stopRunspace) { + try { $stopRunspace.Close() } catch { } + $stopRunspace.Dispose() + } + } + } +} diff --git a/tests/Adapter/LoopbackSender.Tests.ps1 b/tests/Adapter/LoopbackSender.Tests.ps1 index d1b9f94..e525623 100644 --- a/tests/Adapter/LoopbackSender.Tests.ps1 +++ b/tests/Adapter/LoopbackSender.Tests.ps1 @@ -208,6 +208,7 @@ Describe 'Real sender: timeouts and cancellation' { } $result.TransportException | Should -Not -BeNullOrEmpty -Because 'the body phase must time out on its own budget' + $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -Not -BeTrue } It 'aborts an in-flight request when the caller cancels' { @@ -221,6 +222,7 @@ Describe 'Real sender: timeouts and cancellation' { } $result.TransportException | Should -Not -BeNullOrEmpty -Because 'a cancelled token must actually abort the request' + $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue } } diff --git a/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 b/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 index 712b69d..1cfba6b 100644 --- a/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 +++ b/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 @@ -7,6 +7,97 @@ BeforeAll { } Import-Module (Join-Path $built.FullName 'GraphKit.psd1') -Force -ErrorAction Stop + if ($null -eq ('GraphKit.Tests.CompiledAdoptionTokenSource' -as [type])) { + $fixtureRoot = Join-Path $TestDrive 'compiled-adoption-source' + $outputRoot = Join-Path $fixtureRoot 'out' + $null = New-Item -ItemType Directory -Path $fixtureRoot -Force + $contractsPath = [GraphKit.Auth.IGraphTokenSource].Assembly.Location + $escapedContractsPath = [Security.SecurityElement]::Escape($contractsPath) + Set-Content -LiteralPath (Join-Path $fixtureRoot 'Fixture.cs') -NoNewline -Encoding utf8NoBOM -Value @' +using System; +using System.Threading; +using GraphKit.Auth; + +namespace GraphKit.Tests; + +public sealed class CompiledAdoptionTokenSource : IGraphTokenSource +{ + private readonly string _generation; + private int _adoptCount; + + public CompiledAdoptionTokenSource(string generation) => _generation = generation; + public int AdoptCount => Volatile.Read(ref _adoptCount); + public bool CanRefresh => true; + public string AuthMode => "BearerToken"; + public string Audience => "https://graph.microsoft.com"; + public string? ClientId => null; + public DateTimeOffset ExpiresOn { get; private set; } + public string? VerifiedTenantId { get; private set; } + public string CredentialGeneration => _generation; + + public GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + DateTimeOffset now = DateTimeOffset.UtcNow; + return new GraphTokenResult + { + AccessToken = "compiled-adoption-token", + ExpiresOnUtc = now.AddHours(1), + ReceivedOnUtc = now, + TokenType = "Bearer", + Scopes = new[] { "https://graph.microsoft.com/.default" }, + TokenFingerprint = "compiled-fingerprint", + CredentialGeneration = _generation + }; + } + + public void AdoptSharedResult(GraphTokenResult result, bool forceRefresh) + { + if (!string.Equals(result.CredentialGeneration, _generation, StringComparison.Ordinal)) + throw new InvalidOperationException("wrong generation"); + Interlocked.Increment(ref _adoptCount); + ExpiresOn = result.ExpiresOnUtc; + VerifiedTenantId = result.VerifiedTenantId; + } + + public void Dispose() { } +} +'@ + Set-Content -LiteralPath (Join-Path $fixtureRoot 'Fixture.csproj') -NoNewline -Encoding utf8NoBOM -Value @" + + + net8.0 + GraphKit.Task6.SenderFixture + enable + enable + true + true + none + + + + $escapedContractsPath + false + + + +"@ + $compilerOutput = & dotnet build (Join-Path $fixtureRoot 'Fixture.csproj') ` + -c Release -o $outputRoot --nologo --verbosity quiet 2>&1 + $fixtureAssembly = Join-Path $outputRoot 'GraphKit.Task6.SenderFixture.dll' + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $fixtureAssembly -PathType Leaf)) { + throw "Task 6 compiled sender fixture did not compile: $($compilerOutput | Out-String)" + } + $fixtureStream = [IO.MemoryStream]::new( + [IO.File]::ReadAllBytes($fixtureAssembly), $false) + try { + $null = [Runtime.Loader.AssemblyLoadContext]::Default.LoadFromStream($fixtureStream) + } + finally { + $fixtureStream.Dispose() + } + } + $script:VerifiedTenant = [guid] '00000000-0000-0000-0000-000000000001' $script:openServers = [System.Collections.Generic.List[object]]::new() @@ -123,19 +214,20 @@ Describe 'Send-GraphHttpRequest (loopback through the real sender)' { Context 'loopback server lifecycle' { AfterEach { foreach ($server in @($script:openServers)) { - if ($null -eq $server) { continue } - # Stop the listener first so a runspace still blocked in - # Listener.GetContext() fails fast instead of hanging EndInvoke(). - if ($null -ne $server.Listener) { - try { $server.Listener.Stop() } catch { } - try { $server.Listener.Close() } catch { } - } - if ($null -ne $server.Ps -and $null -ne $server.Handle) { - try { $null = $server.Ps.EndInvoke($server.Handle) } catch { } - } - if ($null -ne $server.Runspace) { - try { $server.Runspace.Close() } catch { } - try { $server.Runspace.Dispose() } catch { } + if ($null -ne $server) { + # Stop the listener first so a runspace still blocked in + # Listener.GetContext() fails fast instead of hanging EndInvoke(). + if ($null -ne $server.Listener) { + try { $server.Listener.Stop() } catch { } + try { $server.Listener.Close() } catch { } + } + if ($null -ne $server.Ps -and $null -ne $server.Handle) { + try { $null = $server.Ps.EndInvoke($server.Handle) } catch { } + } + if ($null -ne $server.Runspace) { + try { $server.Runspace.Close() } catch { } + try { $server.Runspace.Dispose() } catch { } + } } } $script:openServers.Clear() @@ -178,6 +270,66 @@ Describe 'Send-GraphHttpRequest (loopback through the real sender)' { $r.StatusCode | Should -Be 204 } + It 'adopts a shared compiled result only through the exact compiled source/result branch' { + [GraphKit.Tests.CompiledAdoptionTokenSource].Assembly.Location | + Should -BeNullOrEmpty + $port = Get-FreePort + $server = Start-GraphLoopback -Port $port -Handler { + param($Context, $Listener, $Captured) + $Context.Response.StatusCode = 200 + } + $authority = [uri] "http://127.0.0.1:$port" + $source = [GraphKit.Tests.CompiledAdoptionTokenSource]::new('compiled-generation') + + $result = InModuleScope GraphKit -ArgumentList $port, $authority, $source { + param($Port, $ExpectedAuthority, $TokenSource) + Send-GraphHttpRequest -Method GET -Uri ([uri] "http://127.0.0.1:$Port/compiled") ` + -CredentialPolicy GraphBearer -ExpectedAuthority $ExpectedAuthority -TokenSource $TokenSource ` + -TokenAcquisitionKey 'task6-compiled-adoption' -TimeoutConnectionSeconds 5 ` + -TimeoutHeadersSeconds 5 -TimeoutBodySeconds 5 + } + $captured = Stop-GraphLoopback -Server $server + + $result.StatusCode | Should -Be 200 + $captured.Authorization | Should -BeExactly 'Bearer compiled-adoption-token' + $source.AdoptCount | Should -Be 1 + } + + It 'does not duck-type compiled shared-result adoption onto an arbitrary source' { + $port = Get-FreePort + $server = Start-GraphLoopback -Port $port -Handler { + param($Context, $Listener, $Captured) + $Context.Response.StatusCode = 200 + } + $authority = [uri] "http://127.0.0.1:$port" + $duck = [pscustomobject]@{ AdoptCount = 0 } + $duck | Add-Member -MemberType ScriptMethod -Name Acquire -Value { + param([bool]$forceRefresh, $cancellation) + $now = [datetimeoffset]::UtcNow + [GraphKit.Auth.GraphTokenResult]@{ + AccessToken = 'duck-compiled-token'; ExpiresOnUtc = $now.AddHours(1); ReceivedOnUtc = $now + TokenType = 'Bearer'; Scopes = @('https://graph.microsoft.com/.default') + TokenFingerprint = 'duck-fingerprint'; CredentialGeneration = 'duck-generation' + } + } + $duck | Add-Member -MemberType ScriptMethod -Name AdoptSharedResult -Value { + param($result, [bool]$forceRefresh) + $this.AdoptCount++ + } + + $result = InModuleScope GraphKit -ArgumentList $port, $authority, $duck { + param($Port, $ExpectedAuthority, $TokenSource) + Send-GraphHttpRequest -Method GET -Uri ([uri] "http://127.0.0.1:$Port/duck") ` + -CredentialPolicy GraphBearer -ExpectedAuthority $ExpectedAuthority -TokenSource $TokenSource ` + -TokenAcquisitionKey 'task6-duck-adoption' -TimeoutConnectionSeconds 5 ` + -TimeoutHeadersSeconds 5 -TimeoutBodySeconds 5 + } + $null = Stop-GraphLoopback -Server $server + + $result.StatusCode | Should -Be 200 + $duck.AdoptCount | Should -Be 0 + } + It 'GraphBearer refuses a foreign authority with a hard error' { $port = Get-FreePort $wrongAuthority = [uri] 'https://graph.microsoft.com' @@ -259,6 +411,7 @@ Describe 'Send-GraphHttpRequest (loopback through the real sender)' { $r.ResponseReceived | Should -BeFalse $r.StatusCode | Should -Be 0 $r.TransportException | Should -Not -BeNullOrEmpty + $r.TransportException.Data['GraphKit.OperationCancellation'] | Should -Not -BeTrue } It 'fires the header timeout independently' { @@ -279,6 +432,7 @@ Describe 'Send-GraphHttpRequest (loopback through the real sender)' { $r.ResponseReceived | Should -BeFalse $r.StatusCode | Should -Be 0 $r.TransportException | Should -Not -BeNullOrEmpty + $r.TransportException.Data['GraphKit.OperationCancellation'] | Should -Not -BeTrue } It 'fires the body timeout independently of the header phase' { @@ -305,6 +459,7 @@ Describe 'Send-GraphHttpRequest (loopback through the real sender)' { $r.ResponseReceived | Should -BeTrue $r.StatusCode | Should -Be 200 $r.TransportException | Should -Not -BeNullOrEmpty + $r.TransportException.Data['GraphKit.OperationCancellation'] | Should -Not -BeTrue } It 'a cancelled token aborts an in-flight request' { @@ -330,6 +485,7 @@ Describe 'Send-GraphHttpRequest (loopback through the real sender)' { $r.ResponseReceived | Should -BeFalse $r.TransportException | Should -Not -BeNullOrEmpty + $r.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue $sw.Elapsed.TotalSeconds | Should -BeLessThan 5 } diff --git a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 new file mode 100644 index 0000000..f282558 --- /dev/null +++ b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 @@ -0,0 +1,616 @@ +BeforeAll { + $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $built = Get-ChildItem (Join-Path $repoRoot 'output/module/GraphKit') -Directory ` + -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | Select-Object -First 1 + if (-not $built) { + throw "No built GraphKit module found under '$repoRoot/output/module/GraphKit'. Run './build.ps1 -Tasks build' first." + } + Import-Module (Join-Path $built.FullName 'GraphKit.psd1') -Force -ErrorAction Stop + + $script:TenantId = [guid] '00000000-0000-0000-0000-000000000001' + # Port zero is reserved and cannot name a listening TCP destination. These + # cases prove cancellation happens before transport, so no server is needed. + $script:NoSendAuthority = [uri] 'http://127.0.0.1:0/' + $script:openServers = [System.Collections.Generic.List[object]]::new() + + function Start-TokenPipelineServer { + param( + [object[]] $Responses, + [scriptblock] $CandidatePortProvider = { + param([int] $Attempt) + + [System.Security.Cryptography.RandomNumberGenerator]::GetInt32( + 49152, + 65536) + } + ) + + $listener = $null + $port = 0 + $bindFailure = $null + foreach ($attempt in 1..16) { + $candidatePort = & $CandidatePortProvider $attempt + $candidate = [System.Net.HttpListener]::new() + $candidate.Prefixes.Add("http://127.0.0.1:$candidatePort/") + try { + $candidate.Start() + $listener = $candidate + $port = $candidatePort + break + } + catch [System.Net.HttpListenerException] { + $bindFailure = $_.Exception + try { $candidate.Close() } catch { } + } + } + if ($null -eq $listener) { + throw [System.InvalidOperationException]::new( + 'Could not bind the token-pipeline loopback server after 16 attempts.', + $bindFailure) + } + + $runspace = [runspacefactory]::CreateRunspace() + $runspace.Open() + $powershell = [powershell]::Create() + $powershell.Runspace = $runspace + [void] $powershell.AddScript({ + param($Listener, $Responses) + + $captured = [System.Collections.Generic.List[object]]::new() + try { + foreach ($responseDefinition in @($Responses)) { + $context = $Listener.GetContext() + $captured.Add([pscustomobject] @{ + Path = $context.Request.Url.PathAndQuery + Authorization = $context.Request.Headers['Authorization'] + }) + + $context.Response.StatusCode = [int] $responseDefinition.StatusCode + if (-not [string]::IsNullOrEmpty([string] $responseDefinition.Body)) { + $bytes = [System.Text.Encoding]::UTF8.GetBytes([string] $responseDefinition.Body) + $context.Response.ContentType = 'application/json' + $context.Response.ContentLength64 = $bytes.Length + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } + $context.Response.Close() + } + } + catch { + $captured.Add([pscustomobject] @{ Error = $_.Exception.Message }) + } + + return ,$captured.ToArray() + }).AddArgument($listener).AddArgument($Responses) + + $handle = $powershell.BeginInvoke() + $server = [pscustomobject] @{ + Listener = $listener + PowerShell = $powershell + Handle = $handle + Runspace = $runspace + Authority = [uri] "http://127.0.0.1:$port/" + } + $script:openServers.Add($server) + return $server + } + + function Stop-TokenPipelineServer { + param($Server) + + if ($null -eq $Server) { return @() } + + $captured = @() + if ($null -ne $Server.Listener) { + try { $Server.Listener.Stop() } catch { } + try { $Server.Listener.Close() } catch { } + } + if ($null -ne $Server.PowerShell -and $null -ne $Server.Handle) { + try { $captured = @($Server.PowerShell.EndInvoke($Server.Handle)) } + catch { $captured = @([pscustomobject] @{ Error = $_.Exception.Message }) } + } + if ($null -ne $Server.Runspace) { + try { $Server.Runspace.Close() } catch { } + try { $Server.Runspace.Dispose() } catch { } + } + return $captured + } + + function New-RotatingTokenSource { + param([string] $ClaimedTenantId = $null) + + $source = [pscustomobject] @{ + CanRefresh = $true + AuthMode = 'Provider' + Audience = 'https://graph.microsoft.com' + ClientId = 'client-id' + CredentialGeneration = 'generation-1' + ClaimedTenantId = $ClaimedTenantId + AcquireFlags = [System.Collections.Generic.List[bool]]::new() + } + + $source | Add-Member -MemberType ScriptMethod -Name Acquire -Value { + param([bool] $forceRefresh, $cancellationToken) + + $this.AcquireFlags.Add($forceRefresh) + $ordinal = $this.AcquireFlags.Count + return [pscustomobject] @{ + AccessToken = "token-$ordinal" + ExpiresOnUtc = [System.DateTimeOffset]::MinValue + ReceivedOnUtc = [System.DateTimeOffset]::UtcNow + TokenType = 'Bearer' + Scopes = @('https://graph.microsoft.com/.default') + VerifiedTenantId = $this.ClaimedTenantId + TokenFingerprint = "fingerprint-$ordinal" + CredentialGeneration = $this.CredentialGeneration + } + } + + return $source + } + + function New-TokenPipelineContext { + param( + [uri] $Authority, + [object] $TokenSource, + [guid] $ClientId = [guid] '00000000-0000-0000-0000-000000000010' + ) + + return [pscustomobject] @{ + ProfileId = 'token-identity-test' + TenantId = $script:TenantId + Cloud = 'Global' + GraphBaseUri = $Authority + ClientId = $ClientId + TokenSource = $TokenSource + CredentialFingerprint = 'credential-fingerprint' + AcquisitionCacheKey = 'token-identity-acquisition-key' + IdentityState = 'NotAcquired' + } + } + + function New-TokenPipelineDescriptor { + param( + [string] $ReplayPolicy = 'Safe', + [string] $ThrottleClass = 'Read', + [string] $IdentityRequirement + ) + + $descriptor = @{ + CredentialPolicy = 'GraphBearer' + ReplayPolicy = $ReplayPolicy + ThrottleClass = $ThrottleClass + ResourceFamily = 'Graph.Test' + ApiVersion = 'v1.0' + Condition = $null + Reconciliation = $null + } + + if ($PSBoundParameters.ContainsKey('IdentityRequirement')) { + $descriptor.IdentityRequirement = $IdentityRequirement + } + + return $descriptor + } +} + +Describe 'Composed retry and sender token identity' { + AfterEach { + foreach ($server in @($script:openServers)) { + if ($null -eq $server) { continue } + if ($null -ne $server.Listener) { + try { $server.Listener.Stop() } catch { } + try { $server.Listener.Close() } catch { } + } + if ($null -ne $server.PowerShell -and $null -ne $server.Handle) { + try { $null = $server.PowerShell.EndInvoke($server.Handle) } catch { } + } + if ($null -ne $server.Runspace) { + try { $server.Runspace.Close() } catch { } + try { $server.Runspace.Dispose() } catch { } + } + } + $script:openServers.Clear() + InModuleScope GraphKit { + $script:GraphTenantBindingCache = @{} + } + } + + It 'retries an occupied bind candidate and reports the exact bound authority' { + $script:collisionCandidateCalls = 0 + $script:collisionBlocker = [System.Net.Sockets.TcpListener]::new( + [System.Net.IPAddress]::Loopback, + 0) + $script:collisionBlocker.Start() + $script:collisionBlockedPort = + ([System.Net.IPEndPoint] $script:collisionBlocker.LocalEndpoint).Port + + try { + $server = Start-TokenPipelineServer -Responses @() -CandidatePortProvider { + param([int] $Attempt) + + $script:collisionCandidateCalls++ + if ($Attempt -eq 2) { + $script:collisionBlocker.Stop() + } + return $script:collisionBlockedPort + } + + $script:collisionCandidateCalls | Should -Be 2 + $server.Authority.AbsoluteUri | Should -BeExactly ( + "http://127.0.0.1:{0}/" -f $script:collisionBlockedPort) + } + finally { + $script:collisionBlocker.Stop() + $script:collisionBlocker.Dispose() + } + } + + It 'acquires exactly once for one ordinary Graph attempt' { + $server = Start-TokenPipelineServer -Responses @( + @{ StatusCode = 204; Body = $null } + ) + $authority = $server.Authority + $tokenSource = New-RotatingTokenSource + + $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor), $authority { + param($Context, $Descriptor, $Authority) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'resource')) ` + -Method GET -Headers @{} -Body $null -CancellationToken ([System.Threading.CancellationToken]::None) + } + + $captured = Stop-TokenPipelineServer $server + $result.Outcome | Should -Be 'Succeeded' + $tokenSource.AcquireFlags.Count | Should -Be 1 + $captured.Count | Should -Be 1 + $captured[0].Authorization | Should -Be 'Bearer token-1' + } + + It 'proves a descriptor-verified GET even when the provider claims the tenant without a cache record' { + $server = Start-TokenPipelineServer -Responses @( + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = $server.Authority + $tokenSource = New-RotatingTokenSource -ClaimedTenantId $script:TenantId.ToString() + $script:verifiedGetProofCalls = 0 + $script:verifiedGetProofToken = $null + $script:verifiedGetProofScope = $null + $script:verifiedGetProofRemaining = [TimeSpan]::Zero + + Mock Confirm-GraphTenantBinding -ModuleName GraphKit { + param($Context, $TokenResult, $CancellationToken, $RemainingDeadline) + + $script:verifiedGetProofCalls++ + $script:verifiedGetProofToken = [string] $TokenResult.AccessToken + $script:verifiedGetProofRemaining = [TimeSpan] $RemainingDeadline + $script:verifiedGetProofScope = & (Get-Module GraphKit) { + param($ProofContext, $ProofTokenResult) + $scope = New-GraphThrottleScope -Context $ProofContext -Descriptor @{ + ThrottleClass = 'Read' + ResourceFamily = 'Graph.Directory' + } + $cacheKey = Get-GraphTenantBindingKey ` + -Fingerprint ([string] $ProofTokenResult.TokenFingerprint) ` + -Generation ([string] $ProofTokenResult.CredentialGeneration) ` + -TenantId $ProofContext.TenantId + $script:GraphTenantBindingCache[$cacheKey] = $true + return $scope + } $Context $TokenResult + $TokenResult.VerifiedTenantId = [string] $Context.TenantId + } + + $result = InModuleScope GraphKit -ArgumentList ` + (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), ` + (New-TokenPipelineDescriptor -IdentityRequirement Verified), $authority { + param($Context, $Descriptor, $Authority) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'resource')) ` + -Method GET -Headers @{} -Body $null -DeadlineSeconds 17 ` + -CancellationToken ([System.Threading.CancellationToken]::None) + } + + $captured = Stop-TokenPipelineServer $server + $result.Outcome | Should -Be 'Succeeded' + $result.Provenance.IdentityState | Should -BeExactly 'VerifiedForToken' + $result.Provenance.TenantId | Should -Be $script:TenantId + $result.Provenance.ActualTenantId | Should -Be $script:TenantId + $result.Provenance.TokenFingerprint | Should -BeExactly 'fingerprint-1' + $result.Provenance.CredentialGeneration | Should -BeExactly 'generation-1' + $result.Provenance.Cloud | Should -BeExactly 'Global' + $result.Provenance.Keys | Should -Not -Contain 'ClientId' + $result.Provenance.Keys | Should -Not -Contain 'ClientScopeFingerprint' + $script:verifiedGetProofCalls | Should -Be 1 + $script:verifiedGetProofToken | Should -BeExactly 'token-1' + $script:verifiedGetProofRemaining | Should -BeGreaterThan ([TimeSpan]::Zero) + $script:verifiedGetProofRemaining | Should -BeLessOrEqual ([TimeSpan]::FromSeconds(17)) + $script:verifiedGetProofScope.CoarseKey | Should -BeExactly 'Global|00000000-0000-0000-0000-000000000001|00000000-0000-0000-0000-000000000010|Read' + $script:verifiedGetProofScope.LeafKey | Should -BeExactly 'Global|00000000-0000-0000-0000-000000000001|00000000-0000-0000-0000-000000000010|Read|Graph.Directory' + $captured | Should -HaveCount 1 + $captured[0].Path | Should -Be '/resource' + $captured[0].Authorization | Should -BeExactly 'Bearer token-1' + } + + It 'returns DeadlineExpired and releases admission before acquisition when the inherited proof budget is exhausted' { + $authority = $script:NoSendAuthority + $tokenSource = New-RotatingTokenSource + $script:deadlineProofEntered = 0 + $script:deadlineProofSawCancellation = $false + + Mock Confirm-GraphTenantBinding -ModuleName GraphKit { + param($Context, $TokenResult, [System.Threading.CancellationToken] $CancellationToken, $RemainingDeadline) + $script:deadlineProofEntered++ + $script:deadlineProofSawCancellation = $CancellationToken.IsCancellationRequested + $CancellationToken.ThrowIfCancellationRequested() + } + + $capture = InModuleScope GraphKit -ArgumentList ` + (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), ` + (New-TokenPipelineDescriptor -IdentityRequirement Verified), $authority { + param($Context, $Descriptor, $Authority) + + $script:deadlineClock = [datetime] '2026-09-01T12:00:00Z' + $script:deadlineOuterBudget = [TimeSpan]::Zero + $send = { + param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, + $TokenSource, $ForceRefresh, $TokenAcquisitionKey, $ExpectedAuthority, + $TargetTenantId, $VerifyTenantBinding, $TenantBindingContext) + + # The outer retry supplied both monotonic remaining time and its + # injected clock deadline. Move that clock to the exact deadline + # without sleeping; the sender must deduct it before proof. + $script:deadlineOuterBudget = [TimeSpan] $TenantBindingContext.RemainingDeadline + $script:deadlineClock = $script:deadlineClock.AddSeconds(5) + Send-GraphHttpRequest -Uri $Uri -Method $Method -Headers $Headers -Body $Body ` + -CancellationToken $CancellationToken -CredentialPolicy $CredentialPolicy ` + -TokenSource $TokenSource -ForceRefresh:$ForceRefresh ` + -TokenAcquisitionKey $TokenAcquisitionKey -ExpectedAuthority $ExpectedAuthority ` + -TargetTenantId $TargetTenantId -VerifyTenantBinding:$VerifyTenantBinding ` + -TenantBindingContext $TenantBindingContext + } + + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $result = Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri]::new($Authority, 'resource')) -Method GET -Headers @{} -Body $null ` + -DeadlineSeconds 5 -CancellationToken ([System.Threading.CancellationToken]::None) ` + -Injections @{ + Send = $send + UtcNow = { $script:deadlineClock } + Delay = { param($Seconds) } + Jitter = { 0.0 } + } + [pscustomobject] @{ + Result = $result + InFlight = (Get-GraphThrottleCoordinator).GetInFlight([string] $scope.LeafKey) + OuterBudget = $script:deadlineOuterBudget + } + } + + $capture.Result.Outcome | Should -BeExactly 'DeadlineExpired' + $capture.Result.Certainty | Should -BeExactly 'Indeterminate' + $capture.InFlight | Should -Be 0 + $capture.OuterBudget | Should -BeGreaterThan ([TimeSpan]::Zero) + $capture.OuterBudget | Should -BeLessOrEqual ([TimeSpan]::FromSeconds(5)) + $script:deadlineProofEntered | Should -Be 0 + $script:deadlineProofSawCancellation | Should -BeFalse + $tokenSource.AcquireFlags | Should -HaveCount 0 + } + + It 'returns Cancelled and releases admission when a descriptor-verified GET is cancelled during proof' { + $authority = $script:NoSendAuthority + $cts = [System.Threading.CancellationTokenSource]::new() + $tokenSource = New-RotatingTokenSource + $tokenSource | Add-Member -MemberType NoteProperty -Name CancellationSource -Value $cts + $clockCapture = [pscustomobject] @{ UtcNow = [datetime] '2026-09-01T12:00:00Z' } + $tokenSource | Add-Member -MemberType NoteProperty -Name ClockCapture -Value $clockCapture + $tokenSource | Add-Member -MemberType ScriptMethod -Name Acquire -Force -Value { + param([bool] $forceRefresh, $cancellationToken) + + $this.AcquireFlags.Add($forceRefresh) + $this.CancellationSource.Cancel() + $this.ClockCapture.UtcNow = $this.ClockCapture.UtcNow.AddSeconds(5) + return [pscustomobject] @{ + AccessToken = 'cancelled-verified-get-token' + ExpiresOnUtc = [System.DateTimeOffset]::UtcNow.AddHours(1) + ReceivedOnUtc = [System.DateTimeOffset]::UtcNow + TokenType = 'Bearer' + Scopes = @('https://graph.microsoft.com/.default') + VerifiedTenantId = $null + TokenFingerprint = 'cancelled-verified-get-fingerprint' + CredentialGeneration = $this.CredentialGeneration + } + } + $script:cancelledVerifiedGetProofCalls = 0 + + Mock Confirm-GraphTenantBinding -ModuleName GraphKit { + param($Context, $TokenResult, [System.Threading.CancellationToken] $CancellationToken) + $script:cancelledVerifiedGetProofCalls++ + $CancellationToken.ThrowIfCancellationRequested() + } + + try { + $capture = InModuleScope GraphKit -ArgumentList ` + (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), ` + (New-TokenPipelineDescriptor -IdentityRequirement Verified), $authority, $cts.Token, $clockCapture { + param($Context, $Descriptor, $Authority, $CancellationToken, $ClockCapture) + + $utcNow = { $ClockCapture.UtcNow }.GetNewClosure() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $result = Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri]::new($Authority, 'resource')) -Method GET -Headers @{} -Body $null ` + -DeadlineSeconds 5 -CancellationToken $CancellationToken ` + -Injections @{ + UtcNow = $utcNow + Delay = { param($Seconds) } + Jitter = { 0.0 } + } + [pscustomobject] @{ + Result = $result + InFlight = (Get-GraphThrottleCoordinator).GetInFlight([string] $scope.LeafKey) + } + } + + $capture.Result.Outcome | Should -BeExactly 'Cancelled' + $capture.Result.Certainty | Should -BeExactly 'Indeterminate' + $capture.InFlight | Should -Be 0 + $script:cancelledVerifiedGetProofCalls | Should -Be 1 + } + finally { + $cts.Dispose() + } + } + + It 'uses false then true acquisition flags across one 401 refresh' { + $server = Start-TokenPipelineServer -Responses @( + @{ StatusCode = 401; Body = '{"error":{"code":"InvalidAuthenticationToken"}}' } + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = $server.Authority + $tokenSource = New-RotatingTokenSource + $injections = @{ + Delay = { param([double] $Seconds) } + Jitter = { 0.0 } + } + + $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor), $authority, $injections { + param($Context, $Descriptor, $Authority, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'resource')) ` + -Method GET -Headers @{} -Body $null -CancellationToken ([System.Threading.CancellationToken]::None) ` + -Injections $Injections + } + + $captured = Stop-TokenPipelineServer $server + $result.Outcome | Should -Be 'Succeeded' + @($tokenSource.AcquireFlags) | Should -Be @($false, $true) + @($captured.Authorization) | Should -Be @('Bearer token-1', 'Bearer token-2') + } + + It 'does not elevate an unproven provider tenant claim into provenance' { + $server = Start-TokenPipelineServer -Responses @( + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = $server.Authority + $tokenSource = New-RotatingTokenSource -ClaimedTenantId $script:TenantId.ToString() + + $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor), $authority { + param($Context, $Descriptor, $Authority) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'resource')) ` + -Method GET -Headers @{} -Body $null -CancellationToken ([System.Threading.CancellationToken]::None) + } + + $null = Stop-TokenPipelineServer $server + $result.Outcome | Should -Be 'Succeeded' + $result.Provenance.ActualTenantId | Should -BeNullOrEmpty + $result.Provenance.IdentityState | Should -Be 'NotAcquired' + } + + It 'does not carry an earlier token proof across a 401 refresh' { + $server = Start-TokenPipelineServer -Responses @( + @{ StatusCode = 401; Body = '{"error":{"code":"InvalidAuthenticationToken"}}' } + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = $server.Authority + $tokenSource = New-RotatingTokenSource -ClaimedTenantId $script:TenantId.ToString() + + InModuleScope GraphKit -ArgumentList $script:TenantId { + param($TenantId) + $key = Get-GraphTenantBindingKey -Fingerprint 'fingerprint-1' -Generation 'generation-1' -TenantId $TenantId + $script:GraphTenantBindingCache[$key] = $true + } + + $injections = @{ + Delay = { param([double] $Seconds) } + Jitter = { 0.0 } + } + $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor), $authority, $injections { + param($Context, $Descriptor, $Authority, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'resource')) ` + -Method GET -Headers @{} -Body $null -CancellationToken ([System.Threading.CancellationToken]::None) ` + -Injections $Injections + } + + $captured = Stop-TokenPipelineServer $server + $result.Outcome | Should -Be 'Succeeded' + @($captured.Authorization) | Should -Be @('Bearer token-1', 'Bearer token-2') + $result.Provenance.ActualTenantId | Should -BeNullOrEmpty + $result.Provenance.IdentityState | Should -Be 'NotAcquired' + } + + It 'cancels during acquisition before tenant proof or mutation bytes are sent' { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + $authority = [uri] "http://127.0.0.1:$port" + $cts = [System.Threading.CancellationTokenSource]::new() + $tokenSource = New-RotatingTokenSource + $tokenSource | Add-Member -MemberType NoteProperty -Name CancellationSource -Value $cts + $tokenSource | Add-Member -MemberType NoteProperty -Name LastResult -Value $null + $tokenSource | Add-Member -MemberType ScriptMethod -Name Acquire -Force -Value { + param([bool] $forceRefresh, $cancellationToken) + + $this.AcquireFlags.Add($forceRefresh) + $this.LastResult = [pscustomobject] @{ + AccessToken = 'cancelled-token' + ExpiresOnUtc = [System.DateTimeOffset]::MinValue + ReceivedOnUtc = [System.DateTimeOffset]::UtcNow + TokenType = 'Bearer' + Scopes = @('https://graph.microsoft.com/.default') + VerifiedTenantId = $null + TokenFingerprint = 'cancelled-fingerprint' + CredentialGeneration = $this.CredentialGeneration + } + $this.CancellationSource.Cancel() + return $this.LastResult + } + + try { + $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor -ReplayPolicy NeverReplay -ThrottleClass Write), $authority, $cts.Token { + param($Context, $Descriptor, $Authority, $CancellationToken) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'mutation')) ` + -Method POST -Headers @{} -Body @{ value = 'x' } -CancellationToken $CancellationToken + } + + $listener.Pending() | Should -BeFalse + $tokenSource.AcquireFlags.Count | Should -Be 1 + $tokenSource.LastResult.VerifiedTenantId | Should -BeNullOrEmpty + (InModuleScope GraphKit { $script:GraphTenantBindingCache.Count }) | Should -Be 0 + $result.Outcome | Should -BeExactly 'Cancelled' + $result.Certainty | Should -BeExactly 'Indeterminate' + } + finally { + $listener.Stop() + $cts.Dispose() + } + } + + It 'proves and sends a mutation with the same exact token' { + $tenantBody = '{"value":[{"id":"' + $script:TenantId.ToString() + '"}]}' + $server = Start-TokenPipelineServer -Responses @( + @{ StatusCode = 200; Body = $tenantBody } + @{ StatusCode = 204; Body = $null } + ) + $authority = $server.Authority + $tokenSource = New-RotatingTokenSource + + $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor -ReplayPolicy NeverReplay -ThrottleClass Write), $authority { + param($Context, $Descriptor, $Authority) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'mutation')) ` + -Method POST -Headers @{} -Body @{ value = 'x' } ` + -CancellationToken ([System.Threading.CancellationToken]::None) + } + + $captured = Stop-TokenPipelineServer $server + $result.Outcome | Should -Be 'Succeeded' + $tokenSource.AcquireFlags.Count | Should -Be 1 + $captured.Count | Should -Be 2 + $captured[0].Path | Should -Be '/v1.0/organization' + $captured[1].Path | Should -Be '/mutation' + $captured[0].Authorization | Should -Be $captured[1].Authorization + $captured[1].Authorization | Should -Be 'Bearer token-1' + $result.Provenance.ActualTenantId | Should -Be $script:TenantId + $result.Provenance.IdentityState | Should -Be 'VerifiedForToken' + $result.Provenance.TokenFingerprint | Should -BeExactly 'fingerprint-1' + $result.Provenance.CredentialGeneration | Should -BeExactly 'generation-1' + $result.Provenance.Cloud | Should -BeExactly 'Global' + $result.Provenance.Keys | Should -Not -Contain 'ClientId' + $result.Provenance.Keys | Should -Not -Contain 'ClientScopeFingerprint' + } +} diff --git a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 new file mode 100644 index 0000000..4c3c297 --- /dev/null +++ b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 @@ -0,0 +1,1573 @@ +BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + # ThreadJob readiness includes runspace startup and a full module import. Keep that + # scheduler-sensitive setup bound separate from the tighter operation/deadlock gates. + $script:Task7ThreadJobReadyTimeoutMilliseconds = 15000 + $builtCandidates = @( + Get-ChildItem -LiteralPath (Join-Path $script:RepoRoot 'output/module/GraphKit') ` + -Directory | Sort-Object Name -Descending + ) + if ($builtCandidates.Count -eq 0) { + throw 'GraphKit is not packed. Run ./build.ps1 -Tasks pack before this file.' + } + $script:BuiltManifest = Join-Path $builtCandidates[0].FullName 'GraphKit.psd1' + Import-Module $script:BuiltManifest -Force -ErrorAction Stop + + if ($null -eq ('GraphKit.Tests.Task7ControlledTokenSource' -as [type])) { + $fixtureRoot = Join-Path $TestDrive 'task7-runspace-fixture' + $fixtureOutput = Join-Path $fixtureRoot 'out' + $offlineFeed = Join-Path $fixtureRoot 'offline-feed' + $null = New-Item -ItemType Directory -Path $fixtureRoot, $offlineFeed -Force + $contractsPath = [GraphKit.Auth.IGraphTokenSource].Assembly.Location + $escapedContractsPath = [Security.SecurityElement]::Escape($contractsPath) + Set-Content -LiteralPath (Join-Path $fixtureRoot 'Fixture.cs') ` + -NoNewline -Encoding utf8NoBOM -Value @' +using System; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GraphKit.Auth; + +namespace GraphKit.Tests; + +// TASK7_FIXTURE_SOURCE_BEGIN +public sealed class Task7ControlledTokenSource : IGraphTokenSource +{ + public const string ContractMarker = "GraphKit.Task7.RunspaceFixture/3"; + public const string ContractSourceSha256 = + "5ecbcb30fa3cd49fdea9c179263ae7953d37a3085356a4f6d3cdd439fe5afe61"; + private readonly object _gate = new(); + private readonly string _token; + private readonly string _fingerprint; + private readonly string? _verifiedTenantId; + private readonly string _generation; + private readonly CountdownEvent _entered; + private readonly ManualResetEventSlim _release; + private readonly bool _suffixByForce; + private readonly ConcurrentQueue _forceFlags = new(); + private readonly ConcurrentDictionary _ownedResults = new(); + private readonly ConcurrentDictionary _resultsByForce = new(); + private GraphTokenResult? _current; + private int _acquireCount; + private int _adoptCount; + private int _disposeCount; + private int _disposed; + + public Task7ControlledTokenSource( + string token, + string fingerprint, + string? verifiedTenantId, + string generation, + CountdownEvent entered, + ManualResetEventSlim release, + bool suffixByForce) + { + _token = token; + _fingerprint = fingerprint; + _verifiedTenantId = verifiedTenantId; + _generation = generation; + _entered = entered; + _release = release; + _suffixByForce = suffixByForce; + } + + public int AcquireCount => Volatile.Read(ref _acquireCount); + public int SemanticAdoptionCount => Volatile.Read(ref _adoptCount); + public int DisposeCount => Volatile.Read(ref _disposeCount); + public bool CanRefresh => true; + public string AuthMode => "Certificate"; + public string Audience => "https://graph.microsoft.com/"; + public string? ClientId => "00000000-0000-0000-0000-000000000072"; + public DateTimeOffset ExpiresOn { get; private set; } + public string? VerifiedTenantId { get; private set; } + public string CredentialGeneration => _generation; + public bool[] ForceFlags => _forceFlags.ToArray(); + public GraphTokenResult? CurrentResult + { + get { lock (_gate) { return _current; } } + } + + public GraphTokenResult? ResultForForce(bool forceRefresh) => + _resultsByForce.TryGetValue(forceRefresh, out var result) ? result : null; + + public GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation) + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(Task7ControlledTokenSource)); + cancellation.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _acquireCount); + _forceFlags.Enqueue(forceRefresh); + _entered.Signal(); + _release.Wait(cancellation); + cancellation.ThrowIfCancellationRequested(); + + string suffix = _suffixByForce ? (forceRefresh ? "-forced" : "-ordinary") : string.Empty; + var result = new GraphTokenResult + { + AccessToken = _token + suffix, + ExpiresOnUtc = new DateTimeOffset(2099, 7, 1, 0, 0, 0, TimeSpan.Zero), + ReceivedOnUtc = new DateTimeOffset(2026, 8, 31, 12, 0, 0, TimeSpan.Zero), + TokenType = "Bearer", + Scopes = new[] { "https://graph.microsoft.com/.default" }, + VerifiedTenantId = _verifiedTenantId, + TokenFingerprint = _fingerprint + suffix, + CredentialGeneration = _generation + }; + _ownedResults.TryAdd(result, 0); + _resultsByForce[forceRefresh] = result; + lock (_gate) + { + _current = result; + ExpiresOn = result.ExpiresOnUtc; + VerifiedTenantId = result.VerifiedTenantId; + } + return result; + } + + public void AdoptSharedResult(GraphTokenResult result, bool forceRefresh) + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(Task7ControlledTokenSource)); + if (!string.Equals(result.CredentialGeneration, _generation, StringComparison.Ordinal)) + throw new InvalidOperationException("Task 7 controlled source rejected a foreign generation."); + if (!_ownedResults.ContainsKey(result)) Interlocked.Increment(ref _adoptCount); + _resultsByForce[forceRefresh] = result; + lock (_gate) + { + _current = result; + ExpiresOn = result.ExpiresOnUtc; + VerifiedTenantId = result.VerifiedTenantId; + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + Interlocked.Increment(ref _disposeCount); + } +} + +public sealed class Task7OfflineHandler : HttpMessageHandler +{ + private int _sendCount; + private int _disposeCount; + public int SendCount => Volatile.Read(ref _sendCount); + public int DisposeCount => Volatile.Read(ref _disposeCount); + public ConcurrentQueue AccessTokens { get; } = new(); + public ConcurrentQueue RequestEvidence { get; } = new(); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _sendCount); + string token = request.Headers.Authorization?.Parameter ?? string.Empty; + AccessTokens.Enqueue(token); + RequestEvidence.Enqueue((request.RequestUri?.AbsolutePath ?? string.Empty) + "|" + token); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + } + + protected override void Dispose(bool disposing) + { + if (disposing) Interlocked.Increment(ref _disposeCount); + base.Dispose(disposing); + } +} +// TASK7_FIXTURE_SOURCE_END +'@ + Set-Content -LiteralPath (Join-Path $fixtureRoot 'Fixture.csproj') ` + -NoNewline -Encoding utf8NoBOM -Value @" + + + net8.0 + GraphKit.Task7.RunspaceFixture + enable + enable + true + true + none + + + + $escapedContractsPath + false + + + +"@ + $restoreOutput = & dotnet restore (Join-Path $fixtureRoot 'Fixture.csproj') ` + --source $offlineFeed --nologo --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Task 7 offline fixture restore failed: $($restoreOutput | Out-String)" + } + $buildOutput = & dotnet build (Join-Path $fixtureRoot 'Fixture.csproj') ` + -c Release -o $fixtureOutput --no-restore --nologo --verbosity quiet 2>&1 + $fixtureAssembly = Join-Path $fixtureOutput 'GraphKit.Task7.RunspaceFixture.dll' + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $fixtureAssembly -PathType Leaf)) { + throw "Task 7 controlled fixture build failed: $($buildOutput | Out-String)" + } + $fixtureStream = [IO.MemoryStream]::new( + [IO.File]::ReadAllBytes($fixtureAssembly), $false) + try { + $null = [Runtime.Loader.AssemblyLoadContext]::Default.LoadFromStream($fixtureStream) + } + finally { + $fixtureStream.Dispose() + } + } + $controlledType = 'GraphKit.Tests.Task7ControlledTokenSource' -as [type] + $contractField = if ($null -ne $controlledType) { + $controlledType.GetField('ContractMarker') + } + else { + $null + } + if ($null -eq $contractField -or + [string] $contractField.GetRawConstantValue() -cne + 'GraphKit.Task7.RunspaceFixture/3') { + throw 'The process-global Task 7 runspace fixture has an incompatible identity or contract.' + } + $sourceShaField = $controlledType.GetField('ContractSourceSha256') + $fixtureFileText = [IO.File]::ReadAllText( + (Join-Path $PSScriptRoot 'GraphKitAuthRunspace.Tests.ps1')) + $fixtureBeginMarker = '// TASK7_FIXTURE_SOURCE_BEGIN' + $fixtureEndMarker = '// TASK7_FIXTURE_SOURCE_END' + $fixtureBegin = $fixtureFileText.IndexOf( + $fixtureBeginMarker, [StringComparison]::Ordinal) + $fixtureEnd = $fixtureFileText.IndexOf( + $fixtureEndMarker, [StringComparison]::Ordinal) + if ($fixtureBegin -lt 0 -or $fixtureEnd -lt $fixtureBegin) { + throw 'The Task 7 runspace fixture source-digest boundaries are missing.' + } + $fixtureBody = $fixtureFileText.Substring( + $fixtureBegin, + ($fixtureEnd + $fixtureEndMarker.Length) - $fixtureBegin) + $normalizedFixtureBody = [regex]::Replace( + $fixtureBody, + '(?s)(ContractSourceSha256\s*=\s*\r?\n\s*")[0-9a-f]{64}(";)', + [Text.RegularExpressions.MatchEvaluator] { + param($Match) + $Match.Groups[1].Value + ('0' * 64) + $Match.Groups[2].Value + }) + $normalizedFixtureBody = $normalizedFixtureBody -replace "`r`n?", "`n" + $computedFixtureSha = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($normalizedFixtureBody))) + $computedFixtureSha = $computedFixtureSha.ToLowerInvariant() + if ($null -eq $sourceShaField -or + [string] $sourceShaField.GetRawConstantValue() -cne $computedFixtureSha) { + throw ( + 'The process-global Task 7 runspace fixture source digest is stale. ' + + 'Run this test file in a fresh PowerShell process after updating its derived digest.' + ) + } + + $script:FixedBearerChild = { + param($Manifest, $HolderKey) + $module = $null + $state = $null + $childHost = $null + $holder = $null + $source = $null + $result = $null + $cleanupObserved = $false + $initialHostOnly = $false + $preRemovalHostOnly = $false + $removeSucceeded = $false + $moduleAbsent = $false + $stopRequested = $false + $cleanupComplete = $false + $activeOperations = -1 + $ownedResourceCount = -1 + $failureCount = -1 + $cleanupError = $null + $outcome = $null + try { + $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop + $childCapture = & $module { + $owned = @($script:GraphKitModuleLifecycle.OwnedResources) + [pscustomobject] @{ + State = $script:GraphKitModuleLifecycle + Host = $script:GraphKitAuthHost + HostOnly = + $owned.Count -eq 1 -and + [object]::ReferenceEquals($owned[0], $script:GraphKitAuthHost) + } + } + $state = $childCapture.State + $childHost = $childCapture.Host + $initialHostOnly = [bool] $childCapture.HostOnly + $childCapture = $null + $holder = [AppDomain]::CurrentDomain.GetData($HolderKey) + if ($null -eq $holder) { throw 'Task 7 parent holder was unavailable.' } + $source = $holder.Source + $holder.ObservedSources.Enqueue([object] $source) + $null = $holder.Ready.Signal() + $holder.Go.Wait() + + $result = $source.Acquire($false, [Threading.CancellationToken]::None) + $holder.Results.Enqueue([object] $result) + $forceRefused = $false + try { + $null = $source.Acquire($true, [Threading.CancellationToken]::None) + } + catch [GraphKit.Auth.GraphAuthException] { + $forceRefused = + $_.Exception.GetType().FullName -ceq 'GraphKit.Auth.GraphAuthException' -and + $_.Exception.Code -ceq 'provider_failure' -and + $_.Exception.Category -ceq 'Provider' -and + $_.Exception.Message -ceq ` + 'The isolated GraphKit.Auth provider could not complete the requested operation.' + } + $outcome = [pscustomobject] @{ + Success = $true + Token = [string] $result.AccessToken + ForceRefused = $forceRefused + ErrorText = $null + } + } + catch { + $outcome = [pscustomobject] @{ + Success = $false + Token = $null + ForceRefused = $false + ErrorText = ($_ | Out-String) + } + } + finally { + if ($null -ne $module) { + try { + $preRemovalHostOnly = [bool] (& $module { + param($ExpectedHost) + $owned = @($script:GraphKitModuleLifecycle.OwnedResources) + $owned.Count -eq 1 -and + [object]::ReferenceEquals($owned[0], $ExpectedHost) + } $childHost) + Remove-Module -ModuleInfo $module -Force -ErrorAction Stop + $removeSucceeded = $true + } + catch { + $cleanupError = ($_ | Out-String) + } + } + $cleanupObserved = $null -ne $state -and $state.WaitForCleanup(5000) + $moduleAbsent = $null -eq (Get-Module -Name GraphKit) + if ($null -ne $state) { + $stopRequested = [bool] $state.StopRequested + $cleanupComplete = [bool] $state.CleanupComplete + $activeOperations = [int] $state.ActiveOperations + $ownedResourceCount = [int] $state.OwnedResources.Count + $failureCount = @($state.GetFailures()).Count + } + $result = $null + $source = $null + $holder = $null + $childHost = $null + $state = $null + $module = $null + } + if ($null -eq $outcome) { + $outcome = [pscustomobject] @{ + Success = $false; Token = $null; ForceRefused = $false + ErrorText = 'Task 7 fixed-bearer child produced no outcome.' + } + } + if (-not [string]::IsNullOrEmpty($cleanupError)) { + $outcome.Success = $false + $outcome.ErrorText = $cleanupError + } + $outcome | Add-Member NoteProperty InitialHostOnly $initialHostOnly + $outcome | Add-Member NoteProperty PreRemovalHostOnly $preRemovalHostOnly + $outcome | Add-Member NoteProperty RemoveSucceeded $removeSucceeded + $outcome | Add-Member NoteProperty ModuleAbsent $moduleAbsent + $outcome | Add-Member NoteProperty CleanupObserved $cleanupObserved + $outcome | Add-Member NoteProperty StopRequested $stopRequested + $outcome | Add-Member NoteProperty CleanupComplete $cleanupComplete + $outcome | Add-Member NoteProperty ActiveOperations $activeOperations + $outcome | Add-Member NoteProperty OwnedResourceCount $ownedResourceCount + $outcome | Add-Member NoteProperty FailureCount $failureCount + return $outcome + } + + $script:ControlledSenderChild = { + param($Manifest, $HolderKey, [int] $ContextIndex, [bool] $ForceRefresh) + $module = $null + $state = $null + $childHost = $null + $holder = $null + $context = $null + $source = $null + $current = $null + $cleanupObserved = $false + $initialHostOnly = $false + $preRemovalHostOnly = $false + $removeSucceeded = $false + $moduleAbsent = $false + $stopRequested = $false + $cleanupComplete = $false + $activeOperations = -1 + $ownedResourceCount = -1 + $failureCount = -1 + $cleanupError = $null + $outcome = $null + try { + $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop + $childCapture = & $module { + $owned = @($script:GraphKitModuleLifecycle.OwnedResources) + [pscustomobject] @{ + State = $script:GraphKitModuleLifecycle + Host = $script:GraphKitAuthHost + HostOnly = + $owned.Count -eq 1 -and + [object]::ReferenceEquals($owned[0], $script:GraphKitAuthHost) + } + } + $state = $childCapture.State + $childHost = $childCapture.Host + $initialHostOnly = [bool] $childCapture.HostOnly + $childCapture = $null + $holder = [AppDomain]::CurrentDomain.GetData($HolderKey) + if ($null -eq $holder) { throw 'Task 7 controlled holder was unavailable.' } + $context = $holder.Contexts[$ContextIndex] + $source = $holder.Sources[$ContextIndex] + $holder.ObservedSources.Enqueue([object] $source) + $null = $holder.Ready.Signal() + $holder.Go.Wait() + $transport = & $module { + param($Context, $Source, $Client, [bool] $ForceRefresh, [int] $RequestIndex) + $clientFactory = { + param([int] $ConnectTimeoutSeconds) + $null = $ConnectTimeoutSeconds + [pscustomobject] @{ + Client = $Client + OwnedByGraphKit = $false + } + }.GetNewClosure() + Send-GraphHttpRequest ` + -Uri ([uri] ("https://graph.microsoft.com/v1.0/task7-offline/{0}" -f $RequestIndex)) ` + -Method GET ` + -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $Source ` + -TokenAcquisitionKey ([string] $Context.AcquisitionCacheKey) ` + -ForceRefresh:$ForceRefresh ` + -LifecycleState $script:GraphKitModuleLifecycle ` + -HttpClientFactory $clientFactory ` + -TimeoutConnectionSeconds 5 ` + -TimeoutHeadersSeconds 5 ` + -TimeoutBodySeconds 5 + } $context $source $holder.Client $ForceRefresh $ContextIndex + $current = $source.ResultForForce($ForceRefresh) + if ($null -eq $current) { + throw 'Task 7 controlled source had no exact force-partition result after the sender returned.' + } + $holder.Results.Enqueue([object] $current) + $outcome = [pscustomobject] @{ + Success = $true + StatusCode = [int] $transport.StatusCode + Token = [string] $current.AccessToken + Fingerprint = [string] $current.TokenFingerprint + Proof = [string] $current.VerifiedTenantId + Generation = [string] $current.CredentialGeneration + ContextIndex = $ContextIndex + ForceRefresh = $ForceRefresh + ErrorText = $null + } + } + catch { + $outcome = [pscustomobject] @{ + Success = $false + StatusCode = 0 + Token = $null + Fingerprint = $null + Proof = $null + Generation = $null + ContextIndex = $ContextIndex + ForceRefresh = $ForceRefresh + ErrorText = ($_ | Out-String) + } + } + finally { + if ($null -ne $module) { + try { + $preRemovalHostOnly = [bool] (& $module { + param($ExpectedHost) + $owned = @($script:GraphKitModuleLifecycle.OwnedResources) + $owned.Count -eq 1 -and + [object]::ReferenceEquals($owned[0], $ExpectedHost) + } $childHost) + Remove-Module -ModuleInfo $module -Force -ErrorAction Stop + $removeSucceeded = $true + } + catch { + $cleanupError = ($_ | Out-String) + } + } + $cleanupObserved = $null -ne $state -and $state.WaitForCleanup(5000) + $moduleAbsent = $null -eq (Get-Module -Name GraphKit) + if ($null -ne $state) { + $stopRequested = [bool] $state.StopRequested + $cleanupComplete = [bool] $state.CleanupComplete + $activeOperations = [int] $state.ActiveOperations + $ownedResourceCount = [int] $state.OwnedResources.Count + $failureCount = @($state.GetFailures()).Count + } + $current = $null + $source = $null + $context = $null + $holder = $null + $childHost = $null + $state = $null + $module = $null + } + if ($null -eq $outcome) { + $outcome = [pscustomobject] @{ + Success = $false; StatusCode = 0; Token = $null + Fingerprint = $null; Proof = $null; Generation = $null + ContextIndex = $ContextIndex; ForceRefresh = $ForceRefresh + ErrorText = 'Task 7 controlled child produced no outcome.' + } + } + if (-not [string]::IsNullOrEmpty($cleanupError)) { + $outcome.Success = $false + $outcome.ErrorText = $cleanupError + } + $outcome | Add-Member NoteProperty InitialHostOnly $initialHostOnly + $outcome | Add-Member NoteProperty PreRemovalHostOnly $preRemovalHostOnly + $outcome | Add-Member NoteProperty RemoveSucceeded $removeSucceeded + $outcome | Add-Member NoteProperty ModuleAbsent $moduleAbsent + $outcome | Add-Member NoteProperty CleanupObserved $cleanupObserved + $outcome | Add-Member NoteProperty StopRequested $stopRequested + $outcome | Add-Member NoteProperty CleanupComplete $cleanupComplete + $outcome | Add-Member NoteProperty ActiveOperations $activeOperations + $outcome | Add-Member NoteProperty OwnedResourceCount $ownedResourceCount + $outcome | Add-Member NoteProperty FailureCount $failureCount + return $outcome + } + + $script:LegacyContainmentChild = { + param($Manifest, $HolderKey) + $module = $null + $state = $null + $childHost = $null + $holder = $null + $source = $null + $cleanupObserved = $false + $initialHostOnly = $false + $preRemovalHostOnly = $false + $removeSucceeded = $false + $moduleAbsent = $false + $stopRequested = $false + $cleanupComplete = $false + $activeOperations = -1 + $ownedResourceCount = -1 + $failureCount = -1 + $cleanupError = $null + $outcome = $null + try { + $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop + $childCapture = & $module { + $owned = @($script:GraphKitModuleLifecycle.OwnedResources) + [pscustomobject] @{ + State = $script:GraphKitModuleLifecycle + Host = $script:GraphKitAuthHost + HostOnly = + $owned.Count -eq 1 -and + [object]::ReferenceEquals($owned[0], $script:GraphKitAuthHost) + } + } + $state = $childCapture.State + $childHost = $childCapture.Host + $initialHostOnly = [bool] $childCapture.HostOnly + $childCapture = $null + $holder = [AppDomain]::CurrentDomain.GetData($HolderKey) + if ($null -eq $holder) { throw 'Task 7 legacy holder was unavailable.' } + $source = $holder.Source + $holder.ObservedSources.Enqueue([object] $source) + $null = $holder.Ready.Signal() + $holder.Go.Wait() + $caught = $null + try { + $null = & $module { + param($Context, $Source, $Client) + $clientFactory = { + param([int] $ConnectTimeoutSeconds) + $null = $ConnectTimeoutSeconds + [pscustomobject] @{ Client = $Client; OwnedByGraphKit = $false } + }.GetNewClosure() + Send-GraphHttpRequest ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/task7-offline') ` + -Method GET ` + -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $Source ` + -TokenAcquisitionKey ([string] $Context.AcquisitionCacheKey) ` + -LifecycleState $script:GraphKitModuleLifecycle ` + -HttpClientFactory $clientFactory + } $holder.Context $source $holder.Client + } + catch { + $caught = $_.Exception + } + if ($null -eq $caught) { + throw 'Task 7 legacy cross-runspace sender unexpectedly succeeded.' + } + $root = $caught + while ($null -ne $root.InnerException) { $root = $root.InnerException } + $outcome = [pscustomobject] @{ + Success = $true + Rejected = + $root.GetType().FullName -ceq 'System.InvalidOperationException' -and + $root.Message -ceq ( + 'This legacy PowerShell token source is bound to the runspace where its context was created. ' + + 'Cross-runspace context use is disabled because PowerShell-class token acquisition can hang; ' + + 'the compiled GraphKit.Auth token source is required for that contract.' + ) + FailureType = $root.GetType().FullName + FailureMessage = $root.Message + ErrorText = $null + } + } + catch { + $outcome = [pscustomobject] @{ + Success = $false + Rejected = $false + FailureType = $null + FailureMessage = $null + ErrorText = ($_ | Out-String) + } + } + finally { + if ($null -ne $module) { + try { + $preRemovalHostOnly = [bool] (& $module { + param($ExpectedHost) + $owned = @($script:GraphKitModuleLifecycle.OwnedResources) + $owned.Count -eq 1 -and + [object]::ReferenceEquals($owned[0], $ExpectedHost) + } $childHost) + Remove-Module -ModuleInfo $module -Force -ErrorAction Stop + $removeSucceeded = $true + } + catch { + $cleanupError = ($_ | Out-String) + } + } + $cleanupObserved = $null -ne $state -and $state.WaitForCleanup(5000) + $moduleAbsent = $null -eq (Get-Module -Name GraphKit) + if ($null -ne $state) { + $stopRequested = [bool] $state.StopRequested + $cleanupComplete = [bool] $state.CleanupComplete + $activeOperations = [int] $state.ActiveOperations + $ownedResourceCount = [int] $state.OwnedResources.Count + $failureCount = @($state.GetFailures()).Count + } + $source = $null + $holder = $null + $childHost = $null + $state = $null + $module = $null + } + if ($null -eq $outcome) { + $outcome = [pscustomobject] @{ + Success = $false; Rejected = $false; FailureType = $null + FailureMessage = $null + ErrorText = 'Task 7 legacy child produced no outcome.' + } + } + if (-not [string]::IsNullOrEmpty($cleanupError)) { + $outcome.Success = $false + $outcome.ErrorText = $cleanupError + } + $outcome | Add-Member NoteProperty InitialHostOnly $initialHostOnly + $outcome | Add-Member NoteProperty PreRemovalHostOnly $preRemovalHostOnly + $outcome | Add-Member NoteProperty RemoveSucceeded $removeSucceeded + $outcome | Add-Member NoteProperty ModuleAbsent $moduleAbsent + $outcome | Add-Member NoteProperty CleanupObserved $cleanupObserved + $outcome | Add-Member NoteProperty StopRequested $stopRequested + $outcome | Add-Member NoteProperty CleanupComplete $cleanupComplete + $outcome | Add-Member NoteProperty ActiveOperations $activeOperations + $outcome | Add-Member NoteProperty OwnedResourceCount $ownedResourceCount + $outcome | Add-Member NoteProperty FailureCount $failureCount + return $outcome + } + + function New-Task7ControlledContext { + param( + [Parameter(Mandatory)] $Source, + [Parameter(Mandatory)] [string] $AcquisitionKey, + [Parameter(Mandatory)] [guid] $TenantId + ) + return [pscustomobject] @{ + PSTypeName = 'GraphKit.Context' + TenantId = $TenantId + GraphBaseUri = [uri] 'https://graph.microsoft.com' + TokenSource = $Source + AcquisitionCacheKey = $AcquisitionKey + } + } + + function Get-Task7OuterFlightSnapshot { + param( + [Parameter(Mandatory)] [string] $AcquisitionKey, + [Parameter(Mandatory)] [bool] $ForceRefresh + ) + return InModuleScope GraphKit -Parameters @{ + AcquisitionKey = $AcquisitionKey + ForceRefresh = $ForceRefresh + } { + param($AcquisitionKey, $ForceRefresh) + $key = Get-GraphTokenFlightKey -AcquisitionKey $AcquisitionKey ` + -ForceRefresh:$ForceRefresh + $flight = [GraphTokenFlight] $null + $exists = [GraphTokenFlightRegistry]::Flights.TryGetValue($key, [ref] $flight) + [pscustomobject] @{ + Key = $key + Exists = $exists + WaiterCount = if ($exists) { + [int] (Get-GraphTokenFlightWaiterCount -Flight $flight) + } + else { + -1 + } + RegistryCount = [GraphTokenFlightRegistry]::Flights.Count + } + } + } + + function Get-Task7OuterFlightRegistryCount { + return InModuleScope GraphKit { [GraphTokenFlightRegistry]::Flights.Count } + } + + function Complete-Task7ChildJobs { + param( + [Parameter(Mandatory)] [object[]] $Jobs, + [Parameter(Mandatory)] [int] $ExpectedCount + ) + $completed = @($Jobs | Wait-Job -Timeout 10) + $null = $completed.Count | Should -Be $ExpectedCount + $outcomes = @($Jobs | Receive-Job -ErrorAction Stop) + $null = $outcomes.Count | Should -Be $ExpectedCount + return $outcomes + } + + function Assert-Task7ChildCleanup { + param([Parameter(Mandatory)] [object[]] $Outcomes) + + foreach ($outcome in $Outcomes) { + $outcome.InitialHostOnly | Should -BeTrue + $outcome.PreRemovalHostOnly | Should -BeTrue + $outcome.RemoveSucceeded | Should -BeTrue + $outcome.ModuleAbsent | Should -BeTrue + $outcome.CleanupObserved | Should -BeTrue + $outcome.StopRequested | Should -BeTrue + $outcome.CleanupComplete | Should -BeTrue + $outcome.ActiveOperations | Should -Be 0 + $outcome.OwnedResourceCount | Should -Be 0 + $outcome.FailureCount | Should -Be 0 + } + } + + function Remove-Task7ChildJobs { + param([object[]] $Jobs) + if ($null -eq $Jobs -or $Jobs.Count -eq 0) { + return + } + $null = @($Jobs | Wait-Job -Timeout 10) + foreach ($job in $Jobs) { + Remove-Job $job -Force -ErrorAction SilentlyContinue + } + } +} + +AfterAll { + Remove-Module GraphKit -Force -ErrorAction SilentlyContinue + $script:FixedBearerChild = $null + $script:ControlledSenderChild = $null + $script:LegacyContainmentChild = $null + $script:BuiltManifest = $null +} + +Describe 'GraphKit.Auth exact parent-source thread-runspace use' -Tag Concurrency { + It 'uses one public compiled fixed-bearer source by exact reference in two children' { + [GraphKit.Tests.Task7ControlledTokenSource].Assembly.Location | + Should -BeNullOrEmpty + $storePath = Join-Path $TestDrive 'task7-fixed-bearer-profiles.json' + $store = [ordered] @{ + SchemaVersion = 1 + Profiles = @( + [ordered] @{ + ProfileId = 'task7-fixed-bearer' + Name = 'Task 7 synthetic fixed bearer' + Kind = 'lab' + TenantId = '00000000-0000-0000-0000-000000000071' + ClientId = $null + AuthMethod = 'BearerToken' + Environment = 'Global' + Credential = [ordered] @{ + Token = 'task7-synthetic-fixed-bearer-token' + Version = 'task7-inline-v1' + } + } + ) + } + [IO.File]::WriteAllText( + $storePath, + (ConvertTo-Json $store -Depth 20), + [Text.UTF8Encoding]::new($false) + ) + $context = Get-GraphContext -ProfileId task7-fixed-bearer -StorePath $storePath + $source = $context.TokenSource + $holderKey = 'GraphKit.Task7.FixedBearer.' + [guid]::NewGuid().ToString('N') + $ready = [Threading.CountdownEvent]::new(2) + $go = [Threading.ManualResetEventSlim]::new($false) + $observed = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $results = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $holder = [pscustomobject] @{ + Context = $context + Source = $source + Ready = $ready + Go = $go + ObservedSources = $observed + Results = $results + } + $jobs = @() + [AppDomain]::CurrentDomain.SetData($holderKey, $holder) + try { + $jobs = @( + 1..2 | ForEach-Object { + Start-ThreadJob -ScriptBlock $script:FixedBearerChild ` + -ArgumentList $script:BuiltManifest, $holderKey + } + ) + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue + $go.Set() + $outcomes = Complete-Task7ChildJobs -Jobs $jobs -ExpectedCount 2 + @($outcomes | Where-Object { -not $_.Success }).Count | Should -Be 0 + Assert-Task7ChildCleanup -Outcomes $outcomes + @($outcomes | ForEach-Object Token) | Should -Be @( + 'task7-synthetic-fixed-bearer-token', + 'task7-synthetic-fixed-bearer-token' + ) + @($outcomes | Where-Object { -not $_.ForceRefused }).Count | Should -Be 0 + + $observedSources = @($observed.ToArray()) + $observedSources.Count | Should -Be 2 + foreach ($observedSource in $observedSources) { + [object]::ReferenceEquals($source, $observedSource) | Should -BeTrue + } + $actualResults = @($results.ToArray()) + $actualResults.Count | Should -Be 2 + [object]::ReferenceEquals($actualResults[0], $actualResults[1]) | Should -BeTrue + } + finally { + $go.Set() + $null = @($jobs | Wait-Job -Timeout 10) + foreach ($job in $jobs) { + Remove-Job $job -Force -ErrorAction SilentlyContinue + } + [AppDomain]::CurrentDomain.SetData($holderKey, $null) + $holder = $null + $context = $null + $source = $null + $observed = $null + $results = $null + if (Test-Path -LiteralPath $storePath -PathType Leaf) { + Remove-Item -LiteralPath $storePath -Force + } + $ready.Dispose() + $go.Dispose() + } + } + + It 'keeps distinct controlled tenant sources isolated when providers release together' { + $entered = [Threading.CountdownEvent]::new(2) + $release = [Threading.ManualResetEventSlim]::new($false) + $ready = [Threading.CountdownEvent]::new(2) + $go = [Threading.ManualResetEventSlim]::new($false) + $sourceA = [GraphKit.Tests.Task7ControlledTokenSource]::new( + 'task7-tenant-a-token', 'task7-tenant-a-fingerprint', 'task7-tenant-a-proof', + 'task7-generation-a', $entered, $release, $false) + $sourceB = [GraphKit.Tests.Task7ControlledTokenSource]::new( + 'task7-tenant-b-token', 'task7-tenant-b-fingerprint', 'task7-tenant-b-proof', + 'task7-generation-b', $entered, $release, $false) + $keySuffix = [guid]::NewGuid().ToString('N') + $contexts = [object[]] @( + (New-Task7ControlledContext -Source $sourceA ` + -AcquisitionKey ('task7-key-a-' + $keySuffix) ` + -TenantId ([guid] '00000000-0000-0000-0000-000000000073')), + (New-Task7ControlledContext -Source $sourceB ` + -AcquisitionKey ('task7-key-b-' + $keySuffix) ` + -TenantId ([guid] '00000000-0000-0000-0000-000000000074')) + ) + $handler = [GraphKit.Tests.Task7OfflineHandler]::new() + $client = [Net.Http.HttpClient]::new($handler, $false) + $observed = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $results = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $holderKey = 'GraphKit.Task7.Distinct.' + [guid]::NewGuid().ToString('N') + $holder = [pscustomobject] @{ + Contexts = $contexts + Sources = [object[]] @($sourceA, $sourceB) + Client = $client + Ready = $ready + Go = $go + ObservedSources = $observed + Results = $results + } + $jobs = @() + [AppDomain]::CurrentDomain.SetData($holderKey, $holder) + try { + $jobs = @( + Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` + -ArgumentList $script:BuiltManifest, $holderKey, 0, $false + Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` + -ArgumentList $script:BuiltManifest, $holderKey, 1, $false + ) + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue + $go.Set() + $entered.Wait(5000) | Should -BeTrue + $release.Set() + $outcomes = Complete-Task7ChildJobs -Jobs $jobs -ExpectedCount 2 + + @($outcomes | Where-Object { -not $_.Success }).Count | Should -Be 0 + Assert-Task7ChildCleanup -Outcomes $outcomes + $outcomeA = @($outcomes | Where-Object ContextIndex -EQ 0) + $outcomeB = @($outcomes | Where-Object ContextIndex -EQ 1) + $outcomeA.Count | Should -Be 1 + $outcomeB.Count | Should -Be 1 + @( + $outcomeA[0].Token, + $outcomeA[0].Fingerprint, + $outcomeA[0].Proof, + $outcomeA[0].Generation + ) | Should -Be @( + 'task7-tenant-a-token', + 'task7-tenant-a-fingerprint', + 'task7-tenant-a-proof', + 'task7-generation-a' + ) + @( + $outcomeB[0].Token, + $outcomeB[0].Fingerprint, + $outcomeB[0].Proof, + $outcomeB[0].Generation + ) | Should -Be @( + 'task7-tenant-b-token', + 'task7-tenant-b-fingerprint', + 'task7-tenant-b-proof', + 'task7-generation-b' + ) + $sourceA.AcquireCount | Should -Be 1 + $sourceB.AcquireCount | Should -Be 1 + $sourceA.SemanticAdoptionCount | Should -Be 0 + $sourceB.SemanticAdoptionCount | Should -Be 0 + [object]::ReferenceEquals($sourceA.CurrentResult, $sourceB.CurrentResult) | + Should -BeFalse + @($handler.RequestEvidence.ToArray() | Sort-Object) | Should -Be @( + '/v1.0/task7-offline/0|task7-tenant-a-token', + '/v1.0/task7-offline/1|task7-tenant-b-token' + ) + $handler.SendCount | Should -Be 2 + Get-Task7OuterFlightRegistryCount | Should -Be 0 + + $observedSources = @($observed.ToArray()) + $observedSources.Count | Should -Be 2 + @($observedSources | Where-Object { + [object]::ReferenceEquals($_, $sourceA) + }).Count | Should -Be 1 + @($observedSources | Where-Object { + [object]::ReferenceEquals($_, $sourceB) + }).Count | Should -Be 1 + $actualResults = @($results.ToArray()) + $actualResults.Count | Should -Be 2 + @($actualResults | Where-Object { + [object]::ReferenceEquals($_, $sourceA.ResultForForce($false)) + }).Count | Should -Be 1 + @($actualResults | Where-Object { + [object]::ReferenceEquals($_, $sourceB.ResultForForce($false)) + }).Count | Should -Be 1 + } + finally { + $release.Set() + $go.Set() + Remove-Task7ChildJobs -Jobs $jobs + [AppDomain]::CurrentDomain.SetData($holderKey, $null) + $holder = $null + $contexts = $null + $observed = $null + $results = $null + $sourceA.Dispose() + $sourceB.Dispose() + $client.Dispose() + $handler.Dispose() + $entered.Dispose() + $release.Dispose() + $ready.Dispose() + $go.Dispose() + } + } + + It 'collapses two controlled sources on one outer key with one exact follower adoption' { + $entered = [Threading.CountdownEvent]::new(1) + $release = [Threading.ManualResetEventSlim]::new($false) + $ready = [Threading.CountdownEvent]::new(2) + $go = [Threading.ManualResetEventSlim]::new($false) + $sourceA = [GraphKit.Tests.Task7ControlledTokenSource]::new( + 'task7-shared-token', 'task7-shared-fingerprint', 'task7-shared-proof', + 'task7-shared-generation', $entered, $release, $false) + $sourceB = [GraphKit.Tests.Task7ControlledTokenSource]::new( + 'task7-shared-token', 'task7-shared-fingerprint', 'task7-shared-proof', + 'task7-shared-generation', $entered, $release, $false) + $sharedKey = 'task7-shared-key-' + [guid]::NewGuid().ToString('N') + $contexts = [object[]] @( + (New-Task7ControlledContext -Source $sourceA -AcquisitionKey $sharedKey ` + -TenantId ([guid] '00000000-0000-0000-0000-000000000075')), + (New-Task7ControlledContext -Source $sourceB -AcquisitionKey $sharedKey ` + -TenantId ([guid] '00000000-0000-0000-0000-000000000075')) + ) + $handler = [GraphKit.Tests.Task7OfflineHandler]::new() + $client = [Net.Http.HttpClient]::new($handler, $false) + $observed = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $results = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $holderKey = 'GraphKit.Task7.Shared.' + [guid]::NewGuid().ToString('N') + $holder = [pscustomobject] @{ + Contexts = $contexts + Sources = [object[]] @($sourceA, $sourceB) + Client = $client + Ready = $ready + Go = $go + ObservedSources = $observed + Results = $results + } + $jobs = @() + [AppDomain]::CurrentDomain.SetData($holderKey, $holder) + try { + $jobs = @( + Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` + -ArgumentList $script:BuiltManifest, $holderKey, 0, $false + Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` + -ArgumentList $script:BuiltManifest, $holderKey, 1, $false + ) + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue + $go.Set() + $entered.Wait(5000) | Should -BeTrue + $followerObserved = [Threading.SpinWait]::SpinUntil( + [Func[bool]] { + (Get-Task7OuterFlightSnapshot ` + -AcquisitionKey $sharedKey -ForceRefresh $false).WaiterCount -eq 1 + }, + 5000 + ) + $release.Set() + $outcomes = Complete-Task7ChildJobs -Jobs $jobs -ExpectedCount 2 + + $followerObserved | Should -BeTrue -Because ` + 'one caller must be inside the exact outer follower wait before release' + @($outcomes | Where-Object { -not $_.Success }).Count | Should -Be 0 + Assert-Task7ChildCleanup -Outcomes $outcomes + $sources = @($sourceA, $sourceB) + $leaders = @($sources | Where-Object AcquireCount -EQ 1) + $followers = @($sources | Where-Object AcquireCount -EQ 0) + $leaders.Count | Should -Be 1 + $followers.Count | Should -Be 1 + $leaders[0].SemanticAdoptionCount | Should -Be 0 + $followers[0].SemanticAdoptionCount | Should -Be 1 + [object]::ReferenceEquals($sourceA.CurrentResult, $sourceB.CurrentResult) | + Should -BeTrue + $actualResults = @($results.ToArray()) + $actualResults.Count | Should -Be 2 + [object]::ReferenceEquals($actualResults[0], $actualResults[1]) | Should -BeTrue + @($outcomes | ForEach-Object Token) | Should -Be @( + 'task7-shared-token', 'task7-shared-token' + ) + $observedSources = @($observed.ToArray()) + $observedSources.Count | Should -Be 2 + @($observedSources | Where-Object { + [object]::ReferenceEquals($_, $sourceA) + }).Count | Should -Be 1 + @($observedSources | Where-Object { + [object]::ReferenceEquals($_, $sourceB) + }).Count | Should -Be 1 + $handler.SendCount | Should -Be 2 + Get-Task7OuterFlightRegistryCount | Should -Be 0 + } + finally { + $release.Set() + $go.Set() + Remove-Task7ChildJobs -Jobs $jobs + [AppDomain]::CurrentDomain.SetData($holderKey, $null) + $holder = $null + $contexts = $null + $observed = $null + $results = $null + $sourceA.Dispose() + $sourceB.Dispose() + $client.Dispose() + $handler.Dispose() + $entered.Dispose() + $release.Dispose() + $ready.Dispose() + $go.Dispose() + } + } + + It 'keeps ordinary and forced outer flights simultaneously partitioned' { + $entered = [Threading.CountdownEvent]::new(2) + $release = [Threading.ManualResetEventSlim]::new($false) + $ready = [Threading.CountdownEvent]::new(2) + $go = [Threading.ManualResetEventSlim]::new($false) + $unrelatedEntered = [Threading.CountdownEvent]::new(1) + $unrelatedRelease = [Threading.ManualResetEventSlim]::new($false) + $source = [GraphKit.Tests.Task7ControlledTokenSource]::new( + 'task7-partition-token', 'task7-partition-fingerprint', 'task7-partition-proof', + 'task7-partition-generation', $entered, $release, $true) + $unrelated = [GraphKit.Tests.Task7ControlledTokenSource]::new( + 'task7-unrelated-token', 'task7-unrelated-fingerprint', 'task7-unrelated-proof', + 'task7-unrelated-generation', $unrelatedEntered, $unrelatedRelease, $false) + $partitionKey = 'task7-partition-key-' + [guid]::NewGuid().ToString('N') + $context = New-Task7ControlledContext -Source $source -AcquisitionKey $partitionKey ` + -TenantId ([guid] '00000000-0000-0000-0000-000000000076') + $handler = [GraphKit.Tests.Task7OfflineHandler]::new() + $client = [Net.Http.HttpClient]::new($handler, $false) + $observed = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $results = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $holderKey = 'GraphKit.Task7.Partition.' + [guid]::NewGuid().ToString('N') + $holder = [pscustomobject] @{ + Contexts = [object[]] @($context, $context) + Sources = [object[]] @($source, $source) + Client = $client + Ready = $ready + Go = $go + ObservedSources = $observed + Results = $results + } + $jobs = @() + [AppDomain]::CurrentDomain.SetData($holderKey, $holder) + try { + $jobs = @( + Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` + -ArgumentList $script:BuiltManifest, $holderKey, 0, $false + Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` + -ArgumentList $script:BuiltManifest, $holderKey, 1, $true + ) + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue + $go.Set() + $entered.Wait(5000) | Should -BeTrue + $ordinarySnapshot = Get-Task7OuterFlightSnapshot ` + -AcquisitionKey $partitionKey -ForceRefresh $false + $forcedSnapshot = Get-Task7OuterFlightSnapshot ` + -AcquisitionKey $partitionKey -ForceRefresh $true + $release.Set() + $outcomes = Complete-Task7ChildJobs -Jobs $jobs -ExpectedCount 2 + + $ordinarySnapshot.Exists | Should -BeTrue + $forcedSnapshot.Exists | Should -BeTrue + $ordinarySnapshot.Key | Should -Not -BeExactly $forcedSnapshot.Key + $ordinarySnapshot.RegistryCount | Should -Be 2 + $forcedSnapshot.RegistryCount | Should -Be 2 + $source.AcquireCount | Should -Be 2 + @($source.ForceFlags | Sort-Object) | Should -Be @($false, $true) + $ordinaryOutcome = @($outcomes | Where-Object { -not $_.ForceRefresh }) + $forcedOutcome = @($outcomes | Where-Object ForceRefresh) + $ordinaryOutcome.Count | Should -Be 1 + $forcedOutcome.Count | Should -Be 1 + @( + $ordinaryOutcome[0].Token, + $ordinaryOutcome[0].Fingerprint, + $ordinaryOutcome[0].Proof, + $ordinaryOutcome[0].Generation + ) | Should -Be @( + 'task7-partition-token-ordinary', + 'task7-partition-fingerprint-ordinary', + 'task7-partition-proof', + 'task7-partition-generation' + ) + @( + $forcedOutcome[0].Token, + $forcedOutcome[0].Fingerprint, + $forcedOutcome[0].Proof, + $forcedOutcome[0].Generation + ) | Should -Be @( + 'task7-partition-token-forced', + 'task7-partition-fingerprint-forced', + 'task7-partition-proof', + 'task7-partition-generation' + ) + $actualResults = @($results.ToArray()) + $actualResults.Count | Should -Be 2 + [object]::ReferenceEquals($actualResults[0], $actualResults[1]) | Should -BeFalse + @($actualResults | Where-Object { + [object]::ReferenceEquals($_, $source.ResultForForce($false)) + }).Count | Should -Be 1 + @($actualResults | Where-Object { + [object]::ReferenceEquals($_, $source.ResultForForce($true)) + }).Count | Should -Be 1 + @($handler.RequestEvidence.ToArray() | Sort-Object) | Should -Be @( + '/v1.0/task7-offline/0|task7-partition-token-ordinary', + '/v1.0/task7-offline/1|task7-partition-token-forced' + ) + $observedSources = @($observed.ToArray()) + $observedSources.Count | Should -Be 2 + foreach ($observedSource in $observedSources) { + [object]::ReferenceEquals($observedSource, $source) | Should -BeTrue + } + $handler.SendCount | Should -Be 2 + $unrelated.AcquireCount | Should -Be 0 + $unrelated.SemanticAdoptionCount | Should -Be 0 + $unrelated.CurrentResult | Should -BeNullOrEmpty + Get-Task7OuterFlightRegistryCount | Should -Be 0 + Assert-Task7ChildCleanup -Outcomes $outcomes + } + finally { + $release.Set() + $unrelatedRelease.Set() + $go.Set() + Remove-Task7ChildJobs -Jobs $jobs + [AppDomain]::CurrentDomain.SetData($holderKey, $null) + $holder = $null + $context = $null + $observed = $null + $results = $null + $source.Dispose() + $unrelated.Dispose() + $client.Dispose() + $handler.Dispose() + $entered.Dispose() + $release.Dispose() + $ready.Dispose() + $go.Dispose() + $unrelatedEntered.Dispose() + $unrelatedRelease.Dispose() + } + } + + It 'contains a legacy source before it can enter or wait on an outer flight' { + $storePath = Join-Path $TestDrive 'task7-legacy-profiles.json' + $store = [ordered] @{ + SchemaVersion = 1 + Profiles = @( + [ordered] @{ + ProfileId = 'task7-legacy-bearer' + Name = 'Task 7 legacy synthetic bearer' + Kind = 'lab' + TenantId = '00000000-0000-0000-0000-000000000077' + ClientId = $null + AuthMethod = 'BearerToken' + Environment = 'Global' + Credential = [ordered] @{ + Token = 'task7-legacy-synthetic-token' + Version = 'task7-legacy-v1' + } + } + ) + } + [IO.File]::WriteAllText( + $storePath, + (ConvertTo-Json $store -Depth 20), + [Text.UTF8Encoding]::new($false) + ) + $factoryCalls = [Collections.Concurrent.ConcurrentQueue[bool]]::new() + $legacyFactory = { + $factoryCalls.Enqueue($true) + throw 'Task 7 bearer factory must remain unused.' + }.GetNewClosure() + $context = Get-GraphContext -ProfileId task7-legacy-bearer ` + -StorePath $storePath -MsalFactory $legacyFactory + $source = $context.TokenSource + $source.GetType().BaseType.Name | Should -BeExactly 'GraphTokenSourceBase' + $seed = $null + $handler = [GraphKit.Tests.Task7OfflineHandler]::new() + $client = [Net.Http.HttpClient]::new($handler, $false) + $ready = [Threading.CountdownEvent]::new(1) + $go = [Threading.ManualResetEventSlim]::new($false) + $observed = [Collections.Concurrent.ConcurrentQueue[object]]::new() + $holderKey = 'GraphKit.Task7.Legacy.' + [guid]::NewGuid().ToString('N') + $holder = [pscustomobject] @{ + Context = $context + Source = $source + Client = $client + Ready = $ready + Go = $go + ObservedSources = $observed + } + $jobs = @() + [AppDomain]::CurrentDomain.SetData($holderKey, $holder) + try { + $seed = InModuleScope GraphKit -Parameters @{ + AcquisitionKey = [string] $context.AcquisitionCacheKey + } { + param($AcquisitionKey) + $key = Get-GraphTokenFlightKey ` + -AcquisitionKey $AcquisitionKey -ForceRefresh:$false + $flight = [GraphTokenFlight]::new() + if (-not [GraphTokenFlightRegistry]::Flights.TryAdd($key, $flight)) { + throw 'Task 7 could not seed the exact incomplete compatibility flight.' + } + [pscustomobject] @{ Key = $key; Flight = [object] $flight } + } + $jobs = @( + Start-ThreadJob -ScriptBlock $script:LegacyContainmentChild ` + -ArgumentList $script:BuiltManifest, $holderKey + ) + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue + $go.Set() + $outcomes = Complete-Task7ChildJobs -Jobs $jobs -ExpectedCount 1 + $outcomes[0].Success | Should -BeTrue + $outcomes[0].Rejected | Should -BeTrue + Assert-Task7ChildCleanup -Outcomes $outcomes + $handler.SendCount | Should -Be 0 + $factoryCalls.Count | Should -Be 0 + $observedSources = @($observed.ToArray()) + $observedSources.Count | Should -Be 1 + [object]::ReferenceEquals($source, $observedSources[0]) | Should -BeTrue + $seedState = InModuleScope GraphKit -Parameters @{ + Key = $seed.Key + ExpectedFlight = $seed.Flight + } { + param($Key, $ExpectedFlight) + $flight = [GraphTokenFlight] $null + $exists = [GraphTokenFlightRegistry]::Flights.TryGetValue($Key, [ref] $flight) + $waiterProperty = if ($exists) { + $flight.PSObject.Properties['WaiterCount'] + } + else { + $null + } + [pscustomobject] @{ + Exists = $exists + SameFlight = $exists -and [object]::ReferenceEquals($ExpectedFlight, $flight) + IsCompleted = $exists -and $flight.Completion.Task.IsCompleted + HasWaiterCount = $null -ne $waiterProperty + WaiterCount = if ($null -ne $waiterProperty) { + [int] (Get-GraphTokenFlightWaiterCount -Flight $flight) + } + else { + -1 + } + } + } + $seedState.Exists | Should -BeTrue + $seedState.SameFlight | Should -BeTrue + $seedState.IsCompleted | Should -BeFalse + $seedState.HasWaiterCount | Should -BeTrue + $seedState.WaiterCount | Should -Be 0 + } + finally { + $go.Set() + Remove-Task7ChildJobs -Jobs $jobs + [AppDomain]::CurrentDomain.SetData($holderKey, $null) + if ($null -ne $seed) { + InModuleScope GraphKit -Parameters @{ + Key = $seed.Key + Flight = $seed.Flight + } { + param($Key, $Flight) + if (Remove-GraphTokenFlightIfCurrent -Key $Key -Flight $Flight) { + $null = $Flight.Completion.TrySetResult($null) + } + } + } + $holder = $null + $context = $null + $source = $null + $factoryCalls = $null + $legacyFactory = $null + $observed = $null + $client.Dispose() + $handler.Dispose() + $ready.Dispose() + $go.Dispose() + if (Test-Path -LiteralPath $storePath -PathType Leaf) { + Remove-Item -LiteralPath $storePath -Force + } + } + } + + It 'removes the owning module, rejects exact-source reuse, and collects its provider context' { + $storePath = Join-Path $TestDrive ( + 'task7-owning-profiles-' + [guid]::NewGuid().ToString('N') + '.json') + $job = Start-ThreadJob -ScriptBlock { + param($Manifest, $StorePath) + + function Invoke-Task7OwningLifecycleProbe { + param($ManifestPath, $ProfileStorePath) + + $module = $null + $context = $null + $source = $null + $capture = $null + $state = $null + $authHost = $null + $weak = $null + $moduleRemoved = $false + try { + $store = [ordered] @{ + SchemaVersion = 1 + Profiles = @( + [ordered] @{ + ProfileId = 'task7-owning-fixed-bearer' + Name = 'Task 7 owning synthetic bearer' + Kind = 'lab' + TenantId = '00000000-0000-0000-0000-000000000078' + ClientId = $null + AuthMethod = 'BearerToken' + Environment = 'Global' + Credential = [ordered] @{ + Token = 'task7-owning-synthetic-fixed-bearer-token' + Version = 'task7-owning-v1' + } + } + ) + } + [IO.File]::WriteAllText( + $ProfileStorePath, + (ConvertTo-Json $store -Depth 20), + [Text.UTF8Encoding]::new($false) + ) + $store = $null + + $module = Import-Module $ManifestPath -Force -PassThru -ErrorAction Stop + $context = Get-GraphContext -ProfileId task7-owning-fixed-bearer ` + -StorePath $ProfileStorePath + $source = $context.TokenSource + $capture = & $module { + param($ExpectedSource) + $owned = @($script:GraphKitModuleLifecycle.OwnedResources) + [pscustomobject] @{ + State = $script:GraphKitModuleLifecycle + Host = $script:GraphKitAuthHost + Weak = $script:GraphKitAuthHost.LoadContextWeakReference + ExactRegistration = + $owned.Count -eq 2 -and + [object]::ReferenceEquals($owned[0], $script:GraphKitAuthHost) -and + [object]::ReferenceEquals($owned[1], $ExpectedSource) + ResourceTypes = @( + $owned | ForEach-Object { $_.GetType().FullName } + ) + } + } $source + $state = $capture.State + $authHost = $capture.Host + $weak = $capture.Weak + $exactRegistration = [bool] $capture.ExactRegistration + $resourceTypes = [string[]] @($capture.ResourceTypes) + $capture = $null + + Remove-Module -ModuleInfo $module -Force -ErrorAction Stop + $moduleRemoved = $true + $cleanupObserved = $state.WaitForCleanup(5000) + $cleanupComplete = [bool] $state.CleanupComplete + $activeOperations = [int] $state.ActiveOperations + $ownedResourceCount = [int] $state.OwnedResources.Count + $failureCount = @($state.GetFailures()).Count + + $rejected = $false + $rejectionType = $null + try { + $null = $source.Acquire( + $false, + [Threading.CancellationToken]::None) + } + catch [ObjectDisposedException] { + $rejected = $true + $rejectionType = $_.Exception.GetType().FullName + } + + $source = $null + $context = $null + $module = $null + $authHost = $null + $state = $null + + return [pscustomobject] @{ + WeakReference = $weak + ExactRegistration = $exactRegistration + ResourceTypes = $resourceTypes + ModuleRemoved = $moduleRemoved + CleanupObserved = $cleanupObserved + CleanupComplete = $cleanupComplete + ActiveOperations = $activeOperations + OwnedResourceCount = $ownedResourceCount + FailureCount = $failureCount + SourceRejected = $rejected + RejectionType = $rejectionType + } + } + finally { + if ($null -ne $module -and -not $moduleRemoved) { + Remove-Module -ModuleInfo $module -Force -ErrorAction SilentlyContinue + } + $capture = $null + $source = $null + $context = $null + $module = $null + $authHost = $null + $state = $null + $weak = $null + if (Test-Path -LiteralPath $ProfileStorePath -PathType Leaf) { + Remove-Item -LiteralPath $ProfileStorePath -Force + } + } + } + + $probe = Invoke-Task7OwningLifecycleProbe ` + -ManifestPath $Manifest -ProfileStorePath $StorePath + $weak = $probe.WeakReference + for ($attempt = 0; $attempt -lt 30 -and $weak.IsAlive; $attempt++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } + [pscustomobject] @{ + ExactRegistration = $probe.ExactRegistration + ResourceTypes = $probe.ResourceTypes + ModuleRemoved = $probe.ModuleRemoved + CleanupObserved = $probe.CleanupObserved + CleanupComplete = $probe.CleanupComplete + ActiveOperations = $probe.ActiveOperations + OwnedResourceCount = $probe.OwnedResourceCount + FailureCount = $probe.FailureCount + SourceRejected = $probe.SourceRejected + RejectionType = $probe.RejectionType + ProviderContextCollected = -not $weak.IsAlive + } + $weak = $null + $probe = $null + } -ArgumentList $script:BuiltManifest, $storePath + + try { + $completed = @($job | Wait-Job -Timeout 10) + $completed.Count | Should -Be 1 + $job.State | Should -BeExactly 'Completed' + $result = @($job | Receive-Job -ErrorAction Stop) + $result.Count | Should -Be 1 + $result[0].ExactRegistration | Should -BeTrue + @($result[0].ResourceTypes) | Should -Be @( + 'GraphKit.Auth.GraphAuthHost', + 'GraphKit.Auth.GraphTokenSourceProxy' + ) + $result[0].ModuleRemoved | Should -BeTrue + $result[0].CleanupObserved | Should -BeTrue + $result[0].CleanupComplete | Should -BeTrue + $result[0].ActiveOperations | Should -Be 0 + $result[0].OwnedResourceCount | Should -Be 0 + $result[0].FailureCount | Should -Be 0 + $result[0].SourceRejected | Should -BeTrue + $result[0].RejectionType | Should -BeExactly 'System.ObjectDisposedException' + $result[0].ProviderContextCollected | Should -BeTrue + } + finally { + if ($null -ne $job) { + $null = @($job | Wait-Job -Timeout 10) + Remove-Job $job -Force -ErrorAction SilentlyContinue + } + $job = $null + if (Test-Path -LiteralPath $storePath -PathType Leaf) { + Remove-Item -LiteralPath $storePath -Force + } + } + } +} diff --git a/tests/Concurrency/TokenIsolation.Tests.ps1 b/tests/Concurrency/TokenIsolation.Tests.ps1 index c5cde40..ab10643 100644 --- a/tests/Concurrency/TokenIsolation.Tests.ps1 +++ b/tests/Concurrency/TokenIsolation.Tests.ps1 @@ -7,11 +7,11 @@ retargets every other. A per-context token source is only an improvement if it actually keeps contexts apart. - Note on structure: token sources are PowerShell classes defined inside the module, so - an instance cannot be marshalled into a bare runspace - the concurrent test therefore - imports the module and constructs its source INSIDE each child, sharing only a plain - ConcurrentDictionary. Properties that are not about concurrency are asserted directly, - because a real runspace adds nothing but flakiness to them. + Note on structure: this file retains direct per-instance coverage for the legacy + PowerShell-class sources. Task 7's GraphKitAuthRunspace.Tests.ps1 separately proves that + one exact compiled parent source crosses real thread runspaces by reference. These legacy + fixtures stay module-scoped because their compatibility boundary intentionally rejects + cross-runspace acquisition. #> BeforeAll { @@ -34,25 +34,32 @@ BeforeAll { # acquisitions and returns a token naming its tenant, so a token reaching the wrong # context is immediately identifiable rather than merely "a token". $script:SourceFactoryScript = { - param([string] $Tenant, $Counter, [int] $DelayMs = 0) + param([string] $Tenant, $Counter, $ForceRefreshFlags) # State is carried on the objects themselves ($this) rather than in closures: # ScriptMethod bodies do not reliably see variables captured by GetNewClosure at # the point they are later invoked, which silently yields a null Counter. $factory = { - $app = [pscustomobject] @{ Tenant = $Tenant; Counter = $Counter; DelayMs = $DelayMs } + $app = [pscustomobject] @{ + Tenant = $Tenant + Counter = $Counter + ForceRefreshFlags = $ForceRefreshFlags + } $app | Add-Member -MemberType ScriptMethod -Name AcquireTokenForClient -Value { param($Scopes) $builder = [pscustomobject] @{ - Tenant = $this.Tenant - Counter = $this.Counter - DelayMs = $this.DelayMs + Tenant = $this.Tenant + Counter = $this.Counter + ForceRefreshFlags = $this.ForceRefreshFlags + } + $builder | Add-Member -MemberType ScriptMethod -Name WithForceRefresh -Value { + param([bool] $ForceRefresh) + $this.ForceRefreshFlags.Enqueue($ForceRefresh) + return $this } $builder | Add-Member -MemberType ScriptMethod -Name ExecuteAsync -Value { param($Cancellation) $null = $this.Counter.AddOrUpdate($this.Tenant, 1, [Func[string, int, int]] { param($k, $v) $v + 1 }) - if ($this.DelayMs -gt 0) { Start-Sleep -Milliseconds $this.DelayMs } - $auth = [pscustomobject] @{ AccessToken = "TOKEN-FOR-$($this.Tenant)" ExpiresOn = [System.DateTimeOffset]::UtcNow.AddHours(1) @@ -75,10 +82,54 @@ BeforeAll { } function New-TestTokenSource { - param([string] $Tenant, $Counter, [int] $DelayMs = 0) - InModuleScope GraphKit -Parameters @{ T = $Tenant; C = $Counter; D = $DelayMs; F = $script:SourceFactoryScript } { - param($T, $C, $D, $F) - & $F $T $C $D + param( + [string] $Tenant, + $Counter, + $ForceRefreshFlags = ([System.Collections.Concurrent.ConcurrentQueue[bool]]::new()) + ) + InModuleScope GraphKit -Parameters @{ T = $Tenant; C = $Counter; Q = $ForceRefreshFlags; F = $script:SourceFactoryScript } { + param($T, $C, $Q, $F) + & $F $T $C $Q + } + } + + function New-TestManagedIdentitySource { + param($ForceRefreshFlags) + + InModuleScope GraphKit -Parameters @{ Q = $ForceRefreshFlags } { + param($Q) + + $factory = { + $app = [pscustomobject] @{ ForceRefreshFlags = $Q } + $app | Add-Member -MemberType ScriptMethod -Name AcquireTokenForManagedIdentity -Value { + param($Scope) + $builder = [pscustomobject] @{ ForceRefreshFlags = $this.ForceRefreshFlags } + $builder | Add-Member -MemberType ScriptMethod -Name WithForceRefresh -Value { + param([bool] $ForceRefresh) + $this.ForceRefreshFlags.Enqueue($ForceRefresh) + return $this + } + $builder | Add-Member -MemberType ScriptMethod -Name ExecuteAsync -Value { + param($Cancellation) + $auth = [pscustomobject] @{ + AccessToken = 'TOKEN-FOR-MANAGED-IDENTITY' + ExpiresOn = [System.DateTimeOffset]::UtcNow.AddHours(1) + } + $task = [pscustomobject] @{ Auth = $auth } + $task | Add-Member -MemberType ScriptMethod -Name GetAwaiter -Value { + $awaiter = [pscustomobject] @{ Auth = $this.Auth } + $awaiter | Add-Member -MemberType ScriptMethod -Name GetResult -Value { return $this.Auth } + return $awaiter + } + return $task + } + return $builder + } + return $app + }.GetNewClosure() + + return [ManagedIdentityTokenSource]::new( + $factory, 'https://graph.microsoft.com', 'client-id', 'managed-generation') } } } @@ -126,6 +177,27 @@ Describe 'Token isolation: a context receives only its own token' { Describe 'Token isolation: refresh and caching stay context-local' { + It 'forwards the force-refresh decision to the confidential-client builder' { + $counter = [System.Collections.Concurrent.ConcurrentDictionary[string, int]]::new() + $flags = [System.Collections.Concurrent.ConcurrentQueue[bool]]::new() + $source = New-TestTokenSource -Tenant 'force-confidential' -Counter $counter -ForceRefreshFlags $flags + + $null = $source.Acquire($false, [System.Threading.CancellationToken]::None) + $null = $source.Acquire($true, [System.Threading.CancellationToken]::None) + + @($flags.ToArray()) | Should -Be @($false, $true) + } + + It 'forwards the force-refresh decision to the managed-identity builder' { + $flags = [System.Collections.Concurrent.ConcurrentQueue[bool]]::new() + $source = New-TestManagedIdentitySource -ForceRefreshFlags $flags + + $null = $source.Acquire($false, [System.Threading.CancellationToken]::None) + $null = $source.Acquire($true, [System.Threading.CancellationToken]::None) + + @($flags.ToArray()) | Should -Be @($false, $true) + } + It 'a forced refresh on one context leaves another untouched' { # The single 401 force-refresh must not be a global event: that is precisely the # process-global behaviour the SDK transport was rejected for. diff --git a/tests/Fixtures/GraphKitAuthParityCases.json b/tests/Fixtures/GraphKitAuthParityCases.json new file mode 100644 index 0000000..bcdf4bc --- /dev/null +++ b/tests/Fixtures/GraphKitAuthParityCases.json @@ -0,0 +1,198 @@ +{ + "schemaVersion": 1, + "rowCount": 16, + "rows": [ + { + "id": "construction-certificate", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "construction", + "authMode": "Certificate", + "callLayerByRunner": {"xunit-compiled": "construction-only", "pester-legacy": "construction-only"}, + "input": {"tokens": [], "expiresOnUtc": [], "forceFlags": [], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 1, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0} + } + }, + { + "id": "construction-client-secret", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "construction", + "authMode": "ClientSecret", + "callLayerByRunner": {"xunit-compiled": "construction-only", "pester-legacy": "construction-only"}, + "input": {"tokens": [], "expiresOnUtc": [], "forceFlags": [], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 1, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0} + } + }, + { + "id": "construction-managed-identity", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "construction", + "authMode": "ManagedIdentity", + "callLayerByRunner": {"xunit-compiled": "construction-only", "pester-legacy": "construction-only"}, + "input": {"tokens": [], "expiresOnUtc": [], "forceFlags": [], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 1, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0} + } + }, + { + "id": "construction-bearer-token", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "construction", + "authMode": "BearerToken", + "callLayerByRunner": {"xunit-compiled": "construction-only", "pester-legacy": "construction-only"}, + "input": {"tokens": ["task7-fixed-bearer"], "expiresOnUtc": [], "forceFlags": [], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": false, "authMode": "BearerToken", "audience": "https://graph.microsoft.com/", "clientId": null, "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": false, "authMode": "BearerToken", "audience": "https://graph.microsoft.com", "clientId": null, "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": null, "cacheState": "Empty", "finalFlightRegistryCount": 0} + } + }, + { + "id": "ordinary-cache-hit", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "cache-hit", + "authMode": "Certificate", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-cache-token"], "expiresOnUtc": ["2099-01-01T00:00:00+00:00"], "forceFlags": [false, false], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-cache-token", "task7-cache-token"], "expiriesOnUtc": ["2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null], "fingerprints": ["04541062146f483873bcfc895119fbad37b329366184fa6853f85b224058ed02", "04541062146f483873bcfc895119fbad37b329366184fa6853f85b224058ed02"], "generations": ["task7-generation", "task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "AllSame", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-cache-token", "task7-cache-token"], "expiriesOnUtc": ["2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null], "fingerprints": ["04541062146f483873bcfc895119fbad37b329366184fa6853f85b224058ed02", "04541062146f483873bcfc895119fbad37b329366184fa6853f85b224058ed02"], "generations": ["task7-generation", "task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "AllSame", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "expired-result-refresh", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "expiry-refresh", + "authMode": "ClientSecret", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-expired-token", "task7-refreshed-token"], "expiresOnUtc": ["2000-01-01T00:00:00+00:00", "2099-02-01T00:00:00+00:00"], "forceFlags": [false, false], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-02-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-expired-token", "task7-refreshed-token"], "expiriesOnUtc": ["2000-01-01T00:00:00+00:00", "2099-02-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null], "fingerprints": ["b92e4ba1ef5d09f503217d3183c0c5800d5b6b41d7428657de83dd13cf70a151", "9fc47689a9665fd18f6533875ee81a073df2735fb1e32e8e7fac98e78889189e"], "generations": ["task7-generation", "task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 2, "forceFlags": [false, false], "referenceIdentity": "AllDistinct", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-02-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-expired-token", "task7-refreshed-token"], "expiriesOnUtc": ["2000-01-01T00:00:00+00:00", "2099-02-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null], "fingerprints": ["b92e4ba1ef5d09f503217d3183c0c5800d5b6b41d7428657de83dd13cf70a151", "9fc47689a9665fd18f6533875ee81a073df2735fb1e32e8e7fac98e78889189e"], "generations": ["task7-generation", "task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 2, "forceFlags": [false, false], "referenceIdentity": "AllDistinct", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "ordinary-forced-ordinary", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "force-partition", + "authMode": "ManagedIdentity", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-mi-ordinary", "task7-mi-forced"], "expiresOnUtc": ["2099-02-01T00:00:00+00:00", "2099-03-01T00:00:00+00:00"], "forceFlags": [false, true, false], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-03-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-mi-ordinary", "task7-mi-forced", "task7-mi-forced"], "expiriesOnUtc": ["2099-02-01T00:00:00+00:00", "2099-03-01T00:00:00+00:00", "2099-03-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null, null], "fingerprints": ["f5398190efdc0448bd241de2daa73362beb3b47655535fcb3523c57efba1f4a7", "365ec8d8974c1d5c5d28946ebf5e4ffee2142fff909ca6c6ae86f7e7ccb698a4", "365ec8d8974c1d5c5d28946ebf5e4ffee2142fff909ca6c6ae86f7e7ccb698a4"], "generations": ["task7-generation", "task7-generation", "task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 2, "forceFlags": [false, true], "referenceIdentity": "SecondAndThirdSame", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-03-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-mi-ordinary", "task7-mi-forced", "task7-mi-forced"], "expiriesOnUtc": ["2099-02-01T00:00:00+00:00", "2099-03-01T00:00:00+00:00", "2099-03-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null, null], "fingerprints": ["f5398190efdc0448bd241de2daa73362beb3b47655535fcb3523c57efba1f4a7", "365ec8d8974c1d5c5d28946ebf5e4ffee2142fff909ca6c6ae86f7e7ccb698a4", "365ec8d8974c1d5c5d28946ebf5e4ffee2142fff909ca6c6ae86f7e7ccb698a4"], "generations": ["task7-generation", "task7-generation", "task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 2, "forceFlags": [false, true], "referenceIdentity": "SecondAndThirdSame", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "acquisition-failure-fanout-retry", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "failure-fanout-retry", + "authMode": "Certificate", + "callLayerByRunner": {"xunit-compiled": "compiled-internal-source-flight", "pester-legacy": "legacy-production-outer-keyed-flight"}, + "input": {"tokens": ["task7-failure", "task7-recovered"], "expiresOnUtc": ["2099-04-01T00:00:00+00:00", "2099-04-01T00:00:00+00:00"], "forceFlags": [false, false], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-04-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-recovered"], "expiriesOnUtc": ["2099-04-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["210b0c9a5a87ec611b26321b4af0372a51a5c37e5285ca760c61e4d6d57fa0cd"], "generations": ["task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 2, "forceFlags": [false, false], "referenceIdentity": "Single", "failureKind": "AcquisitionFailure", "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-04-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-recovered"], "expiriesOnUtc": ["2099-04-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["210b0c9a5a87ec611b26321b4af0372a51a5c37e5285ca760c61e4d6d57fa0cd"], "generations": ["task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 0, "providerAcquisitionCount": 2, "forceFlags": [false, false], "referenceIdentity": "Single", "failureKind": "AcquisitionFailure", "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "caller-cancellation-no-cache", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "caller-cancellation", + "authMode": "ClientSecret", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-cancelled-token"], "expiresOnUtc": ["2099-04-01T00:00:00+00:00"], "forceFlags": [false], "cancelCaller": true, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "None", "failureKind": "Canceled", "cacheState": "Empty", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "None", "failureKind": "Canceled", "cacheState": "Empty", "finalFlightRegistryCount": 0} + } + }, + { + "id": "fixed-bearer-cache-force-refusal", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "fixed-bearer", + "authMode": "BearerToken", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-fixed-bearer"], "expiresOnUtc": [], "forceFlags": [false, false, true], "cancelCaller": false, "fingerprintInput": null, "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": false, "authMode": "BearerToken", "audience": "https://graph.microsoft.com/", "clientId": null, "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fixed-bearer", "task7-fixed-bearer"], "expiriesOnUtc": ["0001-01-01T00:00:00+00:00", "0001-01-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null], "fingerprints": ["031a543f823d15bda5771f6126880f7f6d7ece11e42fdff37b01324ad0cb58a6", "031a543f823d15bda5771f6126880f7f6d7ece11e42fdff37b01324ad0cb58a6"], "generations": ["task7-generation", "task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "AllSame", "failureKind": "RefreshRefused", "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": false, "authMode": "BearerToken", "audience": "https://graph.microsoft.com", "clientId": null, "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fixed-bearer", "task7-fixed-bearer"], "expiriesOnUtc": ["0001-01-01T00:00:00+00:00", "0001-01-01T00:00:00+00:00"], "tokenTypes": ["Bearer", "Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"], ["https://graph.microsoft.com/.default"]], "tenantProofs": [null, null], "fingerprints": ["031a543f823d15bda5771f6126880f7f6d7ece11e42fdff37b01324ad0cb58a6", "031a543f823d15bda5771f6126880f7f6d7ece11e42fdff37b01324ad0cb58a6"], "generations": ["task7-generation", "task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "AllSame", "failureKind": "RefreshRefused", "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "fingerprint-certificate", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "fingerprint", + "authMode": "Certificate", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-fingerprint-certificate"], "expiresOnUtc": ["2099-05-01T00:00:00+00:00"], "forceFlags": [false], "cancelCaller": false, "fingerprintInput": "task7-fingerprint-certificate", "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-05-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-certificate"], "expiriesOnUtc": ["2099-05-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["245d574cc6b41262c018a65535f9937601045f29abff3df9d112e491048948b6"], "generations": ["task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-05-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-certificate"], "expiriesOnUtc": ["2099-05-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["245d574cc6b41262c018a65535f9937601045f29abff3df9d112e491048948b6"], "generations": ["task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "fingerprint-client-secret", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "fingerprint", + "authMode": "ClientSecret", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-fingerprint-client-secret"], "expiresOnUtc": ["2099-05-01T00:00:00+00:00"], "forceFlags": [false], "cancelCaller": false, "fingerprintInput": "task7-fingerprint-client-secret", "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-05-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-client-secret"], "expiriesOnUtc": ["2099-05-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["b4ec6c20d74417b5be0b54ce83bc39a9f0b2e990ec173e6ee9373491a65de55e"], "generations": ["task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ClientSecret", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-05-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-client-secret"], "expiriesOnUtc": ["2099-05-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["b4ec6c20d74417b5be0b54ce83bc39a9f0b2e990ec173e6ee9373491a65de55e"], "generations": ["task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "fingerprint-managed-identity", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "fingerprint", + "authMode": "ManagedIdentity", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-fingerprint-managed-identity"], "expiresOnUtc": ["2099-05-01T00:00:00+00:00"], "forceFlags": [false], "cancelCaller": false, "fingerprintInput": "task7-fingerprint-managed-identity", "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-05-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-managed-identity"], "expiriesOnUtc": ["2099-05-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["6973fa751d466a0429077804c84708dc98188047d415ca992a583a6bf7744866"], "generations": ["task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-05-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-managed-identity"], "expiriesOnUtc": ["2099-05-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["6973fa751d466a0429077804c84708dc98188047d415ca992a583a6bf7744866"], "generations": ["task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 1, "providerAcquisitionCount": 1, "forceFlags": [false], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "fingerprint-bearer-token", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "fingerprint", + "authMode": "BearerToken", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": ["task7-fingerprint-bearer-token"], "expiresOnUtc": [], "forceFlags": [false], "cancelCaller": false, "fingerprintInput": "task7-fingerprint-bearer-token", "adoptToken": null, "adoptGeneration": null, "adoptReceivedOnUtc": null, "adoptExpiresOnUtc": null, "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": false, "authMode": "BearerToken", "audience": "https://graph.microsoft.com/", "clientId": null, "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-bearer-token"], "expiriesOnUtc": ["0001-01-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["04fa2face75f1b6e4df7e9270266abec23741a9e91c7fad9b12b1c8585c30bca"], "generations": ["task7-generation"], "receivedTimeRule": "InjectedClock", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": false, "authMode": "BearerToken", "audience": "https://graph.microsoft.com", "clientId": null, "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": ["task7-fingerprint-bearer-token"], "expiriesOnUtc": ["0001-01-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": [null], "fingerprints": ["04fa2face75f1b6e4df7e9270266abec23741a9e91c7fad9b12b1c8585c30bca"], "generations": ["task7-generation"], "receivedTimeRule": "WallClock", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "Single", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + }, + { + "id": "adoption-generation-mismatch", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "adoption-mismatch", + "authMode": "Certificate", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": [], "expiresOnUtc": [], "forceFlags": [false], "cancelCaller": false, "fingerprintInput": null, "adoptToken": "task7-wrong-generation", "adoptGeneration": "task7-other-generation", "adoptReceivedOnUtc": "2026-08-31T12:00:00+00:00", "adoptExpiresOnUtc": "2099-06-01T00:00:00+00:00", "adoptTenantProof": null}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 1, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": "GenerationMismatch", "cacheState": "Empty", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "Certificate", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000002", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "0001-01-01T00:00:00+00:00", "sourceVerifiedTenantId": null, "tokenSequence": [], "expiriesOnUtc": [], "tokenTypes": [], "orderedScopes": [], "tenantProofs": [], "fingerprints": [], "generations": [], "receivedTimeRule": "None", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "None", "failureKind": "GenerationMismatch", "cacheState": "Empty", "finalFlightRegistryCount": 0} + } + }, + { + "id": "adoption-valid", + "runners": ["xunit-compiled", "pester-legacy"], + "scenario": "adoption-valid", + "authMode": "ManagedIdentity", + "callLayerByRunner": {"xunit-compiled": "direct-source", "pester-legacy": "direct-source"}, + "input": {"tokens": [], "expiresOnUtc": [], "forceFlags": [false], "cancelCaller": false, "fingerprintInput": null, "adoptToken": "task7-adopted", "adoptGeneration": "task7-generation", "adoptReceivedOnUtc": "2026-08-31T12:00:00+00:00", "adoptExpiresOnUtc": "2099-06-01T00:00:00+00:00", "adoptTenantProof": "task7-verified-tenant"}, + "expectedByRunner": { + "xunit-compiled": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com/", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-06-01T00:00:00+00:00", "sourceVerifiedTenantId": "task7-verified-tenant", "tokenSequence": ["task7-adopted"], "expiriesOnUtc": ["2099-06-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": ["task7-verified-tenant"], "fingerprints": ["30b8da1fe4619e861346b4a126726d6c781940797f2446a711666a58256796cc"], "generations": ["task7-generation"], "receivedTimeRule": "LiteralAdopted", "applicationConstructionCount": 1, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "AdoptedAndReturnedSame", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0}, + "pester-legacy": {"canRefresh": true, "authMode": "ManagedIdentity", "audience": "https://graph.microsoft.com", "clientId": "00000000-0000-0000-0000-000000000003", "credentialGeneration": "task7-generation", "sourceExpiresOnUtc": "2099-06-01T00:00:00+00:00", "sourceVerifiedTenantId": "task7-verified-tenant", "tokenSequence": ["task7-adopted"], "expiriesOnUtc": ["2099-06-01T00:00:00+00:00"], "tokenTypes": ["Bearer"], "orderedScopes": [["https://graph.microsoft.com/.default"]], "tenantProofs": ["task7-verified-tenant"], "fingerprints": ["30b8da1fe4619e861346b4a126726d6c781940797f2446a711666a58256796cc"], "generations": ["task7-generation"], "receivedTimeRule": "LiteralAdopted", "applicationConstructionCount": 0, "providerAcquisitionCount": 0, "forceFlags": [], "referenceIdentity": "AdoptedAndReturnedSame", "failureKind": null, "cacheState": "Populated", "finalFlightRegistryCount": 0} + } + } + ] +} diff --git a/tests/QA/BuiltModule.tests.ps1 b/tests/QA/BuiltModule.tests.ps1 index 238f820..dab71c4 100644 --- a/tests/QA/BuiltModule.tests.ps1 +++ b/tests/QA/BuiltModule.tests.ps1 @@ -37,11 +37,20 @@ Describe 'Built module' -Skip:($null -eq $script:BuiltBase) { Test-Path (Join-Path $script:BuiltBase.FullName $Path) | Should -BeTrue -Because 'missing CopyPaths entries vanish silently from the package' } - It 'declares only the always-required runtime dependency' { + It 'declares only Graph Authentication as an always-required runtime dependency' { $d = Import-PowerShellDataFile $script:Manifest $names = @($d.RequiredModules | ForEach-Object { if ($_ -is [hashtable]) { $_.ModuleName } else { $_ } }) $names | Should -Contain 'Microsoft.Graph.Authentication' - $names | Should -Not -Contain 'Microsoft.PowerShell.SecretManagement' -Because 'vault support is loaded only when a vault-backed credential is used' + $names | Should -Not -Contain 'Microsoft.PowerShell.SecretManagement' -Because 'vault support imports SecretManagement only when a persisted vault-backed credential is resolved' + } + + It 'loads exactly the packaged GraphKit.Auth contracts assembly before module import' { + $d = Import-PowerShellDataFile $script:Manifest + (@($d.RequiredAssemblies | Where-Object { $null -ne $_ }) -join '|') | + Should -BeExactly 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + Test-Path -LiteralPath ( + Join-Path $script:BuiltBase.FullName 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + ) -PathType Leaf | Should -BeTrue } It 'registers the format file via FormatsToProcess' { diff --git a/tests/QA/CleanImport.tests.ps1 b/tests/QA/CleanImport.tests.ps1 index 3ece530..6313d1a 100644 --- a/tests/QA/CleanImport.tests.ps1 +++ b/tests/QA/CleanImport.tests.ps1 @@ -96,3 +96,169 @@ Import-Module '$script:manifestPath' -Force [int] $count | Should -Be 5 -Because 'all five v1 strategies must register: Collection, Singleton, Action, Reconciliation, LongRunningJob' } } + +Describe 'Non-vault GraphKit paths do not require SecretManagement or a vault' { + BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $built = Get-ChildItem -Path (Join-Path $script:repoRoot 'output/module/GraphKit') -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -eq $built) { + throw 'GraphKit is not built. Run ./build.ps1 -Tasks build first.' + } + + $graphAuth = Join-Path $script:repoRoot 'output/RequiredModules/Microsoft.Graph.Authentication/2.38.1' + if (-not (Test-Path -LiteralPath $graphAuth -PathType Container)) { + throw 'Microsoft.Graph.Authentication 2.38.1 is not available for the isolated non-vault probe.' + } + + $modulePath = Join-Path $TestDrive 'non-vault-modules' + $graphKitDestination = Join-Path $modulePath "GraphKit/$($built.Name)" + $graphAuthDestination = Join-Path $modulePath 'Microsoft.Graph.Authentication/2.38.1' + $null = New-Item -ItemType Directory -Path $graphKitDestination, $graphAuthDestination -Force + Copy-Item -Path (Join-Path $built.FullName '*') -Destination $graphKitDestination -Recurse -Force + Copy-Item -Path (Join-Path $graphAuth '*') -Destination $graphAuthDestination -Recurse -Force + + $isolatedManifest = Join-Path $graphKitDestination 'GraphKit.psd1' + $storePath = Join-Path $TestDrive 'non-vault-profiles.json' + $escapedModulePath = $modulePath.Replace("'", "''") + $escapedManifest = $isolatedManifest.Replace("'", "''") + $escapedStore = $storePath.Replace("'", "''") + + $probe = @" +`$ErrorActionPreference = 'Stop' +`$result = [ordered]@{ + ImportSucceeded = `$false + FatalStage = `$null + FatalError = `$null + HelpName = `$null + OperationName = `$null + MiAuthMode = `$null + MiIdentityState = `$null + InjectedAuthMode = `$null + ClientSecretContextError = `$null + BearerContextError = `$null + SecretManagementLoadedAfterImport = `$false + SecretManagementLoadedAtEnd = `$false + SecretManagementAvailableAtEnd = `$false +} +`$stage = 'import' +try { + `$env:PSModulePath = '$escapedModulePath' + Import-Module '$escapedManifest' -Force -ErrorAction Stop + `$result.ImportSucceeded = `$true + `$result.SecretManagementLoadedAfterImport = [bool] (Get-Module Microsoft.PowerShell.SecretManagement) + + # RequiredModule resolution may re-add the host's default module roots. Reset the + # path and loaded-module table before exercising the optional dependency boundary. + `$env:PSModulePath = '$escapedModulePath' + Remove-Module Microsoft.PowerShell.SecretManagement -Force -ErrorAction SilentlyContinue + + `$stage = 'help-and-catalog' + `$help = Get-Help Get-GraphOperation -ErrorAction Stop + `$operation = Get-GraphOperation -Type ManagedDevice -Operation List + `$result.HelpName = [string] `$help.Name + `$result.OperationName = "`$(`$operation.Type).`$(`$operation.Operation)" + + `$stage = 'managed-identity' + Register-GraphTenant -ProfileId 'mi-lab' -Name 'Lab' -Kind lab `` + -TenantId '3a4b5c6d-1111-2222-3333-444455556666' -Environment Global `` + -AuthMethod ManagedIdentity -StorePath '$escapedStore' + `$miContext = Get-GraphContext -ProfileId 'mi-lab' -StorePath '$escapedStore' + `$result.MiAuthMode = [string] `$miContext.TokenSource.AuthMode + `$result.MiIdentityState = [string] `$miContext.IdentityState + + `$stage = 'injected-provider' + Register-GraphTenant -ProfileId 'provider-lab' -Name 'Provider' -Kind lab `` + -TenantId '3a4b5c6d-1111-2222-3333-444455556666' `` + -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment Global `` + -AuthMethod ClientSecret -VaultName 'missing' -SecretName 'client-secret' `` + -StorePath '$escapedStore' + `$injected = Get-GraphContext -ProfileId 'provider-lab' -StorePath '$escapedStore' `` + -TokenProvider { @{ Token = 'injected-token'; ExpiresOnUtc = [datetime]::UtcNow.AddHours(1) } } + `$result.InjectedAuthMode = [string] `$injected.TokenSource.AuthMode + + `$stage = 'client-secret-boundary' + try { + `$null = Get-GraphContext -ProfileId 'provider-lab' -StorePath '$escapedStore' + } + catch { + `$result.ClientSecretContextError = @(`$_.Exception.Message, `$_.Exception.InnerException.Message, (`$_ | Out-String)) -join ' ' + } + + `$stage = 'bearer-boundary' + Register-GraphTenant -ProfileId 'bearer-lab' -Name 'Bearer' -Kind lab `` + -TenantId '3a4b5c6d-1111-2222-3333-444455556666' -Environment Global `` + -AuthMethod BearerToken -VaultName 'missing' -SecretName 'bearer' `` + -StorePath '$escapedStore' + try { + `$null = Get-GraphContext -ProfileId 'bearer-lab' -StorePath '$escapedStore' + } + catch { + `$result.BearerContextError = @(`$_.Exception.Message, `$_.Exception.InnerException.Message, (`$_ | Out-String)) -join ' ' + } +} +catch { + `$result.FatalStage = `$stage + `$result.FatalError = @(`$_.Exception.Message, `$_.Exception.InnerException.Message, (`$_ | Out-String)) -join ' ' +} +finally { + `$result.SecretManagementLoadedAtEnd = [bool] (Get-Module Microsoft.PowerShell.SecretManagement) + `$env:PSModulePath = '$escapedModulePath' + `$result.SecretManagementAvailableAtEnd = [bool] (Get-Module Microsoft.PowerShell.SecretManagement -ListAvailable -Refresh) +} +[pscustomobject] `$result | ConvertTo-Json -Compress +"@ + + $savedModulePath = $env:PSModulePath + try { + # Set this before creating pwsh so its initial discovery cache cannot see + # the developer machine's optional SecretManagement installation. + $env:PSModulePath = $modulePath + $raw = & pwsh -NoLogo -NoProfile -Command $probe 2>&1 + $exitCode = $LASTEXITCODE + } + finally { + $env:PSModulePath = $savedModulePath + } + + $json = @($raw | Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') }) | Select-Object -Last 1 + $script:isolated = [pscustomobject]@{ + ExitCode = $exitCode + Data = if ($json) { $json | ConvertFrom-Json } else { $null } + Output = ($raw | Out-String).Trim() + } + } + + It 'imports help and catalog inspection without SecretManagement' { + $script:isolated.ExitCode | Should -Be 0 -Because $script:isolated.Output + $script:isolated.Data | Should -Not -BeNullOrEmpty -Because $script:isolated.Output + $script:isolated.Data.ImportSucceeded | Should -BeTrue -Because "stage $($script:isolated.Data.FatalStage): $($script:isolated.Data.FatalError)" + $script:isolated.Data.FatalError | Should -BeNullOrEmpty + $script:isolated.Data.HelpName | Should -Be 'Get-GraphOperation' + $script:isolated.Data.OperationName | Should -Be 'ManagedDevice.List' + $script:isolated.Data.SecretManagementLoadedAfterImport | Should -BeFalse + $script:isolated.Data.SecretManagementLoadedAtEnd | Should -BeFalse + $script:isolated.Data.SecretManagementAvailableAtEnd | Should -BeFalse + } + + It 'registers and resolves managed identity without SecretManagement' { + $script:isolated.Data.MiAuthMode | Should -Be 'ManagedIdentity' + $script:isolated.Data.MiIdentityState | Should -Be 'NotAcquired' + } + + It 'resolves an injected token provider without SecretManagement' { + $script:isolated.Data.InjectedAuthMode | Should -Be 'Provider' + } + + It 'fails a vault-backed client-secret profile actionably at context resolution' { + $script:isolated.Data.ClientSecretContextError | Should -Match 'Microsoft\.PowerShell\.SecretManagement' + $script:isolated.Data.ClientSecretContextError | Should -Match 'Install-Module' + $script:isolated.Data.ClientSecretContextError | Should -Match '1\.1\.2' + } + + It 'fails a vault-backed bearer profile with the missing-module message, not an opaque token error' { + $script:isolated.Data.BearerContextError | Should -Match 'Microsoft\.PowerShell\.SecretManagement' + $script:isolated.Data.BearerContextError | Should -Match 'Install-Module' + $script:isolated.Data.BearerContextError | Should -Not -Match '(?i)token.*(invalid|expired|malformed)' + } +} diff --git a/tests/QA/Get-GraphKitPesterDiscoveryCount.ps1 b/tests/QA/Get-GraphKitPesterDiscoveryCount.ps1 new file mode 100644 index 0000000..cca3c9a --- /dev/null +++ b/tests/QA/Get-GraphKitPesterDiscoveryCount.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string] $RepositoryRoot) + +$ErrorActionPreference = 'Stop' + +$root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath +$pesterManifest = Join-Path $root 'output/RequiredModules/Pester/6.1.0/Pester.psd1' +if (-not (Test-Path -LiteralPath $pesterManifest -PathType Leaf)) { + throw "The repository-pinned Pester 6.1.0 manifest is missing at '$pesterManifest'." +} +Import-Module $pesterManifest -Force -ErrorAction Stop + +$configuration = New-PesterConfiguration +$configuration.Run.Path = Join-Path $root 'tests' +$configuration.Run.SkipRun = $true +$configuration.Run.PassThru = $true +$configuration.Output.Verbosity = 'None' +$result = Invoke-Pester -Configuration $configuration +if ([string]$result.Result -cne 'Passed' -or @($result.FailedContainers).Count -ne 0) { + $failureDetails = @($result.FailedContainers | ForEach-Object { + "$($_.Item): $($_.ErrorRecord.Exception.Message)" + }) -join '; ' + throw "Pester discovery did not complete cleanly: result=$($result.Result), failedContainers=$(@($result.FailedContainers).Count); $failureDetails" +} + +$platform = if ($IsWindows) { 'Windows' } elseif ($IsLinux) { 'Linux' } elseif ($IsMacOS) { 'MacOS' } else { 'Unknown' } +[ordered]@{ + schemaVersion = 1 + platform = $platform + total = [int]$result.TotalCount + containers = @($result.Containers).Count +} | ConvertTo-Json -Compress diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 new file mode 100644 index 0000000..3bc0827 --- /dev/null +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -0,0 +1,4263 @@ +$task8AuthModes = @( + @{ AuthMode = 'Certificate' } + @{ AuthMode = 'ClientSecret' } + @{ AuthMode = 'ManagedIdentity' } + @{ AuthMode = 'BearerToken' } +) + +$task8UnsafeArchiveCases = @( + @{ Kind = 'parent traversal'; EntryName = '../outside.ps1' } + @{ Kind = 'absolute path'; EntryName = '/absolute.ps1' } + @{ Kind = 'drive path'; EntryName = 'C:/absolute.ps1' } + @{ Kind = 'backslash'; EntryName = 'Data\evil.ps1' } + @{ Kind = 'empty segment'; EntryName = 'Data//evil.ps1' } + @{ Kind = 'dot segment'; EntryName = 'Data/./evil.ps1' } + @{ Kind = 'nested traversal'; EntryName = 'Data/../evil.ps1' } +) + +$task8PortableArchiveSegmentCases = @( + @{ Kind = 'alternate data stream'; EntryName = 'Data/probe.ps1:payload' } + @{ Kind = 'reserved CON basename'; EntryName = 'Data/CON' } + @{ Kind = 'reserved CON basename with extension'; EntryName = 'Data/con.txt' } + @{ Kind = 'reserved NUL basename with extension'; EntryName = 'Data/NUL.ps1' } + @{ Kind = 'reserved COM1 basename with extension'; EntryName = 'Data/Com1.json' } + @{ Kind = 'reserved LPT9 basename with extension'; EntryName = 'Data/lpt9.bin' } + @{ Kind = 'reserved CONIN basename'; EntryName = 'Data/CONIN$' } + @{ Kind = 'reserved CONIN basename with extension'; EntryName = 'Data/conin$.txt' } + @{ Kind = 'reserved CONOUT basename'; EntryName = 'Data/CONOUT$' } + @{ Kind = 'reserved CONOUT basename with extension'; EntryName = 'Data/conout$.json' } + @{ Kind = 'less-than character'; EntryName = 'Data/probe.ps1' } + @{ Kind = 'double-quote character'; EntryName = 'Data/probe"one.ps1' } + @{ Kind = 'pipe character'; EntryName = 'Data/probe|one.ps1' } + @{ Kind = 'question-mark character'; EntryName = 'Data/probe?one.ps1' } + @{ Kind = 'asterisk character'; EntryName = 'Data/probe*one.ps1' } + @{ Kind = 'control character'; EntryName = "Data/probe$([char]1)one.ps1" } + @{ Kind = 'trailing dot'; EntryName = 'Data/probe.ps1.' } + @{ Kind = 'trailing space'; EntryName = 'Data/probe.ps1 ' } +) + +$task8CleanupFileMutationCases = @( + @{ Kind = 'before writable transition'; HookKind = 'CleanupFileContentMutationBefore' } + @{ Kind = 'after writable transition'; HookKind = 'CleanupFileContentMutationAfter' } +) + +$task8PreSealMutationCases = @( + @{ Kind = 'file content'; HookKind = 'PreSealFileMutation'; HasOutside = $false } + @{ Kind = 'directory identity'; HookKind = 'PreSealDirectoryReplacement'; HasOutside = $true } + @{ Kind = 'root identity'; HookKind = 'PreSealRootReplacement'; HasOutside = $true } +) + +$task8CleanupContainerMutationCases = @( + @{ + Kind = 'directory identity after writable transition' + HookKind = 'CleanupDirectoryReplacementAfterWritable' + Relative = 'module' + Phase = 'AfterWritable' + } + @{ + Kind = 'root identity after writable transition' + HookKind = 'CleanupRootReplacementAfterWritable' + Relative = '' + Phase = 'AfterWritable' + } + @{ + Kind = 'directory identity immediately before deletion' + HookKind = 'CleanupDirectoryReplacementBeforeDelete' + Relative = 'module' + Phase = 'BeforeDelete' + } + @{ + Kind = 'root identity immediately before deletion' + HookKind = 'CleanupRootReplacementBeforeDelete' + Relative = '' + Phase = 'BeforeDelete' + } +) + +$task8LiveProofRejectionCases = @( + @{ Kind = 'empty context tenant'; HookKind = 'LiveContextTenantEmpty'; FailureStage = 'Context' } + @{ Kind = 'empty target tenant'; HookKind = 'LiveTargetTenantEmpty'; FailureStage = 'Read' } + @{ Kind = 'empty actual tenant'; HookKind = 'LiveActualTenantEmpty'; FailureStage = 'Read' } + @{ Kind = 'empty source tenant'; HookKind = 'LiveSourceTenantEmpty'; FailureStage = 'Read' } + @{ Kind = 'missing token fingerprint'; HookKind = 'LiveFingerprintMissing'; FailureStage = 'Read' } + @{ Kind = 'blank token fingerprint'; HookKind = 'LiveFingerprintBlank'; FailureStage = 'Read' } + @{ Kind = 'mismatched exposed token fingerprint'; HookKind = 'LiveFingerprintMismatch'; FailureStage = 'Read' } + @{ Kind = 'missing credential generation'; HookKind = 'LiveGenerationMissing'; FailureStage = 'Read' } + @{ Kind = 'blank credential generation'; HookKind = 'LiveGenerationBlank'; FailureStage = 'Read' } + @{ Kind = 'mismatched credential generation'; HookKind = 'LiveGenerationMismatch'; FailureStage = 'Read' } + @{ Kind = 'blank source credential generation'; HookKind = 'LiveSourceGenerationBlank'; FailureStage = 'Context' } + @{ Kind = 'missing proof cloud'; HookKind = 'LiveCloudMissing'; FailureStage = 'Read' } + @{ Kind = 'blank proof cloud'; HookKind = 'LiveCloudBlank'; FailureStage = 'Read' } + @{ Kind = 'mismatched proof cloud'; HookKind = 'LiveCloudMismatch'; FailureStage = 'Read' } + @{ Kind = 'mismatched source client scope'; HookKind = 'LiveSourceClientMismatch'; FailureStage = 'Context' } +) + +$task8ArchiveAliasCases = @( + @{ Kind = 'exact duplicate'; First = 'Data/probe.ps1'; Second = 'Data/probe.ps1' } + @{ Kind = 'portable case collision'; First = 'Data/probe.ps1'; Second = 'data/probe.ps1' } + @{ + Kind = 'NFC collision' + First = "Data/probé.ps1" + Second = "Data/probe$([char]0x0301).ps1" + } +) + +$task8ArchiveLinkCases = @( + @{ Kind = 'Unix symbolic link'; ExternalAttributes = ((0xA000 -bor 0x1A4) -shl 16) } + @{ Kind = 'Unix device'; ExternalAttributes = ((0x2000 -bor 0x180) -shl 16) } + @{ Kind = 'Windows reparse point'; ExternalAttributes = 0x0400 } + @{ Kind = 'Windows directory'; ExternalAttributes = 0x0010 } +) + +$task8EvidenceMutationCases = @( + @{ Kind = 'guid'; Value = '00000000-0000-0000-0000-000000000123' } + @{ Kind = 'profile'; Value = 'customer-profile-sentinel' } + @{ Kind = 'jwt'; Value = 'eyJhbGciOiJub25lIn0.eyJzdWIiOiJzZW50aW5lbCJ9.signature' } + @{ Kind = 'bearer'; Value = 'Bearer task8-secret-sentinel' } + @{ Kind = 'fingerprint'; Value = 'tokenFingerprint:task8-secret-sentinel' } + @{ Kind = 'correlation'; Value = 'correlationId:00000000-0000-0000-0000-000000000123' } + @{ Kind = 'response'; Value = 'responseBody:task8-secret-sentinel' } + @{ Kind = 'exception'; Value = 'System.Exception: task8-secret-sentinel at /tmp/secret.ps1:1' } + @{ Kind = 'unix-path'; Value = '/Users/task8-secret-sentinel/profile.json' } + @{ Kind = 'windows-path'; Value = 'C:\\Users\\task8-secret-sentinel\\profile.json' } + @{ Kind = 'unknown-nested'; Value = 'task8-secret-sentinel' } + @{ Kind = 'string-count'; Value = 'task8-string-count-sentinel' } +) + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $script:runnerPath = Join-Path $script:repoRoot 'scripts/Invoke-GraphKitAuthParity.ps1' + $script:workerPath = Join-Path $script:repoRoot ` + 'scripts/private/Invoke-GraphKitAuthParityWorker.ps1' + $script:task8ModeNames = @('Certificate','ClientSecret','ManagedIdentity','BearerToken') + + function New-Task8SparseFile { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][long] $Length + ) + + if ($IsWindows) { + $fixtureType = 'GraphKitTask8SparseFileFixtureV1' -as [type] + if ($null -eq $fixtureType) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class GraphKitTask8SparseFileFixtureV1 +{ + public const string ContractMarker = "GraphKit.Task8.SparseFileFixture/1"; + private const uint FsctlSetSparse = 0x000900C4; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle device, + uint controlCode, + IntPtr input, + uint inputSize, + IntPtr output, + uint outputSize, + out uint bytesReturned, + IntPtr overlapped); + + public static void MarkSparse(SafeFileHandle handle) + { + uint bytesReturned; + if (!DeviceIoControl( + handle, + FsctlSetSparse, + IntPtr.Zero, + 0, + IntPtr.Zero, + 0, + out bytesReturned, + IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } +} +'@ + $fixtureType = [GraphKitTask8SparseFileFixtureV1] + } + if ($fixtureType::ContractMarker -cne 'GraphKit.Task8.SparseFileFixture/1') { + throw 'A stale Task 8 sparse-file fixture type is already loaded.' + } + } + + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None) + try { + if ($IsWindows) { + [GraphKitTask8SparseFileFixtureV1]::MarkSparse($stream.SafeFileHandle) + } + $stream.SetLength($Length) + } + finally { + $stream.Dispose() + } + } + + function New-Task8FixturePackage { + param( + [Parameter(Mandatory)][string] $Name, + [object[]] $Entries = @( + @{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + } + @{ Path = 'GraphKit.psm1'; Content = '# valid Task 8 fixture module' } + ), + [IO.Compression.CompressionLevel] $CompressionLevel = + [IO.Compression.CompressionLevel]::Optimal + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $packagePath = Join-Path $TestDrive "$Name.nupkg" + $stream = [IO.FileStream]::new( + $packagePath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Create, + $true) + try { + foreach ($record in $Entries) { + $entry = $archive.CreateEntry( + [string] $record.Path, + $CompressionLevel) + if ($record.ContainsKey('ExternalAttributes')) { + $entry.ExternalAttributes = [int] $record.ExternalAttributes + } + $entryStream = $entry.Open() + try { + [byte[]] $bytes = [Text.UTF8Encoding]::new($false).GetBytes( + [string] $record.Content) + if ($record.Content -is [byte[]]) { + $bytes = [byte[]] $record.Content + } + $entryStream.Write($bytes, 0, $bytes.Length) + } + finally { + $entryStream.Dispose() + } + } + } + finally { + $archive.Dispose() + } + } + finally { + $stream.Dispose() + } + return $packagePath + } + + function Get-Task8PackedCandidate { + $sourceManifest = Import-PowerShellDataFile -Path (Join-Path $script:repoRoot 'source/GraphKit.psd1') + $baseVersion = [string] $sourceManifest.ModuleVersion + $builtManifestPath = Join-Path $script:repoRoot "output/module/GraphKit/$baseVersion/GraphKit.psd1" + if (-not (Test-Path -LiteralPath $builtManifestPath -PathType Leaf)) { + throw 'The Task 8 package-consuming tests require a fresh pack.' + } + $builtManifest = Import-PowerShellDataFile -Path $builtManifestPath + $prerelease = [string] $builtManifest.PrivateData.PSData.Prerelease + if ([string]::IsNullOrWhiteSpace($prerelease)) { + throw 'The Task 8 candidate must be a full prerelease build.' + } + $fullVersion = "$baseVersion-$prerelease" + $packagePath = Join-Path $script:repoRoot "output/GraphKit.$fullVersion.nupkg" + if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { + throw "The freshly packed Task 8 candidate '$fullVersion' is missing." + } + [pscustomobject]@{ + PackagePath = $packagePath + PackageSha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + FullVersion = $fullVersion + } + } + + function Invoke-Task8RunnerProcess { + param( + [Parameter(Mandatory)][string] $PackagePath, + [Parameter(Mandatory)][string] $PackageSha256, + [Parameter(Mandatory)][string] $AuthMode, + [switch] $DryRun, + [string] $ProfileId, + [string] $StorePath, + [string] $HookKind = 'None', + [string] $MutationValue = '', + [switch] $OrdinaryExecution, + [ValidateRange(1, 2)] [int] $Repeat = 1 + ) + + $nonce = [guid]::NewGuid().ToString('N') + $wrapperPath = Join-Path $TestDrive "task8-wrapper-$nonce.ps1" + $tracePath = Join-Path $TestDrive "task8-trace-$nonce.jsonl" + $grandchildPath = Join-Path $TestDrive "task8-grandchild-$nonce.ps1" + [IO.File]::WriteAllText($grandchildPath, @' +param( + [Parameter(Mandatory)][string] $HeldPath, + [Parameter(Mandatory)][string] $ReadyPath, + [Parameter(Mandatory)][string] $EscapeSessionText +) +$ErrorActionPreference = 'Stop' +$escapedSession = $false +if ($EscapeSessionText -ceq 'true' -and -not $IsWindows) { + Add-Type -TypeDefinition @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class GraphKitTask8EscapedSessionFixture +{ + public static void Enter() + { + int pid = Environment.ProcessId; + int session = setsid(); + if (session < 0) throw new Win32Exception(Marshal.GetLastPInvokeError()); + if (session != pid || getpgid(0) != pid || getsid(0) != pid) + throw new InvalidOperationException("Fixture session escape failed."); + } + [DllImport("libc", SetLastError = true)] private static extern int setsid(); + [DllImport("libc", SetLastError = true)] private static extern int getpgid(int pid); + [DllImport("libc", SetLastError = true)] private static extern int getsid(int pid); +} +"@ + [GraphKitTask8EscapedSessionFixture]::Enter() + $escapedSession = $true +} +$held = [IO.FileStream]::new( + $HeldPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) +try { + $process = [Diagnostics.Process]::GetCurrentProcess() + $readyRecord = [ordered]@{ + processId = [Environment]::ProcessId + startTimeUtcTicks = $process.StartTime.ToUniversalTime().Ticks + heldPath = $HeldPath + escapedSession = $escapedSession + } | ConvertTo-Json -Compress -Depth 3 + $readyTemporaryPath = $ReadyPath + '.' + [guid]::NewGuid().ToString('N') + '.tmp' + try { + [IO.File]::WriteAllText( + $readyTemporaryPath, + $readyRecord, + [Text.UTF8Encoding]::new($false)) + [IO.File]::Move($readyTemporaryPath, $ReadyPath) + } + finally { + if ([IO.File]::Exists($readyTemporaryPath)) { + [IO.File]::Delete($readyTemporaryPath) + } + } + Start-Sleep -Seconds 30 +} +finally { $held.Dispose() } +'@, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($wrapperPath, @' +param( + [Parameter(Mandatory)][string] $RunnerPath, + [Parameter(Mandatory)][string] $PackagePath, + [Parameter(Mandatory)][string] $PackageSha256, + [Parameter(Mandatory)][string] $AuthMode, + [Parameter(Mandatory)][string] $UseDryRunText, + [string] $ProfileId, + [string] $StorePath, + [Parameter(Mandatory)][string] $HookKind, + [string] $MutationValue, + [Parameter(Mandatory)][string] $OrdinaryExecutionText, + [Parameter(Mandatory)][int] $RepeatCount, + [Parameter(Mandatory)][string] $TracePath, + [Parameter(Mandatory)][string] $GrandchildPath, + [string] $WorkerPath = '', + [string] $InternalWorkerText = 'false' +) +$ErrorActionPreference = 'Stop' +$UseDryRun = $UseDryRunText -ceq 'true' +$UseOrdinaryExecution = $OrdinaryExecutionText -ceq 'true' +$UseInternalWorker = $InternalWorkerText -ceq 'true' +$fixturePackagePath = $PackagePath +$fixturePackageSha256 = $PackageSha256 +$fixtureAuthMode = $AuthMode +$fixtureProfileId = $ProfileId +$fixtureStorePath = $StorePath + +function Write-Task8Trace { + param([Parameter(Mandatory)][string] $Event, [hashtable] $Data = @{}) + $line = [ordered]@{ event = $Event; data = $Data } | ConvertTo-Json -Compress -Depth 4 + [IO.File]::AppendAllText($TracePath, $line + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) +} + +function Import-Task8FixtureContractsFromPackage { + param([Parameter(Mandatory)][string] $Path) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $stream = [IO.FileStream]::new( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, [IO.Compression.ZipArchiveMode]::Read, $false) + try { + $entries = @($archive.Entries | Where-Object { + $_.FullName -ceq 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + }) + if ($entries.Count -ne 1 -or $entries[0].Length -le 0 -or + $entries[0].Length -gt 16MB) { + throw 'The exact package contracts fixture entry was rejected.' + } + $entryStream = $entries[0].Open() + $memory = [IO.MemoryStream]::new() + try { + $entryStream.CopyTo($memory) + return [Reflection.Assembly]::Load($memory.ToArray()) + } + finally { + $memory.Dispose() + $entryStream.Dispose() + } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } +} + +function Set-Task8FixtureOwnerWritable { + param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][bool] $Directory) + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = if ($Directory) { + [Security.AccessControl.DirectorySecurity]::new() + } + else { + [Security.AccessControl.FileSecurity]::new() + } + $acl.SetAccessRuleProtection($true, $false) + $inheritance = if ($Directory) { + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' + } + else { [Security.AccessControl.InheritanceFlags]::None } + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + if ($Directory) { + [IO.FileSystemAclExtensions]::SetAccessControl( + [IO.DirectoryInfo]::new($Path), $acl) + } + else { + [IO.FileSystemAclExtensions]::SetAccessControl( + [IO.FileInfo]::new($Path), $acl) + $attributes = [IO.File]::GetAttributes($Path) + if (($attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) { + $writableAttributes = [IO.FileAttributes]( + [int]$attributes -band (-bnot [int][IO.FileAttributes]::ReadOnly)) + [IO.File]::SetAttributes( + $Path, + $(if ([int]$writableAttributes -eq 0) { + [IO.FileAttributes]::Normal + } + else { $writableAttributes })) + } + } + } + else { + [IO.File]::SetUnixFileMode( + $Path, + $(if ($Directory) { + [IO.UnixFileMode]'UserRead,UserWrite,UserExecute' + } + else { [IO.UnixFileMode]'UserRead,UserWrite' })) + } +} + +function Move-Task8FixtureDirectoryIdentityPreservingChildren { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $OutsidePath + ) + [IO.Directory]::Move($Path, $OutsidePath) + $null = [IO.Directory]::CreateDirectory($Path) + foreach ($child in @([IO.Directory]::EnumerateFileSystemEntries($OutsidePath))) { + $destination = Join-Path $Path ([IO.Path]::GetFileName($child)) + if ([IO.Directory]::Exists($child)) { + $permission = if ($IsWindows) { + Get-Acl -LiteralPath $child + } + else { [IO.File]::GetUnixFileMode($child) } + Set-Task8FixtureOwnerWritable -Path $child -Directory $true + [IO.Directory]::Move($child, $destination) + if ($IsWindows) { + Set-Acl -LiteralPath $destination -AclObject $permission + } + else { [IO.File]::SetUnixFileMode($destination, $permission) } + } + else { + [IO.File]::Move($child, $destination) + } + } +} + +Add-Type -TypeDefinition @" +using System; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; + +public static class GraphKitTask8HardLinkFixture +{ + private const int AtFdcwd = -100; + + public static void Create(string linkPath, string existingPath) + { + string link = Path.GetFullPath(linkPath); + string target = Path.GetFullPath(existingPath); + if (OperatingSystem.IsWindows()) + { + if (!CreateHardLinkW(ToExtendedWindowsPath(link), ToExtendedWindowsPath(target), IntPtr.Zero)) + { + throw new IOException($"Native fixture hard-link creation failed (Win32 {Marshal.GetLastWin32Error()})."); + } + return; + } + if (linkat(AtFdcwd, target, AtFdcwd, link, 0) != 0) + { + throw new IOException($"Native fixture hard-link creation failed (errno {Marshal.GetLastWin32Error()})."); + } + } + + private static string ToExtendedWindowsPath(string path) + { + if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) return path; + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + return @"\\?\UNC\" + path.Substring(2); + return @"\\?\" + path; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateHardLinkW( + string fileName, + string existingFileName, + IntPtr securityAttributes); + + [DllImport("libc", SetLastError = true)] + private static extern int linkat( + int oldDirectory, + string oldPath, + int newDirectory, + string newPath, + int flags); +} + +public class GraphKitTask8TokenSourceProxy : DispatchProxy +{ + public string AuthModeValue { get; set; } = "Certificate"; + public bool CanRefreshValue { get; set; } = true; + public string VerifiedTenantIdValue { get; set; } = "00000000-0000-0000-0000-000000000111"; + public string ClientIdValue { get; set; } = "00000000-0000-0000-0000-000000000333"; + public string CredentialGenerationValue { get; set; } = "task8-fixture-generation"; + public string TokenFingerprint { get; set; } = "task8-fixture-token-fingerprint"; + public object TokenResultValue { get; set; } + private int _acquireCallCount; + + public int AcquireCallCount { get { return Volatile.Read(ref _acquireCallCount); } } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + switch (targetMethod.Name) + { + case "get_AuthMode": return AuthModeValue; + case "get_CanRefresh": return CanRefreshValue; + case "get_VerifiedTenantId": return VerifiedTenantIdValue; + case "get_Audience": return "https://graph.microsoft.com/"; + case "get_ClientId": return ClientIdValue; + case "get_CredentialGeneration": return CredentialGenerationValue; + case "get_ExpiresOn": return DateTimeOffset.UtcNow.AddMinutes(5); + case "Acquire": + Interlocked.Increment(ref _acquireCallCount); + return TokenResultValue ?? throw new InvalidOperationException( + "Task 8 proxy acquisition was not configured."); + case "AdoptSharedResult": return null; + case "Dispose": return null; + default: throw new InvalidOperationException("Task 8 proxy method was not expected."); + } + } +} +"@ + +function New-Task8FixtureHardLink { + param( + [Parameter(Mandatory)] $State, + [Parameter(Mandatory)][string] $RelativePath, + [Parameter(Mandatory)][string] $LinkPath + ) + $targetPath = Join-Path $State.RootPath ( + $RelativePath -replace '/', [IO.Path]::DirectorySeparatorChar) + $evidenceType = $State.RootEvidence.GetType() + $nativeType = $evidenceType.Assembly.GetType( + $evidenceType.Namespace + '.GraphKitAuthStageCapture', $true, $false) + $failures = [Collections.Generic.List[Exception]]::new() + $sourceDirectory = $null + $linkCreated = $false + $targetWritableTransitionAttempted = $false + if ($IsWindows) { + $sourceDirectory = [IO.Path]::GetDirectoryName($targetPath) + try { + Set-Task8FixtureOwnerWritable -Path $sourceDirectory -Directory $true + } + catch { $failures.Add($_.Exception) | Out-Null } + if ($failures.Count -eq 0) { + $targetWritableTransitionAttempted = $true + try { + Set-Task8FixtureOwnerWritable -Path $targetPath -Directory $false + } + catch { $failures.Add($_.Exception) | Out-Null } + } + if ($failures.Count -eq 0) { + try { + [GraphKitTask8HardLinkFixture]::Create($LinkPath, $targetPath) + $linkCreated = $true + } + catch { $failures.Add($_.Exception) | Out-Null } + } + if ($targetWritableTransitionAttempted) { + try { $nativeType::SetOwnerOnly($targetPath, $false, $false) } + catch { $failures.Add($_.Exception) | Out-Null } + } + try { + $nativeType::SetOwnerOnly($sourceDirectory, $true, $false) + } + catch { $failures.Add($_.Exception) | Out-Null } + } + else { + try { + [GraphKitTask8HardLinkFixture]::Create($LinkPath, $targetPath) + $linkCreated = $true + } + catch { $failures.Add($_.Exception) | Out-Null } + } + if ($failures.Count -eq 0) { + try { + $linked = $nativeType::InspectFile($State.RootPath, $RelativePath) + if ([long]$linked.LinkCount -ne 2) { + throw 'The native fixture did not establish an exact two-link file.' + } + } + catch { $failures.Add($_.Exception) | Out-Null } + } + if ($failures.Count -gt 0) { + if ($linkCreated) { + if ($IsWindows) { + try { + Set-Task8FixtureOwnerWritable -Path $targetPath -Directory $false + } + catch { $failures.Add($_.Exception) | Out-Null } + } + try { [IO.File]::Delete($LinkPath) } + catch { $failures.Add($_.Exception) | Out-Null } + } + if ($IsWindows -and $targetWritableTransitionAttempted) { + try { $nativeType::SetOwnerOnly($targetPath, $false, $false) } + catch { $failures.Add($_.Exception) | Out-Null } + } + if ($IsWindows -and $null -ne $sourceDirectory) { + try { $nativeType::SetOwnerOnly($sourceDirectory, $true, $false) } + catch { $failures.Add($_.Exception) | Out-Null } + } + throw [AggregateException]::new( + 'The native hard-link fixture failed; bounded cleanup was attempted.', + $failures.ToArray()) + } +} + +function New-Task8SourceProxy { + param( + [Parameter(Mandatory)][string] $Mode, + [Parameter(Mandatory)][bool] $CanRefresh, + [string] $VerifiedTenantId = '00000000-0000-0000-0000-000000000111', + [AllowNull()][string] $ClientId = '00000000-0000-0000-0000-000000000333', + [string] $CredentialGeneration = 'task8-fixture-generation', + [string] $TokenFingerprint = 'task8-fixture-token-fingerprint', + [AllowNull()] $TokenResult + ) + $interface = [AppDomain]::CurrentDomain.GetAssemblies() | + ForEach-Object { $_.GetType('GraphKit.Auth.IGraphTokenSource', $false, $false) } | + Where-Object { $null -ne $_ } | + Select-Object -First 1 + if ($null -eq $interface) { throw 'Task 8 fixture could not find the loaded token-source interface.' } + $create = [Reflection.DispatchProxy].GetMethods([Reflection.BindingFlags]'Public,Static') | + Where-Object { $_.Name -ceq 'Create' -and $_.IsGenericMethodDefinition } | + Select-Object -First 1 + $source = $create.MakeGenericMethod($interface, [GraphKitTask8TokenSourceProxy]).Invoke($null, @()) + $control = [GraphKitTask8TokenSourceProxy] $source + $control.AuthModeValue = $Mode + $control.CanRefreshValue = $CanRefresh + $control.VerifiedTenantIdValue = $VerifiedTenantId + $control.ClientIdValue = $ClientId + $control.CredentialGenerationValue = $CredentialGeneration + $control.TokenFingerprint = $TokenFingerprint + $control.TokenResultValue = $TokenResult + return $source +} + +$hooks = [ordered]@{ + ContractMarker = 'GraphKit.Task8.ParityTestHooks/1' + TracePath = $TracePath + PackageLiveHolderKey = $null + CleanupOriginalBytes = $null + PreSealMutationDone = $false + CleanupContainerMutationDone = $false +} + +$hooks.AfterRootCreated = { + param($state) + Write-Task8Trace -Event 'root-created' -Data @{ root = [string] $state.RootPath } +}.GetNewClosure() +$hooks.AfterSnapshot = { + param($state) + Write-Task8Trace -Event 'snapshot-created' -Data @{ snapshot = [string] $state.SnapshotPath } +}.GetNewClosure() +$hooks.AfterArchivePlan = { + param($state, $plan) + Write-Task8Trace -Event 'archive-plan-created' -Data @{ + recordCount = @($plan.Records).Count + } +}.GetNewClosure() +$hooks.AfterExtraction = { + param($state) + Write-Task8Trace -Event 'extraction-created' -Data @{ moduleRoot = [string] $state.ModuleRoot } +}.GetNewClosure() +$hooks.AfterImport = { + param($state) + Write-Task8Trace -Event 'imported' -Data @{ + processId = [Environment]::ProcessId + manifestPath = [string] $state.ImportedManifestPath + modulePath = [string] $state.ImportedModulePath + moduleVersion = [string] $state.ModuleVersion + } +}.GetNewClosure() +$hooks.BeforeCleanup = { + param($state) + Write-Task8Trace -Event 'cleanup-started' -Data @{ + processId = [Environment]::ProcessId + root = [string] $state.RootPath + } +}.GetNewClosure() +$workerWrapperPath = [IO.Path]::GetFullPath($PSCommandPath) +$hooks.ConfigureWorkerStartInfo = { + param($startInfo, $workerPath) + $startInfo.ArgumentList.Clear() + foreach ($argument in @( + '-NoLogo','-NoProfile','-NonInteractive','-File',$workerWrapperPath, + '-RunnerPath',$RunnerPath, + '-PackagePath','unused.nupkg', + '-PackageSha256',('0' * 64), + '-AuthMode','Certificate', + '-UseDryRunText','true', + '-ProfileId','', + '-StorePath','', + '-HookKind',$(if ($HookKind -cin @( + 'PackageLiveSuccess','WorkerExtraBlankFrame','WorkerBomFrame', + 'WorkerSecondFrame','WorkerMissingTerminator','WorkerEmptyFrame', + 'WorkerInvalidUtf8','WorkerStderr','WorkerStdoutOverflow', + 'WorkerStderrOverflow','WorkerNonzeroExit','WorkerNoRead', + 'WorkerGrandchild','WorkerSessionEscape')) { + $HookKind + } else { 'None' }), + '-MutationValue','', + '-OrdinaryExecutionText','false', + '-RepeatCount','1', + '-TracePath',$TracePath, + '-GrandchildPath',$GrandchildPath, + '-WorkerPath',$workerPath, + '-InternalWorkerText','true' + )) { + $null = $startInfo.ArgumentList.Add([string]$argument) + } +}.GetNewClosure() +$hooks.AfterWorkerExit = { + param($state, $workerProcessId, $workerRun) + Write-Task8Trace -Event 'worker-exited' -Data @{ + processId = [Environment]::ProcessId + workerProcessId = [int]$workerProcessId + root = [string]$state.RootPath + forcedTermination = [bool]$workerRun.ForcedTermination + protocolValid = [bool]$workerRun.ProtocolValid + workerState = $(if ($null -eq $workerRun.Result) { '' } else { + [string]$workerRun.Result.state + }) + workerFailureStage = $(if ($null -eq $workerRun.Result) { '' } else { + [string]$workerRun.Result.failureStage + }) + protocolFailure = [string]$workerRun.ProtocolFailure + ownershipEstablished = [bool]$workerRun.OwnershipEstablished + requestReleased = [bool]$workerRun.RequestReleased + rootExitConfirmed = [bool]$workerRun.RootExitConfirmed + treeExitConfirmed = [bool]$workerRun.TreeExitConfirmed + streamsDrained = [bool]$workerRun.StreamsDrained + elapsedMilliseconds = [long]$workerRun.ElapsedMilliseconds + operationDeadlineMilliseconds = [long]$workerRun.OperationDeadlineMilliseconds + hardDeadlineMilliseconds = [long]$workerRun.HardDeadlineMilliseconds + } +}.GetNewClosure() +$hooks.AfterWorkerRootExit = { + param($metadata) + Write-Task8Trace -Event 'worker-root-exited' -Data @{ + workerProcessId = [int]$metadata.WorkerProcessId + ownershipEstablished = [bool]$metadata.OwnershipEstablished + requestReleased = [bool]$metadata.RequestReleased + } +}.GetNewClosure() +$hooks.BeforeWorkerTreeTermination = { + param($metadata) + Write-Task8Trace -Event 'worker-tree-termination-requested' -Data @{ + workerProcessId = [int]$metadata.WorkerProcessId + rootExitConfirmed = [bool]$metadata.RootExitConfirmed + residualTreeDetected = [bool]$metadata.ResidualTreeDetected + } +}.GetNewClosure() +$hooks.AfterWorkerTreeExit = { + param($metadata) + Write-Task8Trace -Event 'worker-tree-exit-confirmed' -Data @{ + workerProcessId = [int]$metadata.WorkerProcessId + terminationRequested = [bool]$metadata.TerminationRequested + residualTreeDetected = [bool]$metadata.ResidualTreeDetected + streamsDrained = [bool]$metadata.StreamsDrained + } +}.GetNewClosure() +$hooks.AfterWorkerProcessFailure = { + param($failurePoint) + Write-Task8Trace -Event 'worker-process-failure' -Data @{ + failurePoint = [string]$failurePoint + } +}.GetNewClosure() + +if ($HookKind -ceq 'PostStartSetupFailure') { + $hooks.AfterWorkerStarted = { + param($workerProcess) + Write-Task8Trace -Event 'worker-setup-started' -Data @{ + processId = [int]$workerProcess.Id + } + throw 'The injected post-start collector setup failed.' + }.GetNewClosure() +} +if ($HookKind -cin @('WorkerNoRead','WorkerPermanentPollFailure')) { + $hooks.SelectWorkerTimeoutSeconds = { param($defaultSeconds) [int]3 } +} +if ($HookKind -ceq 'WorkerSessionEscape') { + # The child receives a separate five-second readiness bound only after this + # worker has bootstrapped and imported the candidate. Keep the collector's + # enclosing deadline strictly larger so the parent cannot terminate the + # original group after setsid but before readiness is published. + $hooks.SelectWorkerTimeoutSeconds = { param($defaultSeconds) [int]15 } +} +if ($HookKind -ceq 'WorkerPermanentPollFailure') { + $hooks.BeforeWorkerLifecyclePoll = { + param($metadata) + throw 'The injected lifecycle poll failed permanently.' + }.GetNewClosure() +} +if ($HookKind -ceq 'WorkerPathMismatch') { + $hooks.MutateWorkerRequest = { + param($request) + $request.state.moduleRoot = Join-Path $request.state.rootPath 'different-module' + return $request + }.GetNewClosure() +} +if ($HookKind -ceq 'WorkerRequestVersionMismatch') { + $hooks.MutateWorkerRequest = { + param($request) + $request.moduleVersion = '0.4.0-r8.other' + return $request + }.GetNewClosure() +} +if ($HookKind -ceq 'WorkerRequestTrailingLf') { + $hooks.MutateWorkerRequestJson = { + param($json) + return [string]$json + "`n" + }.GetNewClosure() +} +if ($HookKind -ceq 'WorkerRequestBom') { + $hooks.MutateWorkerRequestJson = { + param($json) + return [string][char]0xFEFF + [string]$json + }.GetNewClosure() +} +if ($HookKind -ceq 'StreamSentinel') { + $hooks.MutateWorkerRequest = { + param($request) + [IO.File]::WriteAllText( + ($TracePath + '.worker-request.json'), + ($request | ConvertTo-Json -Compress -Depth 12), + [Text.UTF8Encoding]::new($false)) + return $request + }.GetNewClosure() +} + +$preloadedRoot = $null +$preloadedModule = $null +$script:task8PackageLiveHolderKey = $null +if ($HookKind -ceq 'PreloadedGraphKit') { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $preloadedRoot = Join-Path ([IO.Path]::GetTempPath()) ('graphkit-task8-preloaded-' + [guid]::NewGuid().ToString('N')) + [IO.Compression.ZipFile]::ExtractToDirectory($PackagePath, $preloadedRoot) + $preloadedModule = Import-Module (Join-Path $preloadedRoot 'GraphKit.psd1') -PassThru -Force -ErrorAction Stop + Write-Task8Trace -Event 'preloaded' -Data @{} +} + +switch ($HookKind) { + 'OversizedSource' { + $hooks.BeforeSourceMetadata = { + param($sourcePath) + Write-Task8Trace -Event 'source-metadata-started' -Data @{} + }.GetNewClosure() + $hooks.BeforeSourceHash = { + param($sourcePath) + Write-Task8Trace -Event 'source-hash-started' -Data @{} + throw 'The oversized source reached the forbidden hash boundary.' + }.GetNewClosure() + } + 'SnapshotCollision' { + $hooks.AfterRootCreated = { + param($state) + Write-Task8Trace -Event 'root-created' -Data @{ root = [string] $state.RootPath } + [IO.File]::WriteAllText((Join-Path $state.RootPath 'candidate.nupkg'), 'collision') + }.GetNewClosure() + } + 'SnapshotContentMutation' { + $hooks.AfterSnapshot = { + param($state) + Write-Task8Trace -Event 'snapshot-created' -Data @{ + snapshot = [string]$state.SnapshotPath + } + [IO.File]::WriteAllBytes( + $state.SnapshotPath, [IO.File]::ReadAllBytes($MutationValue)) + Write-Task8Trace -Event 'snapshot-mutated' -Data @{ + root = [string]$state.RootPath + replacement = [string]$MutationValue + } + }.GetNewClosure() + } + 'PreSealFileMutation' { + $hooks.BeforeSealFile = { + param($state, $relative) + if ($hooks.PreSealMutationDone -or + [string]$relative -cne 'module/GraphKit.psd1') { + return + } + $hooks.PreSealMutationDone = $true + $path = Join-Path $state.RootPath ( + [string]$relative -replace '/', [IO.Path]::DirectorySeparatorChar) + $laterFileExists = [IO.File]::Exists( + (Join-Path $state.RootPath 'module/GraphKit.psm1')) + [IO.File]::AppendAllText($path, '# pre-seal same-identity mutation') + Write-Task8Trace -Event 'preseal-mutated' -Data @{ + kind = $HookKind + relative = [string]$relative + laterFileExists = $laterFileExists + root = [string]$state.RootPath + outside = '' + } + }.GetNewClosure() + } + 'PreSealDirectoryReplacement' { + $hooks.BeforeSealDirectory = { + param($state, $relative) + if ($hooks.PreSealMutationDone -or [string]$relative -cne 'module') { + return + } + $hooks.PreSealMutationDone = $true + $path = Join-Path $state.RootPath 'module' + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-preseal-directory-' + [guid]::NewGuid().ToString('N')) + Move-Task8FixtureDirectoryIdentityPreservingChildren ` + -Path $path -OutsidePath $outside + Write-Task8Trace -Event 'preseal-mutated' -Data @{ + kind = $HookKind + relative = [string]$relative + root = [string]$state.RootPath + outside = [string]$outside + } + }.GetNewClosure() + } + 'PreSealRootReplacement' { + $hooks.BeforeSealRoot = { + param($state) + if ($hooks.PreSealMutationDone) { return } + $hooks.PreSealMutationDone = $true + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-preseal-root-' + [guid]::NewGuid().ToString('N')) + Move-Task8FixtureDirectoryIdentityPreservingChildren ` + -Path $state.RootPath -OutsidePath $outside + Write-Task8Trace -Event 'preseal-mutated' -Data @{ + kind = $HookKind + relative = '' + root = [string]$state.RootPath + outside = [string]$outside + } + }.GetNewClosure() + } + { $_ -cin @('OutsideSentinel','WorkerGrandchild','WorkerSessionEscape') } { + $hooks.AfterRootCreated = { + param($state) + Write-Task8Trace -Event 'root-created' -Data @{ root = [string] $state.RootPath } + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-outside-' + [guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllText($outside, 'outside-sentinel') + Write-Task8Trace -Event 'outside-created' -Data @{ path = $outside } + }.GetNewClosure() + } + { $_ -cin @('WorkerGrandchild','WorkerSessionEscape') } { + $hooks.AfterImport = { + param($state) + Write-Task8Trace -Event 'imported' -Data @{ + processId = [Environment]::ProcessId + manifestPath = [string] $state.ImportedManifestPath + modulePath = [string] $state.ImportedModulePath + moduleVersion = [string] $state.ModuleVersion + } + $heldPath = Join-Path $state.ModuleRoot ` + 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + $readyPath = $TracePath + '.grandchild-ready' + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = [Environment]::ProcessPath + $startInfo.UseShellExecute = $false + foreach ($argument in @( + '-NoLogo','-NoProfile','-NonInteractive','-File',$GrandchildPath, + '-HeldPath',$heldPath,'-ReadyPath',$readyPath, + '-EscapeSessionText',$(if ($HookKind -ceq 'WorkerSessionEscape') { + 'true' + } else { 'false' }))) { + $null = $startInfo.ArgumentList.Add([string]$argument) + } + $child = [Diagnostics.Process]::new() + $child.StartInfo = $startInfo + try { + if (-not $child.Start()) { + throw 'The Task 8 residual-tree fixture did not start.' + } + $deadline = [DateTime]::UtcNow.AddSeconds(5) + while (-not [IO.File]::Exists($readyPath) -and + [DateTime]::UtcNow -lt $deadline -and -not $child.HasExited) { + Start-Sleep -Milliseconds 10 + } + $ready = if ([IO.File]::Exists($readyPath)) { + [IO.File]::ReadAllText($readyPath) | + ConvertFrom-Json -ErrorAction Stop + } + else { $null } + $expectedEscape = $HookKind -ceq 'WorkerSessionEscape' + if ($null -eq $ready -or $child.HasExited -or + @($ready.PSObject.Properties).Count -ne 4 -or + (@($ready.PSObject.Properties.Name | Sort-Object) -join ',') -cne + 'escapedSession,heldPath,processId,startTimeUtcTicks' -or + $ready.processId.GetType() -ne [long] -or + [long]$ready.processId -ne [long]$child.Id -or + $ready.startTimeUtcTicks.GetType() -ne [long] -or + [long]$ready.startTimeUtcTicks -le 0 -or + $ready.heldPath.GetType() -ne [string] -or + [string]$ready.heldPath -cne $heldPath -or + $ready.escapedSession.GetType() -ne [bool] -or + [bool]$ready.escapedSession -ne $expectedEscape) { + try { + if (-not $child.HasExited) { $child.Kill($true) } + $null = $child.WaitForExit(5000) + } + catch {} + throw 'The Task 8 residual-tree fixture did not become ready.' + } + Write-Task8Trace -Event 'grandchild-ready' -Data @{ + processId = [long]$ready.processId + startTimeUtcTicks = [long]$ready.startTimeUtcTicks + heldPath = [string]$ready.heldPath + escapedSession = [bool]$ready.escapedSession + } + } + finally { $child.Dispose() } + }.GetNewClosure() + } + 'ExtractedMutation' { + $hooks.BeforeImport = { + param($state) + $path = Join-Path $state.ModuleRoot 'GraphKit.psm1' + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + [IO.File]::AppendAllText($path, '# mutation') + }.GetNewClosure() + } + 'ExtractedWritable' { + $hooks.BeforeImport = { + param($state) + $path = Join-Path $state.ModuleRoot 'GraphKit.psm1' + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + }.GetNewClosure() + } + { $_ -in @( + 'FinalImportContentMutation','FinalImportWritableMutation', + 'FinalImportClosureMutation','FinalImportHardLinkMutation') + } { + $hooks.BeforeFinalImportRecheck = { + param($state) + $path = Join-Path $state.ModuleRoot 'GraphKit.psm1' + $outside = $null + switch ($HookKind) { + 'FinalImportContentMutation' { + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + [IO.File]::AppendAllText($path, '# final import content mutation') + } + 'FinalImportWritableMutation' { + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + } + 'FinalImportClosureMutation' { + Set-Task8FixtureOwnerWritable -Path $state.ModuleRoot -Directory $true + [IO.File]::WriteAllText( + (Join-Path $state.ModuleRoot 'task8-unexpected.ps1'), 'unexpected') + } + 'FinalImportHardLinkMutation' { + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-link-target-' + [guid]::NewGuid().ToString('N')) + New-Task8FixtureHardLink -State $state ` + -RelativePath 'module/GraphKit.psm1' -LinkPath $outside + Write-Task8Trace -Event 'mutation-outside-created' -Data @{ path = $outside } + } + } + Write-Task8Trace -Event 'final-import-mutated' -Data @{ + kind = $HookKind + outside = [string] $outside + } + }.GetNewClosure() + } + { $_ -in @( + 'CleanupContentMutation','CleanupWritableMutation', + 'CleanupClosureMutation','CleanupHardLinkMutation') + } { + $hooks.BeforeCleanup = { + param($state) + $path = Join-Path $state.ModuleRoot 'GraphKit.psm1' + $outside = $null + switch ($HookKind) { + 'CleanupContentMutation' { + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + [IO.File]::AppendAllText($path, '# cleanup content mutation') + } + 'CleanupWritableMutation' { + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + } + 'CleanupClosureMutation' { + Set-Task8FixtureOwnerWritable -Path $state.ModuleRoot -Directory $true + [IO.File]::WriteAllText( + (Join-Path $state.ModuleRoot 'task8-unexpected.ps1'), 'unexpected') + } + 'CleanupHardLinkMutation' { + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-link-target-' + [guid]::NewGuid().ToString('N')) + New-Task8FixtureHardLink -State $state ` + -RelativePath 'module/GraphKit.psm1' -LinkPath $outside + Write-Task8Trace -Event 'mutation-outside-created' -Data @{ path = $outside } + } + } + Write-Task8Trace -Event 'cleanup-mutated' -Data @{ + kind = $HookKind + outside = [string] $outside + } + }.GetNewClosure() + } + { $_ -in @('CleanupFileContentMutationBefore','CleanupFileContentMutationAfter') } { + $hooks.OnCleanupFile = { + param($state, $relative, $phase, $native) + if ([string]$relative -cne 'module/GraphKit.psm1') { + return + } + $path = Join-Path $state.RootPath ( + [string]$relative -replace '/', [IO.Path]::DirectorySeparatorChar) + if ($HookKind -ceq 'CleanupFileContentMutationBefore' -and + [string]$phase -ceq 'AfterWritable') { + if ($null -eq $hooks.CleanupOriginalBytes) { + throw 'The cleanup fixture lost its exact original bytes.' + } + [IO.File]::WriteAllBytes($path, [byte[]]$hooks.CleanupOriginalBytes) + $hooks.CleanupOriginalBytes = $null + Write-Task8Trace -Event 'cleanup-file-restored' -Data @{ + relative = [string]$relative + phase = [string]$phase + root = [string]$state.RootPath + } + return + } + $expectedPhase = if ($HookKind -ceq 'CleanupFileContentMutationBefore') { + 'BeforeWritable' + } + else { 'AfterWritable' } + if ([string]$phase -cne $expectedPhase) { return } + if ($phase -ceq 'BeforeWritable') { + $hooks.CleanupOriginalBytes = [IO.File]::ReadAllBytes($path) + $native::SetOwnerOnly($path, $false, $true) + } + [IO.File]::AppendAllText($path, '# same-identity cleanup mutation') + if ($phase -ceq 'BeforeWritable') { + $native::SetOwnerOnly($path, $false, $false) + } + Write-Task8Trace -Event 'cleanup-file-mutated' -Data @{ + relative = [string]$relative + phase = [string]$phase + root = [string]$state.RootPath + } + }.GetNewClosure() + } + { $_ -in @( + 'CleanupDirectoryReplacementAfterWritable', + 'CleanupDirectoryReplacementBeforeDelete' + ) } { + $hooks.OnCleanupDirectory = { + param($state, $relative, $phase, $native) + $expectedPhase = if ($HookKind -ceq 'CleanupDirectoryReplacementBeforeDelete') { + 'BeforeDelete' + } + else { 'AfterWritable' } + if ($hooks.CleanupContainerMutationDone -or + [string]$relative -cne 'module' -or + [string]$phase -cne $expectedPhase) { + return + } + $hooks.CleanupContainerMutationDone = $true + $path = Join-Path $state.RootPath 'module' + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-cleanup-directory-' + [guid]::NewGuid().ToString('N')) + Move-Task8FixtureDirectoryIdentityPreservingChildren ` + -Path $path -OutsidePath $outside + Write-Task8Trace -Event 'cleanup-container-mutated' -Data @{ + kind = $HookKind + relative = [string]$relative + phase = [string]$phase + root = [string]$state.RootPath + outside = [string]$outside + } + }.GetNewClosure() + } + { $_ -in @( + 'CleanupRootReplacementAfterWritable', + 'CleanupRootReplacementBeforeDelete' + ) } { + $hooks.OnCleanupRoot = { + param($state, $phase, $native) + $expectedPhase = if ($HookKind -ceq 'CleanupRootReplacementBeforeDelete') { + 'BeforeDelete' + } + else { 'AfterWritable' } + if ($hooks.CleanupContainerMutationDone -or + [string]$phase -cne $expectedPhase) { + return + } + $hooks.CleanupContainerMutationDone = $true + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-cleanup-root-' + [guid]::NewGuid().ToString('N')) + Move-Task8FixtureDirectoryIdentityPreservingChildren ` + -Path $state.RootPath -OutsidePath $outside + Write-Task8Trace -Event 'cleanup-container-mutated' -Data @{ + kind = $HookKind + relative = '' + phase = [string]$phase + root = [string]$state.RootPath + outside = [string]$outside + } + }.GetNewClosure() + } + 'ExtractedFileReplacement' { + $hooks.BeforeImport = { + param($state) + $path = Join-Path $state.ModuleRoot 'GraphKit.psm1' + $bytes = [IO.File]::ReadAllBytes($path) + Set-Task8FixtureOwnerWritable -Path $state.ModuleRoot -Directory $true + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + [IO.File]::Delete($path) + [IO.File]::WriteAllBytes($path, $bytes) + Write-Task8Trace -Event 'file-replaced' -Data @{} + }.GetNewClosure() + } + 'ExtractedHardLink' { + $hooks.BeforeImport = { + param($state) + $path = Join-Path $state.ModuleRoot 'GraphKit.psm1' + $outside = Join-Path (Split-Path $state.RootPath -Parent) ( + 'graphkit-task8-link-target-' + [guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllBytes($outside, [IO.File]::ReadAllBytes($path)) + Set-Task8FixtureOwnerWritable -Path $state.ModuleRoot -Directory $true + Set-Task8FixtureOwnerWritable -Path $path -Directory $false + [IO.File]::Delete($path) + $null = New-Item -ItemType HardLink -Path $path -Target $outside -ErrorAction Stop + Write-Task8Trace -Event 'link-substituted' -Data @{ outside = $outside } + }.GetNewClosure() + } + 'ModuleDirectoryReplacement' { + $hooks.BeforeImport = { + param($state) + $backup = $state.ModuleRoot + '.original' + Set-Task8FixtureOwnerWritable -Path $state.RootPath -Directory $true + Set-Task8FixtureOwnerWritable -Path $state.ModuleRoot -Directory $true + [IO.Directory]::Move($state.ModuleRoot, $backup) + [IO.Directory]::CreateDirectory($state.ModuleRoot) | Out-Null + Write-Task8Trace -Event 'module-directory-replaced' -Data @{ backup = $backup } + }.GetNewClosure() + } + 'RootReplacement' { + $hooks.BeforeImport = { + param($state) + $backup = $state.RootPath + '.original' + Set-Task8FixtureOwnerWritable -Path $state.RootPath -Directory $true + [IO.Directory]::Move($state.RootPath, $backup) + [IO.Directory]::CreateDirectory($state.RootPath) | Out-Null + Write-Task8Trace -Event 'root-replaced' -Data @{ backup = $backup; replacement = $state.RootPath } + }.GetNewClosure() + } + 'ExternalSeams' { + foreach ($name in @( + 'Get-GraphContext','Get-GraphObject','Invoke-GraphOperation', + 'Get-Secret','Get-SecretInfo','Get-SecretVault','Set-Secret','Remove-Secret', + 'Test-SecretVault','Unlock-SecretVault','Register-SecretVault','Unregister-SecretVault', + 'Invoke-RestMethod','Invoke-WebRequest','Connect-MgGraph','Invoke-MgGraphRequest', + 'New-MgApplication','Update-MgApplication','Remove-MgApplication', + 'Add-MgApplicationKey','Remove-MgApplicationKey', + 'Add-MgApplicationPassword','Remove-MgApplicationPassword', + 'New-MgServicePrincipal','Update-MgServicePrincipal','Remove-MgServicePrincipal', + 'Add-MgServicePrincipalKey','Remove-MgServicePrincipalKey', + 'Add-MgServicePrincipalPassword','Remove-MgServicePrincipalPassword', + 'New-MgServicePrincipalAppRoleAssignment','Remove-MgServicePrincipalAppRoleAssignment', + 'New-MgServicePrincipalAppRoleAssignedTo','Remove-MgServicePrincipalAppRoleAssignedTo', + 'New-MgOauth2PermissionGrant','Update-MgOauth2PermissionGrant', + 'Remove-MgOauth2PermissionGrant', + 'Register-GraphTenant','Remove-GraphTenant','Install-PSResource','Install-Module', + 'Save-Module','Register-PSRepository','Connect-AzAccount','New-AzResourceGroup', + 'Remove-AzResourceGroup','New-AzUserAssignedIdentity','Remove-AzUserAssignedIdentity', + 'New-AzContainerGroup','Remove-AzContainerGroup','az' + )) { + Set-Item -Path "function:global:$name" -Value { + Write-Task8Trace -Event 'forbidden-seam' + throw 'task8-secret-sentinel' + }.GetNewClosure() + } + } + 'PackageLiveSuccess' { + $hooks.PrepareLiveModule = { + param($module, $state, $route, $requestedProfileId, $requestedStorePath, $storePathBound) + $tenantId = [guid] '00000000-0000-0000-0000-000000000111' + $clientId = [guid] '00000000-0000-0000-0000-000000000333' + $generation = 'task8-fixture-generation' + $fingerprint = 'task8-fixture-token-fingerprint' + $source = New-Task8SourceProxy -Mode ([string]$route.AuthMode) ` + -CanRefresh ([bool]$route.CanRefresh) -VerifiedTenantId $tenantId.ToString('D') ` + -ClientId $clientId.ToString('D') -CredentialGeneration $generation ` + -TokenFingerprint $fingerprint + $profile = @{ + ProfileId = $requestedProfileId + Name = 'Task 8 Fixture' + Kind = 'lab' + TenantId = $tenantId.ToString('D') + Environment = 'Global' + AuthMethod = 'Certificate' + ClientId = $clientId.ToString('D') + Credential = @{ VaultName = 'fixture'; CertificateName = 'fixture'; Version = 'v1' } + } + $holderKey = 'GraphKit.Task8.PackageLive/' + [guid]::NewGuid().ToString('N') + $holder = [pscustomobject]@{ + Source = $source + Profile = $profile + TenantId = $tenantId + Generation = $generation + Fingerprint = $fingerprint + Events = [Collections.Concurrent.ConcurrentQueue[object]]::new() + } + [AppDomain]::CurrentDomain.SetData($holderKey, $holder) + $hooks.PackageLiveHolderKey = $holderKey + Write-Task8Trace -Event 'prepare-live-module' -Data @{ + storePathBound = [bool]$storePathBound + } + & $module { + param($key) + $script:Task8PackageLiveHolderKey = $key + Set-Item -Path Function:Get-GraphProfileStore -Value { + param([string] $StorePath) + $holder = [AppDomain]::CurrentDomain.GetData( + $script:Task8PackageLiveHolderKey) + $holder.Events.Enqueue([pscustomobject]@{ + Kind = 'context-command' + StorePath = [string]$StorePath + }) + return [pscustomobject]@{ Profiles = @($holder.Profile) } + } + Set-Item -Path Function:New-GraphTokenSource -Value { + param($Profile, $Cloud, $MsalFactory) + $holder = [AppDomain]::CurrentDomain.GetData( + $script:Task8PackageLiveHolderKey) + $holder.Events.Enqueue([pscustomobject]@{ + Kind = 'source-created' + AuthMethod = [string]$Profile.AuthMethod + }) + return $holder.Source + } + Set-Item -Path Function:Invoke-GraphPaging -Value { + param( + $Context, $Descriptor, $FirstPageUri, $RequestFactoryScript, + $TransportScript, $MaxPages, $CancellationToken, $DeadlineSeconds, $UtcNow + ) + $holder = [AppDomain]::CurrentDomain.GetData( + $script:Task8PackageLiveHolderKey) + $holder.Events.Enqueue([pscustomobject]@{ + Kind = 'read-command' + Type = [string]$Descriptor.Type + Operation = [string]$Descriptor.Operation + MaxPages = [int]$MaxPages + FirstPageAuthority = [string]$FirstPageUri.Authority + }) + return [pscustomobject]@{ + PSTypeName = 'GraphKit.OperationResult' + Outcome = 'Succeeded' + Certainty = 'Known' + Truncated = $false + PageCount = 1 + Data = @( + [pscustomobject]@{ id = 'task8-package-row-1' } + [pscustomobject]@{ id = 'task8-package-row-2' } + ) + Telemetry = @() + Provenance = @{ + IdentityState = 'VerifiedForToken' + TenantId = $holder.TenantId + ActualTenantId = $holder.TenantId + TokenFingerprint = [string]$holder.Fingerprint + CredentialGeneration = [string]$holder.Generation + Cloud = 'Global' + } + } + } + } $holderKey + }.GetNewClosure() + } + { $_ -like 'Live*' } { + $hooks.GetContext = { + param($requestedProfileId, $requestedStorePath, $route) + $mode = [string] $route.AuthMode + $refresh = $mode -cne 'BearerToken' + $contextIdentityState = 'NotAcquired' + $contextTenant = if ($HookKind -ceq 'LiveContextTenantEmpty') { + [guid]::Empty + } + else { [guid] '00000000-0000-0000-0000-000000000111' } + $contextClient = if ($mode -ceq 'BearerToken') { + $null + } + else { [guid] '00000000-0000-0000-0000-000000000333' } + $sourceClient = if ($mode -ceq 'BearerToken') { + $null + } + elseif ($HookKind -ceq 'LiveSourceClientMismatch') { + '00000000-0000-0000-0000-000000000444' + } + else { '00000000-0000-0000-0000-000000000333' } + $generation = if ($HookKind -ceq 'LiveSourceGenerationBlank') { + '' + } + else { 'task8-fixture-generation' } + $sourceFingerprint = if ($HookKind -ceq 'LiveFingerprintMismatch') { + 'task8-fixture-source-fingerprint-mismatch' + } + else { 'task8-fixture-token-fingerprint' } + Write-Task8Trace -Event 'context' -Data @{ + mode = $mode + identityState = $contextIdentityState + } + $source = if ($HookKind -ceq 'LiveInterfaceMismatch') { + [pscustomobject]@{ AuthMode = $mode; CanRefresh = $refresh } + } + else { + New-Task8SourceProxy -Mode $(if ($HookKind -ceq 'LiveModeMismatch') { 'Certificate' } else { $mode }) ` + -CanRefresh $(if ($HookKind -ceq 'LiveRefreshMismatch') { -not $refresh } else { $refresh }) ` + -VerifiedTenantId $(if ($HookKind -ceq 'LiveSourceTenantMismatch') { + '00000000-0000-0000-0000-000000000222' + } elseif ($HookKind -ceq 'LiveSourceTenantEmpty') { + '00000000-0000-0000-0000-000000000000' + } else { '00000000-0000-0000-0000-000000000111' }) ` + -ClientId $sourceClient -CredentialGeneration $generation ` + -TokenFingerprint $sourceFingerprint + } + return [pscustomobject]@{ + PSTypeName = 'GraphKit.Context' + ProfileId = $requestedProfileId + TenantId = $contextTenant + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com' + ClientId = $contextClient + TokenSource = $source + CredentialFingerprint = $(if ([string]::IsNullOrEmpty($generation)) { + '' + } else { + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($generation))).ToLowerInvariant() + }) + AcquisitionCacheKey = 'task8-fixture-acquisition-key' + IdentityState = $contextIdentityState + } + }.GetNewClosure() + $hooks.Read = { + param($context, $type, $operation, $passThruResult) + Write-Task8Trace -Event 'read' -Data @{ + type = [string] $type + operation = [string] $operation + passThruResult = [bool] $passThruResult + } + if ($HookKind -ceq 'LiveAcquisitionFailure') { + throw [GraphKit.Auth.GraphAuthException]::new( + 'task8_fixture_acquisition', 'Acquisition', 'task8-secret-sentinel', $null, $null) + } + $outcome = if ($HookKind -ceq 'LiveFailedEnvelope') { 'Failed' } else { 'Succeeded' } + $certainty = if ($HookKind -ceq 'LiveIndeterminate') { 'Indeterminate' } else { 'Known' } + $truncated = $HookKind -ceq 'LiveTruncated' + $verified = $HookKind -cne 'LiveUnverified' + $provenance = @{ + IdentityState = $(if ($verified) { 'VerifiedForToken' } else { 'NotAcquired' }) + TenantId = $(if (-not $verified) { $null } elseif ( + $HookKind -ceq 'LiveTargetTenantMismatch') { + [guid] '00000000-0000-0000-0000-000000000222' + } elseif ($HookKind -ceq 'LiveTargetTenantEmpty') { + [guid]::Empty + } else { $context.TenantId }) + ActualTenantId = $(if (-not $verified) { $null } elseif ( + $HookKind -ceq 'LiveActualTenantMismatch') { + [guid] '00000000-0000-0000-0000-000000000222' + } elseif ($HookKind -ceq 'LiveActualTenantEmpty') { + [guid]::Empty + } else { $context.TenantId }) + TokenFingerprint = $(if ($HookKind -ceq 'LiveFingerprintBlank') { + '' + } else { 'task8-fixture-token-fingerprint' }) + CredentialGeneration = $(if ($HookKind -ceq 'LiveGenerationBlank') { + '' + } elseif ($HookKind -ceq 'LiveGenerationMismatch') { + 'task8-fixture-generation-mismatch' + } else { 'task8-fixture-generation' }) + Cloud = $(if ($HookKind -ceq 'LiveCloudBlank') { + '' + } elseif ($HookKind -ceq 'LiveCloudMismatch') { + 'USGov' + } else { 'Global' }) + } + if ($HookKind -ceq 'LiveFingerprintMissing') { + $null = $provenance.Remove('TokenFingerprint') + } + if ($HookKind -ceq 'LiveGenerationMissing') { + $null = $provenance.Remove('CredentialGeneration') + } + if ($HookKind -ceq 'LiveCloudMissing') { + $null = $provenance.Remove('Cloud') + } + return [pscustomobject]@{ + PSTypeName = 'GraphKit.OperationResult' + Outcome = $outcome + Certainty = $certainty + Truncated = $truncated + Data = @( + [pscustomobject]@{ id = 'task8-row-secret-1'; displayName = 'task8-secret-sentinel' } + [pscustomobject]@{ id = 'task8-row-secret-2'; displayName = 'task8-secret-sentinel' } + ) + Provenance = $provenance + } + }.GetNewClosure() + } + 'EnvironmentProbe' { + Write-Task8Trace -Event 'environment-probe' -Data @{ + upperHttp = [string] $env:HTTP_PROXY + upperHttps = [string] $env:HTTPS_PROXY + upperAll = [string] $env:ALL_PROXY + upperNo = [string] $env:NO_PROXY + lowerHttp = [string] $env:http_proxy + lowerHttps = [string] $env:https_proxy + lowerAll = [string] $env:all_proxy + lowerNo = [string] $env:no_proxy + } + } + 'StreamSentinel' { + $hooks.AllowStreamRecords = $true + $hooks.AfterSnapshot = { + param($state) + Write-Task8Trace -Event 'stream-sentinel-fired' -Data @{} + Write-Output 'task8-secret-sentinel-success' + Write-Warning 'task8-secret-sentinel-warning' + Write-Verbose 'task8-secret-sentinel-verbose' -Verbose + Write-Debug 'task8-secret-sentinel-debug' -Debug + Write-Information 'task8-secret-sentinel-information' -InformationAction Continue + Write-Host 'task8-secret-sentinel-host' + Write-Error 'task8-secret-sentinel-error' -ErrorAction Continue + }.GetNewClosure() + } + 'EvidenceMutation' { + $hooks.MutateEvidence = { + param($record) + if ($MutationValue -ceq 'task8-string-count-sentinel') { + $record.read.rowCount = $MutationValue + } + elseif ($MutationValue -ceq '0.4.0-task8-secret-sentinel') { + $record.moduleVersion = $MutationValue + } + elseif ($MutationValue -ceq 'task8-secret-sentinel') { + $record.checks | Add-Member -MemberType NoteProperty -Name unknownNested -Value $MutationValue + } + else { + $record | Add-Member -MemberType NoteProperty -Name forbidden -Value $MutationValue + } + }.GetNewClosure() + } +} + +if ($HookKind -ceq 'AbsentModulePath') { + Remove-Item -LiteralPath Env:PSModulePath -ErrorAction SilentlyContinue +} +$beforeModulePathPresent = Test-Path -LiteralPath Env:PSModulePath +$beforeModulePath = if ($beforeModulePathPresent) { [string] $env:PSModulePath } else { $null } + +try { + $parameters = @{ + PackagePath = $PackagePath + PackageSha256 = $PackageSha256 + AuthMode = $AuthMode + } + if ($UseDryRun) { $parameters.DryRun = $true } + else { + $parameters.ProfileId = $ProfileId + if (-not [string]::IsNullOrEmpty($StorePath)) { $parameters.StorePath = $StorePath } + } + if ($UseInternalWorker) { + # The production worker enters its own Unix session before reading stdin. + # This wrapper is the actual test worker root, so establish the identical + # ownership boundary before a no-read seam or before invoking the worker. + $treeBootstrapHooks = [pscustomobject]@{ + ContractMarker = 'GraphKit.Task8.ParityTestHooks/1' + ExportFunctionsOnly = $true + } + [AppDomain]::CurrentDomain.SetData( + 'GraphKit.Task8.ParityTestHooks/1', $treeBootstrapHooks) + try { + . $RunnerPath -PackagePath 'unused.nupkg' -PackageSha256 ('0' * 64) ` + -AuthMode Certificate -DryRun + } + finally { + [AppDomain]::CurrentDomain.SetData('GraphKit.Task8.ParityTestHooks/1', $null) + } + Initialize-GraphKitAuthParityProcessTreeNative + $script:GraphKitAuthParityProcessTreeType::EnterUnixWorkerSession() + if ($HookKind -ceq 'WorkerNoRead') { + Start-Sleep -Seconds 30 + } + else { + [AppDomain]::CurrentDomain.SetData( + 'GraphKit.Task8.ParityTestHooks/1', [pscustomobject] $hooks) + if ($HookKind -cin @( + 'WorkerBomFrame','WorkerSecondFrame','WorkerMissingTerminator', + 'WorkerEmptyFrame','WorkerInvalidUtf8','WorkerStderr', + 'WorkerStdoutOverflow','WorkerStderrOverflow','WorkerNonzeroExit')) { + $savedWriter = [Console]::Out + $captureWriter = [IO.StringWriter]::new( + [Globalization.CultureInfo]::InvariantCulture) + try { + [Console]::SetOut($captureWriter) + & $WorkerPath + } + finally { [Console]::SetOut($savedWriter) } + $payloadText = $captureWriter.ToString() + $captureWriter.Dispose() + $utf8 = [Text.UTF8Encoding]::new($false) + $payload = $utf8.GetBytes($payloadText) + $outputStream = [Console]::OpenStandardOutput() + $errorStream = [Console]::OpenStandardError() + switch ($HookKind) { + 'WorkerBomFrame' { + $outputStream.Write([byte[]]@(0xEF,0xBB,0xBF), 0, 3) + $outputStream.Write($payload, 0, $payload.Length) + } + 'WorkerSecondFrame' { + $outputStream.Write($payload, 0, $payload.Length) + $outputStream.Write($payload, 0, $payload.Length) + } + 'WorkerMissingTerminator' { + $unterminated = $utf8.GetBytes( + $payloadText.TrimEnd([char[]]@("`r","`n"))) + $outputStream.Write($unterminated, 0, $unterminated.Length) + } + 'WorkerEmptyFrame' {} + 'WorkerInvalidUtf8' { + $invalid = [byte[]]@(0xFF,0x0A) + $outputStream.Write($invalid, 0, $invalid.Length) + } + 'WorkerStderr' { + $outputStream.Write($payload, 0, $payload.Length) + $errorBytes = $utf8.GetBytes("task8-secret-sentinel`n") + $errorStream.Write($errorBytes, 0, $errorBytes.Length) + } + 'WorkerStdoutOverflow' { + $overflowBytes = $utf8.GetBytes( + 'task8-secret-sentinel' + ('x' * 66000) + "`n") + $outputStream.Write($overflowBytes, 0, $overflowBytes.Length) + } + 'WorkerStderrOverflow' { + $outputStream.Write($payload, 0, $payload.Length) + $overflowBytes = $utf8.GetBytes(('x' * 66000) + "`n") + $errorStream.Write($overflowBytes, 0, $overflowBytes.Length) + } + 'WorkerNonzeroExit' { + $outputStream.Write($payload, 0, $payload.Length) + } + } + $outputStream.Flush() + $errorStream.Flush() + if ($HookKind -ceq 'WorkerNonzeroExit') { exit 7 } + } + else { + & $WorkerPath + } + if ($HookKind -ceq 'WorkerExtraBlankFrame') { + [Console]::Out.WriteLine('') + } + } + } + elseif ($HookKind -like 'Live*') { + $dryOutput = @(& $RunnerPath -PackagePath $fixturePackagePath ` + -PackageSha256 $fixturePackageSha256 -AuthMode $fixtureAuthMode -DryRun) + $dryParsedState = if ($dryOutput.Count -eq 1) { + [string]($dryOutput[0] | ConvertFrom-Json -ErrorAction Stop).state + } + else { '' } + if ($dryOutput.Count -ne 1 -or + $dryParsedState -cne 'Passed') { + throw 'The exact package did not pass the prerequisite DryRun.' + } + $exportHooks = [pscustomobject]@{ + ContractMarker = 'GraphKit.Task8.ParityTestHooks/1' + ExportFunctionsOnly = $true + } + [AppDomain]::CurrentDomain.SetData('GraphKit.Task8.ParityTestHooks/1', $exportHooks) + try { + . $RunnerPath -PackagePath 'unused.nupkg' -PackageSha256 ('a' * 64) ` + -AuthMode Certificate -DryRun + } + finally { + [AppDomain]::CurrentDomain.SetData('GraphKit.Task8.ParityTestHooks/1', $null) + } + $null = Import-Task8FixtureContractsFromPackage -Path $fixturePackagePath + $contracts = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -ceq 'GraphKit.Auth.Contracts' + }) + if ($contracts.Count -ne 1) { + throw 'The exact package did not load one contracts assembly for the test core.' + } + $diagnostics = [pscustomobject]@{ + InterfaceType = $contracts[0].GetType('GraphKit.Auth.IGraphTokenSource', $true, $false) + ContractsAssembly = $contracts[0] + } + $route = New-GraphKitAuthParityRoute -Mode $fixtureAuthMode + $core = Invoke-GraphKitAuthParityLiveCore -Route $route -Diagnostics $diagnostics ` + -ProfileId $fixtureProfileId -StorePath $fixtureStorePath ` + -StorePathBound:$(-not [string]::IsNullOrEmpty($fixtureStorePath)) ` + -GetContextAction $hooks.GetContext -ReadAction $hooks.Read + $core | ConvertTo-Json -Compress -Depth 5 + } + else { + [AppDomain]::CurrentDomain.SetData( + 'GraphKit.Task8.ParityTestHooks/1', [pscustomobject] $hooks) + for ($runIndex = 0; $runIndex -lt $RepeatCount; $runIndex++) { + if ($UseOrdinaryExecution) { & $RunnerPath @parameters } + else { . $RunnerPath @parameters } + Write-Task8Trace -Event 'parent-after-run' -Data @{ + processId = [Environment]::ProcessId + graphKitCount = @(Get-Module -Name GraphKit -All).Count + contractsCount = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -ceq 'GraphKit.Auth.Contracts' + }).Count + } + } + } +} +finally { + [AppDomain]::CurrentDomain.SetData('GraphKit.Task8.ParityTestHooks/1', $null) + $script:task8PackageLiveHolderKey = [string]$hooks.PackageLiveHolderKey + if (-not [string]::IsNullOrEmpty([string]$script:task8PackageLiveHolderKey)) { + $holder = [AppDomain]::CurrentDomain.GetData($script:task8PackageLiveHolderKey) + if ($null -ne $holder) { + $event = $null + while ($holder.Events.TryDequeue([ref]$event)) { + Write-Task8Trace -Event ([string]$event.Kind) -Data @{ + storePath = $(if ($null -ne $event.PSObject.Properties['StorePath']) { + [string]$event.StorePath + } else { '' }) + authMethod = $(if ($null -ne $event.PSObject.Properties['AuthMethod']) { + [string]$event.AuthMethod + } else { '' }) + type = $(if ($null -ne $event.PSObject.Properties['Type']) { + [string]$event.Type + } else { '' }) + operation = $(if ($null -ne $event.PSObject.Properties['Operation']) { + [string]$event.Operation + } else { '' }) + maxPages = $(if ($null -ne $event.PSObject.Properties['MaxPages']) { + [int]$event.MaxPages + } else { 0 }) + firstPageAuthority = $(if ( + $null -ne $event.PSObject.Properties['FirstPageAuthority']) { + [string]$event.FirstPageAuthority + } else { '' }) + } + $event = $null + } + } + [AppDomain]::CurrentDomain.SetData($script:task8PackageLiveHolderKey, $null) + $script:task8PackageLiveHolderKey = $null + } + if (-not $UseInternalWorker) { + $modulePathPresent = Test-Path -LiteralPath Env:PSModulePath + Write-Task8Trace -Event 'wrapper-finished' -Data @{ + modulePathRestored = ($modulePathPresent -eq $beforeModulePathPresent -and + (-not $beforeModulePathPresent -or [string] $env:PSModulePath -ceq $beforeModulePath)) + modulePathPresent = $modulePathPresent + graphKitLoaded = (@(Get-Module -Name GraphKit -All).Count -ne 0) + preloadedStillLoaded = ($null -ne $preloadedModule -and + @(Get-Module -Name GraphKit -All).Count -ne 0) + } + } + if ($null -ne $preloadedModule) { + Remove-Module -ModuleInfo $preloadedModule -Force -ErrorAction SilentlyContinue + $preloadedModule = $null + } + if ($null -ne $preloadedRoot -and (Test-Path -LiteralPath $preloadedRoot)) { + Remove-Item -LiteralPath $preloadedRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} +'@, [Text.UTF8Encoding]::new($false)) + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Command pwsh -ErrorAction Stop).Source + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo','-NoProfile','-File',$wrapperPath, + '-RunnerPath',$script:runnerPath, + '-PackagePath',$PackagePath, + '-PackageSha256',$PackageSha256, + '-AuthMode',$AuthMode, + '-UseDryRunText',([string][bool] $DryRun).ToLowerInvariant(), + '-ProfileId',([string] $ProfileId), + '-StorePath',([string] $StorePath), + '-HookKind',$HookKind, + '-MutationValue',$MutationValue, + '-OrdinaryExecutionText',([string][bool] $OrdinaryExecution).ToLowerInvariant(), + '-RepeatCount',([string] $Repeat), + '-TracePath',$tracePath, + '-GrandchildPath',$grandchildPath + )) { + $null = $startInfo.ArgumentList.Add([string] $argument) + } + $startInfo.Environment['NuGetAudit'] = 'false' + $startInfo.Environment['HTTP_PROXY'] = 'http://127.0.0.1:1' + $startInfo.Environment['HTTPS_PROXY'] = 'http://127.0.0.1:1' + $startInfo.Environment['ALL_PROXY'] = 'http://127.0.0.1:1' + $startInfo.Environment['NO_PROXY'] = 'localhost,127.0.0.1' + $startInfo.Environment['http_proxy'] = 'http://127.0.0.1:1' + $startInfo.Environment['https_proxy'] = 'http://127.0.0.1:1' + $startInfo.Environment['all_proxy'] = 'http://127.0.0.1:1' + $startInfo.Environment['no_proxy'] = 'localhost,127.0.0.1' + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { throw 'Task 8 fresh-process runner did not start.' } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(60000)) { + $process.Kill($true) + throw 'Task 8 fresh-process runner exceeded the 60-second liveness bound.' + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $frames = [regex]::Matches( + $stdout, + '\G(?\{[^\r\n]*\})(?:\r\n|\n)', + [Text.RegularExpressions.RegexOptions]::CultureInvariant) + $capturedLength = [long]0 + foreach ($frame in $frames) { $capturedLength += $frame.Length } + $publicFramesValid = $frames.Count -gt 0 -and + $capturedLength -eq $stdout.Length + $outputLines = if ($publicFramesValid) { + @($frames | ForEach-Object { $_.Groups['json'].Value }) + } + else { @() } + $parsedRecords = @() + $parseFailed = -not $publicFramesValid + foreach ($line in $outputLines) { + try { + $parsedRecords += ConvertFrom-Task8JsonText -Json $line + } + catch { + $parseFailed = $true + $parsedRecords = @() + break + } + } + $jsonCount = if ($parseFailed) { 0 } else { $parsedRecords.Count } + $parsed = if ($jsonCount -eq 1) { $parsedRecords[0] } else { $null } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout + StdErr = $stderr + Output = $stdout + $stderr + Data = $parsed + DataRecords = @($parsedRecords) + JsonCount = $jsonCount + OutputLineCount = $outputLines.Count + TracePath = $tracePath + } + } + finally { + $process.Dispose() + } + } + + function ConvertFrom-Task8JsonElement { + param([Parameter(Mandatory)][Text.Json.JsonElement] $Element) + switch ($Element.ValueKind) { + Object { + $value = [ordered]@{} + foreach ($property in $Element.EnumerateObject()) { + $value[$property.Name] = ConvertFrom-Task8JsonElement -Element $property.Value + } + return [pscustomobject]$value + } + Array { + $items = [Collections.Generic.List[object]]::new() + foreach ($item in $Element.EnumerateArray()) { + $items.Add((ConvertFrom-Task8JsonElement -Element $item)) + } + return ,$items.ToArray() + } + String { return [string]$Element.GetString() } + Number { + $integer = 0L + if ($Element.TryGetInt64([ref]$integer)) { return $integer } + return $Element.GetDecimal() + } + True { return $true } + False { return $false } + Null { return $null } + default { throw "Unsupported Task 8 JSON kind '$($Element.ValueKind)'." } + } + } + + function ConvertFrom-Task8JsonText { + param([Parameter(Mandatory)][string] $Json) + $document = [Text.Json.JsonDocument]::Parse($Json) + try { return ConvertFrom-Task8JsonElement -Element $document.RootElement } + finally { $document.Dispose() } + } + + function Assert-Task8ModeRecordShape { + param( + [Parameter(Mandatory)] $Record, + [Parameter(Mandatory)][string] $Execution, + [Parameter(Mandatory)][string] $AuthMode, + [Parameter(Mandatory)][string] $PackageSha256 + ) + + ($Record.PSObject.Properties.Name -join '|') | Should -BeExactly ( + 'schemaVersion|execution|moduleVersion|packageSha256|authMode|state|failureStage|' + + 'failureCode|checks|adapter|read|startedUtc|completedUtc') + $Record.schemaVersion | Should -Be 1 + $Record.execution | Should -BeExactly $Execution + $Record.packageSha256 | Should -BeExactly $PackageSha256 + $Record.authMode | Should -BeExactly $AuthMode + ($Record.checks.PSObject.Properties.Name -join '|') | Should -BeExactly ( + 'packageDigestMatched|snapshotBound|archiveValidated|extractionSealed|exactImport|' + + 'routeMatched|contextMatched|sourceMatched|tenantProofVerified|cleanupVerified') + @($Record.checks.PSObject.Properties.Value | Where-Object { $_ -isnot [bool] }).Count | + Should -Be 0 + ($Record.adapter.PSObject.Properties.Name -join '|') | Should -BeExactly ( + 'abiMarkerExact|contractsDefault|providerCollectibleNonDefault|msalVersionExact|' + + 'providerMsalSameContext|publicAbiExact') + @($Record.adapter.PSObject.Properties.Value | Where-Object { $_ -isnot [bool] }).Count | + Should -Be 0 + ($Record.read.PSObject.Properties.Name -join '|') | + Should -BeExactly 'operation|attempted|succeeded|rowCount' + $Record.read.operation | Should -BeExactly 'ManagedDevice.List' + $Record.read.attempted | Should -BeOfType ([bool]) + $Record.read.succeeded | Should -BeOfType ([bool]) + [long] $Record.read.rowCount | Should -BeGreaterOrEqual 0 + $Record.startedUtc | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' + $Record.completedUtc | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' + } + + function Assert-Task8LiveCoreResult { + param( + [Parameter(Mandatory)] $Record, + [Parameter(Mandatory)][string] $AuthMode, + [Parameter(Mandatory)][string] $State, + [Parameter(Mandatory)][string] $FailureStage, + [Parameter(Mandatory)][string] $FailureCode + ) + ($Record.PSObject.Properties.Name -join '|') | Should -BeExactly ( + 'recordKind|authMode|state|failureStage|failureCode|contextMatched|sourceMatched|' + + 'tenantProofVerified|readAttempted|readSucceeded|rowCount') + $Record.recordKind | Should -BeExactly 'GraphKit.Task8.LiveCoreTestResult/1' + $Record.authMode | Should -BeExactly $AuthMode + $Record.state | Should -BeExactly $State + $Record.failureStage | Should -BeExactly $FailureStage + $Record.failureCode | Should -BeExactly $FailureCode + foreach ($name in @( + 'contextMatched','sourceMatched','tenantProofVerified','readAttempted','readSucceeded')) { + $Record.$name | Should -BeOfType ([bool]) + } + $Record.rowCount | Should -BeOfType ([long]) + } + + function Get-Task8TraceRecords { + param([Parameter(Mandatory)][string] $Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return @() } + return @([IO.File]::ReadAllLines($Path) | ForEach-Object { $_ | ConvertFrom-Json -Depth 5 }) + } + + function Assert-Task8SafeFailure { + param( + [Parameter(Mandatory)] $Invocation, + [Parameter(Mandatory)][string] $Stage, + [Parameter(Mandatory)][string] $Code, + [string] $AuthMode = 'Certificate', + [string] $PackageSha256 = ('0' * 64) + ) + $Invocation.ExitCode | Should -Be 0 + $Invocation.OutputLineCount | Should -Be 1 + $Invocation.JsonCount | Should -Be 1 + $Invocation.StdErr | Should -BeNullOrEmpty + Assert-Task8ModeRecordShape -Record $Invocation.Data -Execution $( + if ($Invocation.Data.execution -ceq 'Live') { 'Live' } else { 'DryRun' }) ` + -AuthMode $AuthMode -PackageSha256 $PackageSha256 + $Invocation.Data.state | Should -BeExactly 'Failed' + $Invocation.Data.failureStage | Should -BeExactly $Stage + $Invocation.Data.failureCode | Should -BeExactly $Code + } + + function Resolve-Task8ResidualFixturePath { + param([AllowNull()][string] $Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + $full = [IO.Path]::GetFullPath($Path) + $temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { [StringComparison]::Ordinal } + if (-not [string]::Equals( + [IO.Path]::GetDirectoryName($full), $temp, $comparison) -or + [IO.Path]::GetFileName($full) -notmatch '^graphkit-task8-') { + throw 'Task 8 fixture cleanup refused a non-literal residual path.' + } + return $full + } + + function Remove-Task8ResidualFixturePath { + param( + [AllowNull()][string] $Path, + [switch] $OwnedHardLink + ) + $full = Resolve-Task8ResidualFixturePath -Path $Path + if ($null -eq $full -or -not (Test-Path -LiteralPath $full)) { return } + $rootItem = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if ($OwnedHardLink) { + if ($rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [string] $rootItem.LinkType -cne 'HardLink' -or + [IO.Path]::GetFileName($full) -cnotmatch + '^graphkit-task8-link-target-[0-9a-f]{32}$') { + throw 'Task 8 owned hard-link cleanup refused an unexpected entry.' + } + $paths = @($rootItem) + } + elseif (-not [string]::IsNullOrEmpty([string] $rootItem.LinkType) -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Remove-Item -LiteralPath $full -Force -ErrorAction Stop + return + } + else { + $paths = @( + Get-ChildItem -LiteralPath $full -Recurse -Force -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Length } -Descending + ) + @($rootItem) + } + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + foreach ($item in $paths) { + if ((-not $OwnedHardLink -and + -not [string]::IsNullOrEmpty([string] $item.LinkType)) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + continue + } + $directory = [bool]$item.PSIsContainer + $acl = if ($directory) { + [Security.AccessControl.DirectorySecurity]::new() + } + else { + [Security.AccessControl.FileSecurity]::new() + } + $acl.SetAccessRuleProtection($true, $false) + $inheritance = if ($directory) { + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit + } + else { + [Security.AccessControl.InheritanceFlags]::None + } + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + if ($directory) { + [IO.FileSystemAclExtensions]::SetAccessControl( + [IO.DirectoryInfo]::new($item.FullName), $acl) + } + else { + [IO.FileSystemAclExtensions]::SetAccessControl( + [IO.FileInfo]::new($item.FullName), $acl) + $attributes = [IO.File]::GetAttributes($item.FullName) + if (($attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) { + $writableAttributes = [IO.FileAttributes]( + [int]$attributes -band (-bnot [int][IO.FileAttributes]::ReadOnly)) + [IO.File]::SetAttributes( + $item.FullName, + $(if ([int]$writableAttributes -eq 0) { + [IO.FileAttributes]::Normal + } + else { $writableAttributes })) + } + } + } + } + else { + foreach ($item in $paths) { + if ((-not $OwnedHardLink -and + -not [string]::IsNullOrEmpty([string] $item.LinkType)) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + continue + } + $mode = [IO.File]::GetUnixFileMode($item.FullName) + $required = [IO.UnixFileMode]::UserRead -bor + [IO.UnixFileMode]::UserWrite + $anyExecute = [IO.UnixFileMode]::UserExecute -bor + [IO.UnixFileMode]::GroupExecute -bor + [IO.UnixFileMode]::OtherExecute + if ($item.PSIsContainer -or + (([int] $mode -band [int] $anyExecute) -ne 0)) { + $required = $required -bor [IO.UnixFileMode]::UserExecute + } + [IO.File]::SetUnixFileMode( + $item.FullName, + [IO.UnixFileMode]([int] $mode -bor [int] $required)) + } + } + Remove-Item -LiteralPath $full -Recurse -Force -ErrorAction Stop + } + + function Remove-Task8OwnedHardLinkFixtureTree { + param( + [AllowNull()][string] $OutsidePath, + [AllowNull()][string] $RootPath + ) + $failures = [Collections.Generic.List[Exception]]::new() + try { + Remove-Task8ResidualFixturePath -Path $OutsidePath -OwnedHardLink + } + catch { $failures.Add($_.Exception) | Out-Null } + try { Remove-Task8ResidualFixturePath -Path $RootPath } + catch { $failures.Add($_.Exception) | Out-Null } + if ($failures.Count -gt 0) { + throw [AggregateException]::new( + 'Task 8 owned hard-link fixture cleanup failed.', + $failures.ToArray()) + } + } + + function New-Task8ModeRecordFixture { + param( + [string] $AuthMode = 'Certificate', + [string] $ModuleVersion = '0.4.0-r8.fixture', + [string] $PackageSha256 = ('a' * 64), + [ValidateSet('DryRun','Live')][string] $Execution = 'DryRun' + ) + [pscustomobject][ordered]@{ + schemaVersion = [int] 1 + execution = $Execution + moduleVersion = $ModuleVersion + packageSha256 = $PackageSha256 + authMode = $AuthMode + state = 'Passed' + failureStage = 'None' + failureCode = 'None' + checks = [pscustomobject][ordered]@{ + packageDigestMatched = $true + snapshotBound = $true + archiveValidated = $true + extractionSealed = $true + exactImport = $true + routeMatched = $true + contextMatched = $Execution -ceq 'Live' + sourceMatched = $Execution -ceq 'Live' + tenantProofVerified = $Execution -ceq 'Live' + cleanupVerified = $true + } + adapter = [pscustomobject][ordered]@{ + abiMarkerExact = $true + contractsDefault = $true + providerCollectibleNonDefault = $true + msalVersionExact = $true + providerMsalSameContext = $true + publicAbiExact = $true + } + read = [pscustomobject][ordered]@{ + operation = 'ManagedDevice.List' + attempted = $Execution -ceq 'Live' + succeeded = $Execution -ceq 'Live' + rowCount = [long]$(if ($Execution -ceq 'Live') { 1 } else { 0 }) + } + startedUtc = '2026-09-01T12:00:00.0000000Z' + completedUtc = '2026-09-01T12:00:01.0000000Z' + } + } + + function New-Task8FrozenArtifactFixture { + param( + [string] $ModuleVersion = '0.4.0-r8.fixture', + [string] $PackageSha256 = ('a' * 64) + ) + [pscustomobject][ordered]@{ + schemaVersion = [int] 1 + moduleVersion = $ModuleVersion + sourceRevision = ('b' * 40) + packageSha256 = $PackageSha256 + proofSha256 = ('c' * 64) + } + } + + function Invoke-Task8PrivateHelper { + param( + [Parameter(Mandatory)][string] $FunctionName, + [hashtable] $Arguments = @{} + ) + if (-not (Test-Path -LiteralPath $script:runnerPath -PathType Leaf)) { + throw 'Task 8 private helper implementation is missing.' + } + $hooks = [pscustomobject]@{ + ContractMarker = 'GraphKit.Task8.ParityTestHooks/1' + ExportFunctionsOnly = $true + } + [AppDomain]::CurrentDomain.SetData('GraphKit.Task8.ParityTestHooks/1', $hooks) + try { + . $script:runnerPath -PackagePath 'unused.nupkg' -PackageSha256 ('a' * 64) ` + -AuthMode Certificate -DryRun + & $FunctionName @Arguments + } + finally { + [AppDomain]::CurrentDomain.SetData('GraphKit.Task8.ParityTestHooks/1', $null) + } + } + + function Assert-Task8VerifiedGetProofControlFlow { + param([Parameter(Mandatory)][string] $SourceRoot) + + function Get-Task8ParsedFunction { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $Name + ) + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $Path, [ref]$tokens, [ref]$errors) + if (@($errors).Count -ne 0) { + throw 'A Task 8 proof-control source file did not parse.' + } + $functions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $Name + }, $true)) + if ($functions.Count -ne 1) { + throw 'A Task 8 proof-control function was not singular.' + } + return $functions[0] + } + + $getObject = Get-Task8ParsedFunction -Path ( + Join-Path $SourceRoot 'Public/Get-GraphObject.ps1') -Name Get-GraphObject + $transportAssignments = @($getObject.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -ceq 'transport' + }, $true)) + $pagingCalls = @($getObject.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-GraphPaging' + }, $true)) + if ($transportAssignments.Count -ne 1 -or + @($transportAssignments[0].FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Invoke-GraphRetry' + }, $true)).Count -ne 1 -or + $pagingCalls.Count -ne 1 -or + $pagingCalls[0].Extent.Text -cnotmatch '(?s)-TransportScript\s+\$transport\b') { + throw 'ManagedDevice.List is not routed through the verified retry transport.' + } + + $retry = Get-Task8ParsedFunction -Path ( + Join-Path $SourceRoot 'Private/Invoke-GraphRetry.ps1') -Name Invoke-GraphRetry + $bindingAssignments = @($retry.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$requiresTenantBinding' + }, $true)) + $verifyAssignments = @($retry.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ceq '$sendParams.VerifyTenantBinding' + }, $true)) + $sendCalls = @($retry.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.Extent.Text -cmatch '^&\s+\$send\s+@sendParams\b' + }, $true)) + if ($bindingAssignments.Count -ne 1 -or + $bindingAssignments[0].Extent.Text -cnotmatch + "IdentityRequirement\s+-ceq\s+'Verified'" -or + $verifyAssignments.Count -ne 1 -or $sendCalls.Count -ne 1 -or + $verifyAssignments[0].Extent.StartOffset -le + $bindingAssignments[0].Extent.StartOffset -or + $verifyAssignments[0].Extent.EndOffset -ge $sendCalls[0].Extent.StartOffset) { + throw 'A Verified descriptor is not bound to the sender before invocation.' + } + + $senderFunction = Get-Task8ParsedFunction -Path ( + Join-Path $SourceRoot 'Private/Transport/Send-GraphHttpRequest.ps1') ` + -Name Send-GraphHttpRequest + $proofCalls = @($senderFunction.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Confirm-GraphTenantBinding' + }, $true)) + $physicalSends = @($senderFunction.FindAll({ + param($node) + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Member.Extent.Text -ceq 'SendAsync' + }, $true)) + if ($proofCalls.Count -ne 1 -or $physicalSends.Count -ne 1 -or + $proofCalls[0].Extent.EndOffset -ge $physicalSends[0].Extent.StartOffset) { + throw 'Tenant proof is not ordered before the one physical send.' + } + $proofGuard = $null + $ancestor = $proofCalls[0].Parent + while ($null -ne $ancestor) { + if ($ancestor -is [Management.Automation.Language.IfStatementAst] -and + $ancestor.Extent.Text -cmatch '\$VerifyTenantBinding\b') { + $proofGuard = $ancestor + break + } + $ancestor = $ancestor.Parent + } + if ($null -eq $proofGuard) { + throw 'Tenant proof is not controlled by the verified-send guard.' + } + return $true + } +} + +Describe 'Task 8 protected GraphKit.Auth parity runner contract' { + It 'provides the verification-only runner at the approved literal path' { + $script:runnerPath | Should -Exist + $script:workerPath | Should -Exist + } + + It 'declares the exact public parameter contract and required private helpers' { + if (-not (Test-Path -LiteralPath $script:runnerPath -PathType Leaf)) { + throw 'Task 8 runner and private helpers are not implemented.' + } + + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $script:runnerPath, + [ref] $tokens, + [ref] $errors) + @($errors).Count | Should -Be 0 + $workerTokens = $null + $workerErrors = $null + $workerAst = [Management.Automation.Language.Parser]::ParseFile( + $script:workerPath, + [ref] $workerTokens, + [ref] $workerErrors) + @($workerErrors).Count | Should -Be 0 + @($workerAst.ParamBlock.Parameters).Count | Should -Be 0 + + $workerVersionGuards = @($workerAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text -cmatch 'Get-GraphKitAuthParityFullVersion' -and + $node.Extent.Text -cmatch '\$request\.moduleVersion\b' + }, $true)) + $workerImports = @($workerAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Import-Module' + }, $true)) + $workerVersionGuards.Count | Should -Be 1 + $workerImports.Count | Should -Be 1 + @($workerVersionGuards[0].FindAll({ + param($node) + $node -is [Management.Automation.Language.BinaryExpressionAst] -and + $node.Operator -eq [Management.Automation.Language.TokenKind]::Cne + }, $true)).Count | Should -Be 1 + $workerVersionThrows = @($workerVersionGuards[0].FindAll({ + param($node) + $node -is [Management.Automation.Language.ThrowStatementAst] + }, $true)) + $workerVersionThrows.Count | Should -Be 1 + $workerVersionThrows[0].Extent.Text | + Should -Match 'protected parity worker version was rejected' + $workerVersionGuards[0].Extent.EndOffset | + Should -BeLessThan $workerImports[0].Extent.StartOffset + + @($ast.ParamBlock.Parameters.Name.VariablePath.UserPath) -join '|' | + Should -BeExactly 'PackagePath|PackageSha256|AuthMode|ProfileId|StorePath|DryRun' + $functionNames = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] + }, $true).Name) + $functionNames | Should -Contain 'New-GraphKitAuthParityRoute' + $functionNames | Should -Contain 'Test-GraphKitAuthParityEvidence' + $functionNames | Should -Contain 'Invoke-GraphKitAuthParityLiveCore' + $functionNames | Should -Contain 'Assert-GraphKitAuthParitySourceBound' + $functionNames | Should -Contain 'Assert-GraphKitAuthParityProviderWeakReference' + $functionNames | Should -Contain 'Get-GraphKitAuthParityPublicAbiSha256' + + $runnerText = [IO.File]::ReadAllText($script:runnerPath) + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + . (Join-Path $script:repoRoot 'scripts/private/Test-GraphKitPackagePrivacy.ps1') + $moduleManifest = Import-PowerShellDataFile -Path ( + Join-Path $script:repoRoot 'source/GraphKit.psd1') + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet ` + -ModuleGuid ([guid]$moduleManifest.GUID) + $privacyFindings = [Collections.Generic.List[object]]::new() + $privacyKeys = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal) + foreach ($task8Script in @($script:runnerPath, $script:workerPath)) { + $task8Bytes = [IO.File]::ReadAllBytes($task8Script) + $task8Text = $strictUtf8.GetString($task8Bytes) + if ($task8Text.Length -gt 0) { + $task8Text[0] | Should -Not -Be ([char]0xFEFF) + } + Test-GraphKitPackagePrivacyText -Text $task8Text ` + -EntryName ([IO.Path]::GetRelativePath($script:repoRoot, $task8Script)) ` + -Encoding 'source-strict-utf8' -AllowedGuids $allowedGuids ` + -Findings $privacyFindings -FindingKeys $privacyKeys + } + $privacyFindings.Count | Should -Be 0 + $normalizedRunnerText = $runnerText.Replace("`r`n", "`n") + $embeddedStartToken = "`$helperGzipBase64 = @'`n" + $embeddedStart = $normalizedRunnerText.IndexOf( + $embeddedStartToken, [StringComparison]::Ordinal) + $embeddedStart | Should -BeGreaterOrEqual 0 + $embeddedStart += $embeddedStartToken.Length + $embeddedEnd = $normalizedRunnerText.IndexOf( + "`n'@", $embeddedStart, [StringComparison]::Ordinal) + $embeddedEnd | Should -BeGreaterThan $embeddedStart + $compressedHelper = [Convert]::FromBase64String( + ($normalizedRunnerText.Substring($embeddedStart, $embeddedEnd - $embeddedStart) -replace '\s', '')) + $compressedStream = [IO.MemoryStream]::new($compressedHelper, $false) + try { + $gzip = [IO.Compression.GZipStream]::new( + $compressedStream, [IO.Compression.CompressionMode]::Decompress, $false) + try { + $reader = [IO.StreamReader]::new( + $gzip, [Text.UTF8Encoding]::new($false, $true), $true, 4096, $false) + try { $embeddedHelper = $reader.ReadToEnd() } + finally { $reader.Dispose() } + } + finally { $gzip.Dispose() } + } + finally { $compressedStream.Dispose() } + $trackedHelperPath = Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs' + $trackedHelper = [IO.File]::ReadAllText($trackedHelperPath) + $embeddedHelper | Should -BeExactly $trackedHelper ` + -Because 'the self-contained protected runner helper must be generated from the reviewed tracked source' + + $stateAssignmentIndex = $runnerText.IndexOf( + '$task8State = [pscustomobject]@{', [StringComparison]::Ordinal) + $rootPermissionCheckIndex = $runnerText.IndexOf( + 'if (-not $task8Native::HasInitialOwnerOnlyDirectoryAccess($task8RootEvidence))', + [StringComparison]::Ordinal) + $stateAssignmentIndex | Should -BeGreaterThan -1 + $rootPermissionCheckIndex | Should -BeGreaterThan $stateAssignmentIndex + + $hookAssignments = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -ceq 'task8Hooks' + }, $true)) + $hookAssignments.Count | Should -Be 1 + $hookAssignments[0].Extent.Text | Should -Match ([regex]::Escape( + "if (`$MyInvocation.InvocationName -ceq '.')")) + $hookCalls = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Get-GraphKitAuthParityTestHooks' + }, $true)) + $hookCalls.Count | Should -Be 1 + + $cleanupFunction = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Remove-GraphKitAuthParityState' + }, $true)) + $cleanupFunction.Count | Should -Be 1 + $cleanupText = $cleanupFunction[0].Extent.Text + $directoryEmptyIndex = $cleanupText.IndexOf( + 'if ([IO.Directory]::EnumerateFileSystemEntries($path).GetEnumerator().MoveNext())', + [StringComparison]::Ordinal) + $directoryBeforeDeleteIndex = $cleanupText.IndexOf( + "-Arguments @(`$State, `$relative, 'BeforeDelete', `$native)", + [StringComparison]::Ordinal) + $directoryIdentityIndex = $cleanupText.IndexOf( + '$deleteDirectory = $native::InspectDirectory($State.RootPath, $relative)', + [StringComparison]::Ordinal) + $directoryDeleteIndex = $cleanupText.IndexOf( + '[IO.Directory]::Delete($path, $false)', [StringComparison]::Ordinal) + $directoryEmptyIndex | Should -BeGreaterOrEqual 0 + $directoryBeforeDeleteIndex | Should -BeGreaterThan $directoryEmptyIndex + $directoryIdentityIndex | Should -BeGreaterThan $directoryBeforeDeleteIndex + $directoryDeleteIndex | Should -BeGreaterThan $directoryIdentityIndex + + $hookFunction = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Invoke-GraphKitAuthParityHook' + }, $true)) + $hookFunction.Count | Should -Be 1 + $hookFunction[0].Extent.Text | Should -Not -Match ([regex]::Escape('return $null')) + + $validationAttributes = @($ast.ParamBlock.Parameters.Attributes | Where-Object { + $_.TypeName.FullName -in @('ValidateSet','ValidatePattern','ValidateScript') + }) + $validationAttributes.Count | Should -Be 0 + $ast.ParamBlock.Parameters[0].Attributes.TypeName.FullName | Should -Contain 'Parameter' + + Invoke-Task8PrivateHelper -FunctionName Assert-GraphKitAuthParitySourceBound ` + -Arguments @{ Evidence = [pscustomobject]@{ Length = [long]512MB } } | + Should -BeTrue + { + Invoke-Task8PrivateHelper -FunctionName Assert-GraphKitAuthParitySourceBound ` + -Arguments @{ Evidence = [pscustomobject]@{ Length = [long]512MB + 1 } } + } | Should -Throw + + $expectedContext = [object]::new() + $otherContext = [object]::new() + $weak = [WeakReference]::new($expectedContext) + Invoke-Task8PrivateHelper -FunctionName Assert-GraphKitAuthParityProviderWeakReference ` + -Arguments @{ WeakReference = $weak; ProviderContext = $expectedContext } | + Should -BeTrue + { + Invoke-Task8PrivateHelper -FunctionName Assert-GraphKitAuthParityProviderWeakReference ` + -Arguments @{ WeakReference = $weak; ProviderContext = $otherContext } + } | Should -Throw + } + + It 'contains no provisioning, mutation, installation, Graph SDK, or Azure command in its AST' { + $forbidden = @( + 'New-Ivy24LabApp','New-ClientServicePrincipalCBA','Register-GraphTenant', + 'Remove-GraphTenant','Set-Secret','Remove-Secret','Register-SecretVault', + 'Unregister-SecretVault','Install-PSResource','Install-Module','Save-Module', + 'Register-PSRepository','Connect-MgGraph','Invoke-MgGraphRequest','Connect-AzAccount', + 'New-MgApplication','Update-MgApplication','Remove-MgApplication', + 'Add-MgApplicationKey','Remove-MgApplicationKey', + 'Add-MgApplicationPassword','Remove-MgApplicationPassword', + 'New-MgServicePrincipal','Update-MgServicePrincipal','Remove-MgServicePrincipal', + 'Add-MgServicePrincipalKey','Remove-MgServicePrincipalKey', + 'Add-MgServicePrincipalPassword','Remove-MgServicePrincipalPassword', + 'New-MgServicePrincipalAppRoleAssignment','Remove-MgServicePrincipalAppRoleAssignment', + 'New-MgServicePrincipalAppRoleAssignedTo','Remove-MgServicePrincipalAppRoleAssignedTo', + 'New-MgOauth2PermissionGrant','Update-MgOauth2PermissionGrant', + 'Remove-MgOauth2PermissionGrant', + 'New-AzResourceGroup','Remove-AzResourceGroup','New-AzUserAssignedIdentity', + 'Remove-AzUserAssignedIdentity','New-AzContainerGroup','Remove-AzContainerGroup','az' + ) + $commands = foreach ($task8Script in @($script:runnerPath, $script:workerPath)) { + if (-not (Test-Path -LiteralPath $task8Script -PathType Leaf)) { + throw 'A Task 8 verifier script AST is not implemented.' + } + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $task8Script, [ref] $tokens, [ref] $errors) + @($errors).Count | Should -Be 0 + @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true) | ForEach-Object { $_.GetCommandName() } | + Where-Object { $null -ne $_ }) + } + @($commands | Where-Object { $_ -in $forbidden }).Count | Should -Be 0 + } + + It 'requires ManagedDevice.List to explicitly declare support' -ForEach $task8AuthModes { + $descriptor = Import-PowerShellDataFile -Path ( + Join-Path $script:repoRoot 'source/Data/Operations/ManagedDevice.List.psd1') + @($descriptor.SupportedAuthModes) | Should -Contain $AuthMode + } + + It 'dead-ends uppercase and lowercase proxy variables in every fresh test process' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind EnvironmentProbe + + $result.ExitCode | Should -Be 0 + $result.JsonCount | Should -Be 1 + $probes = @(Get-Task8TraceRecords $result.TracePath | + Where-Object event -eq 'environment-probe') + $probes.Count | Should -Be 1 + $expected = [ordered]@{ + upperHttp = 'http://127.0.0.1:1' + upperHttps = 'http://127.0.0.1:1' + upperAll = 'http://127.0.0.1:1' + upperNo = 'localhost,127.0.0.1' + lowerHttp = 'http://127.0.0.1:1' + lowerHttps = 'http://127.0.0.1:1' + lowerAll = 'http://127.0.0.1:1' + lowerNo = 'localhost,127.0.0.1' + } + foreach ($entry in $expected.GetEnumerator()) { + $probes[0].data.($entry.Key) | Should -BeExactly $entry.Value + } + } +} + +Describe 'Task 8 canonical GraphKit.Auth ABI gate' -Tag 'Task8Abi' { + BeforeAll { + function New-Task8AbiProbeAssembly { + param( + [string] $Name = 'GraphKit.Task8.AbiProbe', + [version] $Version = [version]'1.0.0.0', + [Parameter(Mandatory)][string] $InformationalVersion, + [ValidateSet('Int32','Byte')][string] $EnumUnderlyingType = 'Int32', + [ValidateSet('NotNull','Nullable')][string] $StringNullability = 'NotNull', + [switch] $AddPublicMethod + ) + + $assemblyName = [Reflection.AssemblyName]::new($Name) + $assemblyName.Version = $Version + $assembly = [Reflection.Emit.AssemblyBuilder]::DefineDynamicAssembly( + $assemblyName, + [Reflection.Emit.AssemblyBuilderAccess]::RunAndCollect) + $attributeConstructor = [Reflection.AssemblyInformationalVersionAttribute].GetConstructor( + [type[]]@([string])) + $assembly.SetCustomAttribute([Reflection.Emit.CustomAttributeBuilder]::new( + $attributeConstructor, + [object[]]@($InformationalVersion))) + $module = $assembly.DefineDynamicModule($Name) + $type = $module.DefineType( + 'GraphKit.Task8.AbiProbe', + [Reflection.TypeAttributes]'Public,Sealed,Class') + $null = $type.DefineDefaultConstructor([Reflection.MethodAttributes]::Public) + + $property = $type.DefineProperty( + 'DisplayName', + [Reflection.PropertyAttributes]::None, + [string], + [type[]]@()) + $nullableAttribute = [Type]::GetType( + 'System.Runtime.CompilerServices.NullableAttribute, System.Private.CoreLib', + $true) + $nullableConstructor = $nullableAttribute.GetConstructor([type[]]@([byte])) + $nullableFlag = [byte]$(if ($StringNullability -ceq 'Nullable') { 2 } else { 1 }) + $property.SetCustomAttribute([Reflection.Emit.CustomAttributeBuilder]::new( + $nullableConstructor, + [object[]]@($nullableFlag))) + $getter = $type.DefineMethod( + 'get_DisplayName', + [Reflection.MethodAttributes]'Public,SpecialName,HideBySig', + [string], + [type[]]@()) + $getterIl = $getter.GetILGenerator() + $getterIl.Emit([Reflection.Emit.OpCodes]::Ldnull) + $getterIl.Emit([Reflection.Emit.OpCodes]::Ret) + $property.SetGetMethod($getter) + + if ($AddPublicMethod) { + $method = $type.DefineMethod( + 'AddedPublicMethod', + [Reflection.MethodAttributes]'Public,HideBySig', + [void], + [type[]]@()) + $method.GetILGenerator().Emit([Reflection.Emit.OpCodes]::Ret) + } + $null = $type.CreateType() + + $underlyingType = if ($EnumUnderlyingType -ceq 'Byte') { [byte] } else { [int] } + $enum = $module.DefineEnum( + 'GraphKit.Task8.AbiProbeMode', + [Reflection.TypeAttributes]::Public, + $underlyingType) + $firstValue = if ($EnumUnderlyingType -ceq 'Byte') { [byte]0 } else { [int]0 } + $secondValue = if ($EnumUnderlyingType -ceq 'Byte') { [byte]1 } else { [int]1 } + $null = $enum.DefineLiteral('First', $firstValue) + $null = $enum.DefineLiteral('Second', $secondValue) + $null = $enum.CreateType() + return $assembly + } + + function Get-Task8AbiProbeHash { + param([Parameter(Mandatory)][Reflection.Assembly] $Assembly) + Invoke-Task8PrivateHelper ` + -FunctionName Get-GraphKitAuthParityPublicAbiSha256 ` + -Arguments @{ Assembly = $Assembly } + } + } + + It 'projects the exact 161-record Task 7 contract surface and expected digest' { + $sourceManifest = Import-PowerShellDataFile -Path ( + Join-Path $script:repoRoot 'source/GraphKit.psd1') + $contractsPath = Join-Path $script:repoRoot ( + 'output/module/GraphKit/{0}/Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' -f + [string] $sourceManifest.ModuleVersion) + $contractsPath | Should -Exist + $assembly = [Reflection.Assembly]::LoadFile( + (Resolve-Path -LiteralPath $contractsPath).ProviderPath) + + $records = @(Invoke-Task8PrivateHelper ` + -FunctionName Get-GraphKitAuthParityPublicAbiRecords ` + -Arguments @{ Assembly = $assembly }) + $hash = Get-Task8AbiProbeHash -Assembly $assembly + + $records.Count | Should -Be 161 + $hash | Should -BeExactly '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' + } + + It 'ignores informational version while retaining the same assembly name and version' { + $first = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+first-commit' + $second = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+second-commit' + + $firstHash = Get-Task8AbiProbeHash -Assembly $first + $secondHash = Get-Task8AbiProbeHash -Assembly $second + + $firstHash | Should -Match '^[0-9a-f]{64}$' + $secondHash | Should -BeExactly $firstHash ` + -Because 'commit-bearing informational metadata is outside the public ABI' + } + + It 'changes the canonical hash when a public method is added' { + $baseline = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+baseline' + $changed = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+method' ` + -AddPublicMethod + + (Get-Task8AbiProbeHash -Assembly $changed) | Should -Not -BeExactly ( + Get-Task8AbiProbeHash -Assembly $baseline) + } + + It 'changes the canonical hash when an enum underlying type changes' { + $baseline = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+enum-int' + $changed = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+enum-byte' ` + -EnumUnderlyingType Byte + + (Get-Task8AbiProbeHash -Assembly $changed) | Should -Not -BeExactly ( + Get-Task8AbiProbeHash -Assembly $baseline) + } + + It 'changes the canonical hash when public member nullability changes' { + $baseline = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+not-null' + $changed = New-Task8AbiProbeAssembly -InformationalVersion '1.0.0+nullable' ` + -StringNullability Nullable + + (Get-Task8AbiProbeHash -Assembly $changed) | Should -Not -BeExactly ( + Get-Task8AbiProbeHash -Assembly $baseline) + } + + It 'accepts only the exact neutral unsigned GraphKit.Auth.Contracts assembly identity' { + $expected = [Reflection.AssemblyName]::new( + 'GraphKit.Auth.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null') + Invoke-Task8PrivateHelper ` + -FunctionName Test-GraphKitAuthParityContractsIdentity ` + -Arguments @{ Name = $expected } | Should -BeTrue + + $changedIdentities = @( + [Reflection.AssemblyName]::new( + 'GraphKit.Auth.Contracts.Changed, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null') + [Reflection.AssemblyName]::new( + 'GraphKit.Auth.Contracts, Version=1.0.0.1, Culture=neutral, PublicKeyToken=null') + [Reflection.AssemblyName]::new( + 'GraphKit.Auth.Contracts, Version=1.0.0.0, Culture=en-US, PublicKeyToken=null') + [Reflection.AssemblyName]::new( + 'GraphKit.Auth.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=0011223344556677') + ) + foreach ($identity in $changedIdentities) { + Invoke-Task8PrivateHelper ` + -FunctionName Test-GraphKitAuthParityContractsIdentity ` + -Arguments @{ Name = $identity } | Should -BeFalse + } + } +} + +Describe 'Task 8 guarded parameter and package binding' { + It 'maps a missing package to one fixed artifact failure before extraction' { + $missing = Join-Path $TestDrive 'missing.nupkg' + $result = Invoke-Task8RunnerProcess -PackagePath $missing -PackageSha256 ('a' * 64) ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected + @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | + Should -Be 0 + } + + It 'maps an existing non-nupkg file to one fixed artifact failure before extraction' { + $path = Join-Path $TestDrive 'candidate.zip' + [IO.File]::WriteAllText($path, 'task8 fixture') + $sha = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $path -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected + @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | + Should -Be 0 + + $oversized = Join-Path $TestDrive 'oversized-source.nupkg' + New-Task8SparseFile -Path $oversized -Length ([long]512MB + 1) + $oversizedResult = Invoke-Task8RunnerProcess -PackagePath $oversized ` + -PackageSha256 ('a' * 64) -AuthMode Certificate -DryRun -HookKind OversizedSource + Assert-Task8SafeFailure -Invocation $oversizedResult -Stage Artifact -Code ArtifactRejected + $trace = Get-Task8TraceRecords $oversizedResult.TracePath + @($trace | Where-Object event -eq 'source-metadata-started').Count | Should -Be 1 + @($trace | Where-Object event -eq 'source-hash-started').Count | Should -Be 0 + foreach ($eventName in @('root-created','snapshot-created','extraction-created')) { + @($trace | Where-Object event -eq $eventName).Count | Should -Be 0 + } + } + + It 'does not leak a malformed digest sentinel through any fresh-process stream' { + $fixture = New-Task8FixturePackage -Name 'malformed-digest' + $sentinel = 'task8-secret-sentinel-digest' + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sentinel ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) + @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | + Should -Be 0 + } + + It 'rejects a digest mismatch before archive extraction and never reports the unbound digest' { + $fixture = New-Task8FixturePackage -Name 'digest-mismatch' + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 ('b' * 64) ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected + @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'extraction-created').Count | + Should -Be 0 + } + + It 'does not leak an invalid auth-mode sentinel through any fresh-process stream' { + $fixture = New-Task8FixturePackage -Name 'invalid-mode' + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $sentinel = 'task8-secret-sentinel-mode' + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode $sentinel -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) + @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | + Should -Be 0 + } + + It 'does not leak an invalid live profile identifier through any fresh-process stream' { + $fixture = New-Task8FixturePackage -Name 'invalid-profile' + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $sentinel = 'task8-secret-sentinel/profile' + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -ProfileId $sentinel + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected + $result.Data.execution | Should -BeExactly 'Live' + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) + @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | + Should -Be 0 + } +} + +Describe 'Task 8 archive validation and resource bounds' { + It 'rejects unsafe archive path before extraction' -ForEach $task8UnsafeArchiveCases { + $fixture = New-Task8FixturePackage -Name ("unsafe-" + [guid]::NewGuid().ToString('N')) ` + -Entries @( + @{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + } + @{ Path = 'GraphKit.psm1'; Content = '# valid Task 8 fixture module' } + @{ Path = $EntryName; Content = 'unsafe' } + ) + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected ` + -AuthMode Certificate -PackageSha256 $sha + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + } + + It 'rejects non-portable archive segment before extraction' ` + -ForEach $task8PortableArchiveSegmentCases { + $fixture = New-Task8FixturePackage -Name ( + 'portable-segment-' + [guid]::NewGuid().ToString('N')) -Entries @( + @{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + } + @{ Path = 'GraphKit.psm1'; Content = '' } + @{ Path = $EntryName; Content = 'must-not-extract' } + ) + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected ` + -AuthMode Certificate -PackageSha256 $sha + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + } + + It 'rejects archive aliases ordinally and portably' -ForEach $task8ArchiveAliasCases { + $fixture = New-Task8FixturePackage -Name ("alias-" + [guid]::NewGuid().ToString('N')) ` + -Entries @( + @{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + } + @{ Path = 'GraphKit.psm1'; Content = '# valid Task 8 fixture module' } + @{ Path = $First; Content = 'first' } + @{ Path = $Second; Content = 'second' } + ) + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected ` + -AuthMode Certificate -PackageSha256 $sha + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + } + + It 'rejects a ZIP entry encoded as ' -ForEach $task8ArchiveLinkCases { + $fixture = New-Task8FixturePackage -Name ("link-" + [guid]::NewGuid().ToString('N')) ` + -Entries @( + @{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + ExternalAttributes = $ExternalAttributes + } + @{ Path = 'GraphKit.psm1'; Content = '# valid Task 8 fixture module' } + ) + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected ` + -AuthMode Certificate -PackageSha256 $sha + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + } + + It 'rejects a high-ratio compressed entry before allocating its declared expansion' { + $fixture = New-Task8FixturePackage -Name 'ratio-bomb' -Entries @( + @{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + } + @{ Path = 'GraphKit.psm1'; Content = '# valid Task 8 fixture module' } + @{ Path = 'Data/ratio.bin'; Content = [byte[]]::new(2MB) } + ) + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected ` + -AuthMode Certificate -PackageSha256 $sha + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + } + + It 'rejects an archive whose entry count exceeds the protected-host bound' { + $entries = [Collections.Generic.List[object]]::new() + $entries.Add(@{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + }) + $entries.Add(@{ Path = 'GraphKit.psm1'; Content = '# valid Task 8 fixture module' }) + for ($index = 0; $index -lt 4097; $index++) { + $entries.Add(@{ Path = "Data/entry-$index.txt"; Content = '' }) + } + $fixture = New-Task8FixturePackage -Name 'entry-count-bomb' -Entries $entries.ToArray() + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected ` + -AuthMode Certificate -PackageSha256 $sha + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + } + + It 'rejects a source package symbolic link or reparse alias without following it' { + $target = New-Task8FixturePackage -Name 'source-target' + $alias = Join-Path $TestDrive 'source-alias.nupkg' + $null = New-Item -ItemType SymbolicLink -Path $alias -Target $target -ErrorAction Stop + $sha = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $alias -PackageSha256 $sha ` + -AuthMode Certificate -DryRun + + Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected + (Test-Path -LiteralPath $target -PathType Leaf) | Should -BeTrue + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'root-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'snapshot-created').Count | Should -Be 0 + } + + It 'uses create-new semantics for the package snapshot destination' { + $fixture = New-Task8FixturePackage -Name 'snapshot-collision' + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun -HookKind SnapshotCollision + $root = [string]((Get-Task8TraceRecords $result.TracePath | + Where-Object event -eq 'root-created').data.root) + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -AuthMode Certificate -PackageSha256 ('0' * 64) + } + finally { Remove-Task8ResidualFixturePath -Path $root } + } + + It 'rejects same-identity snapshot bytes changed to a different valid package before archive planning' { + $fixtureEntries = @( + @{ + Path = 'GraphKit.psd1' + Content = "@{ RootModule = 'GraphKit.psm1'; ModuleVersion = '0.4.0'; PrivateData = @{ PSData = @{ Prerelease = 'r8.fixture' } } }" + } + @{ Path = 'GraphKit.psm1'; Content = '# snapshot package A' } + ) + $replacementEntries = @( + $fixtureEntries[0] + @{ Path = 'GraphKit.psm1'; Content = '# snapshot package B' } + ) + $fixture = New-Task8FixturePackage -Name 'snapshot-original' ` + -Entries $fixtureEntries -CompressionLevel NoCompression + $replacement = New-Task8FixturePackage -Name 'snapshot-replacement' ` + -Entries $replacementEntries -CompressionLevel NoCompression + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $replacementSha = (Get-FileHash -LiteralPath $replacement -Algorithm SHA256). + Hash.ToLowerInvariant() + $replacementSha | Should -Not -BeExactly $sha + (Get-Item -LiteralPath $replacement).Length | + Should -Be (Get-Item -LiteralPath $fixture).Length + + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun -HookKind SnapshotContentMutation ` + -MutationValue $replacement + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string]($trace | Where-Object event -eq 'root-created').data.root + $snapshot = [string]($trace | Where-Object event -eq 'snapshot-created').data.snapshot + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $sha + @($trace | Where-Object event -eq 'snapshot-mutated').Count | Should -Be 1 + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'imported').Count | Should -Be 0 + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + (Test-Path -LiteralPath $snapshot -PathType Leaf) | Should -BeTrue + (Get-FileHash -LiteralPath $snapshot -Algorithm SHA256).Hash.ToLowerInvariant() | + Should -BeExactly $replacementSha + } + finally { Remove-Task8ResidualFixturePath -Path $root } + } +} + +Describe 'Task 8 isolated import, routing, and cleanup' { + It 'rejects replacement during the writable extraction window before adoption' ` + -ForEach $task8PreSealMutationCases { + $fixture = New-Task8FixturePackage -Name ( + 'preseal-' + $HookKind.ToLowerInvariant() + '-' + [guid]::NewGuid().ToString('N')) + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun -HookKind $HookKind + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string]($trace | Where-Object event -eq 'root-created').data.root + $mutations = @($trace | Where-Object event -eq 'preseal-mutated') + $outside = if ($mutations.Count -eq 1) { + [string]$mutations[0].data.outside + } + else { $null } + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $sha + $mutations.Count | Should -Be 1 + $mutations[0].data.root | Should -BeExactly $root + if ($HookKind -ceq 'PreSealFileMutation') { + $mutations[0].data.relative | Should -BeExactly 'module/GraphKit.psd1' + $mutations[0].data.laterFileExists | Should -BeFalse + } + @($trace | Where-Object event -eq 'archive-plan-created').Count | Should -Be 1 + @($trace | Where-Object event -eq 'extraction-created').Count | Should -Be 0 + @($trace | Where-Object event -eq 'imported').Count | Should -Be 0 + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + if ($HasOutside) { + [string]::IsNullOrWhiteSpace($outside) | Should -BeFalse + (Test-Path -LiteralPath $outside -PathType Container) | Should -BeTrue + } + else { + [string]::IsNullOrEmpty($outside) | Should -BeTrue + } + } + finally { + Remove-Task8ResidualFixturePath -Path $root + Remove-Task8ResidualFixturePath -Path $outside + } + if ($HasOutside) { + (Test-Path -LiteralPath $outside) | Should -BeFalse + } + } + + It 'runs the exact protected DryRun route for without an external seam' -ForEach $task8AuthModes { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode $AuthMode -DryRun ` + -HookKind ExternalSeams + + $result.ExitCode | Should -Be 0 + $result.JsonCount | Should -Be 1 + $result.StdErr | Should -BeNullOrEmpty + Assert-Task8ModeRecordShape -Record $result.Data -Execution DryRun ` + -AuthMode $AuthMode -PackageSha256 $candidate.PackageSha256 + $result.Data.state | Should -BeExactly 'Passed' + $result.Data.failureStage | Should -BeExactly 'None' + $result.Data.failureCode | Should -BeExactly 'None' + $result.Data.moduleVersion | Should -BeExactly $candidate.FullVersion + foreach ($name in @( + 'packageDigestMatched','snapshotBound','archiveValidated','extractionSealed', + 'exactImport','routeMatched','cleanupVerified' + )) { + $result.Data.checks.$name | Should -BeTrue + } + foreach ($name in @('contextMatched','sourceMatched','tenantProofVerified')) { + $result.Data.checks.$name | Should -BeFalse + } + @($result.Data.adapter.PSObject.Properties.Value | Where-Object { -not $_ }).Count | + Should -Be 0 + $result.Data.read.attempted | Should -BeFalse + $result.Data.read.succeeded | Should -BeFalse + $result.Data.read.rowCount | Should -Be 0 + @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'forbidden-seam').Count | + Should -Be 0 + $result.Output | Should -Not -Match ([regex]::Escape($script:repoRoot)) + $result.Output | Should -Not -Match ([regex]::Escape($TestDrive)) + } + + It 'refuses an already loaded GraphKit module without removing the caller-owned module' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind PreloadedGraphKit + + Assert-Task8SafeFailure -Invocation $result -Stage Import -Code ImportRejected + $trace = Get-Task8TraceRecords $result.TracePath + @($trace | Where-Object event -eq 'root-created').Count | Should -Be 0 + ($trace | Where-Object event -eq 'wrapper-finished').data.preloadedStillLoaded | + Should -BeTrue + } + + It 'rejects an extracted-file digest mutation at the immediate pre-import recheck' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind ExtractedMutation + $root = [string]((Get-Task8TraceRecords $result.TracePath | + Where-Object event -eq 'root-created').data.root) + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + } + finally { Remove-Task8ResidualFixturePath -Path $root } + + foreach ($hookKind in @( + 'FinalImportContentMutation','FinalImportWritableMutation', + 'FinalImportClosureMutation','FinalImportHardLinkMutation' + )) { + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind $hookKind + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string]($trace | Where-Object event -eq 'root-created').data.root + $outsideRecords = @($trace | Where-Object event -eq 'mutation-outside-created') + $outsideRecords.Count | Should -Be $(if ( + $hookKind -ceq 'FinalImportHardLinkMutation') { 1 } else { 0 }) + $outside = if ($outsideRecords.Count -eq 1) { + [string]$outsideRecords[0].data.path + } + else { $null } + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + @($trace | Where-Object event -eq 'final-import-mutated').Count | Should -Be 1 + @($trace | Where-Object event -eq 'imported').Count | Should -Be 0 + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + } + finally { + Remove-Task8OwnedHardLinkFixtureTree ` + -OutsidePath $outside -RootPath $root + } + if ($null -ne $outside) { + (Test-Path -LiteralPath $outside) | Should -BeFalse + } + } + } + + It 'rejects writable extracted content at the immediate pre-import recheck' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind ExtractedWritable + $root = [string]((Get-Task8TraceRecords $result.TracePath | + Where-Object event -eq 'root-created').data.root) + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + } + finally { Remove-Task8ResidualFixturePath -Path $root } + + foreach ($hookKind in @( + 'CleanupContentMutation','CleanupWritableMutation', + 'CleanupClosureMutation','CleanupHardLinkMutation' + )) { + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind $hookKind + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string]($trace | Where-Object event -eq 'root-created').data.root + $outsideRecords = @($trace | Where-Object event -eq 'mutation-outside-created') + $outsideRecords.Count | Should -Be $(if ( + $hookKind -ceq 'CleanupHardLinkMutation') { 1 } else { 0 }) + $outside = if ($outsideRecords.Count -eq 1) { + [string]$outsideRecords[0].data.path + } + else { $null } + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + @($trace | Where-Object event -eq 'cleanup-mutated').Count | Should -Be 1 + @($trace | Where-Object event -eq 'imported').Count | Should -Be 1 + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + } + finally { + Remove-Task8OwnedHardLinkFixtureTree ` + -OutsidePath $outside -RootPath $root + } + if ($null -ne $outside) { + (Test-Path -LiteralPath $outside) | Should -BeFalse + } + } + } + + It 'refuses same-identity per-file cleanup mutation and preserves the root' ` + -ForEach $task8CleanupFileMutationCases { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind $HookKind + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string]($trace | Where-Object event -eq 'root-created').data.root + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + $mutations = @($trace | Where-Object event -eq 'cleanup-file-mutated') + $mutations.Count | Should -Be 1 + $mutations[0].data.root | Should -BeExactly $root + $mutations[0].data.relative | Should -BeExactly 'module/GraphKit.psm1' + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + (Test-Path -LiteralPath (Join-Path $root 'module/GraphKit.psm1') -PathType Leaf) | + Should -BeTrue + } + finally { Remove-Task8ResidualFixturePath -Path $root } + } + + It 'refuses and preserves both ambiguous container identities' ` + -ForEach $task8CleanupContainerMutationCases { + $fixture = New-Task8FixturePackage -Name ( + 'cleanup-container-' + $HookKind.ToLowerInvariant() + '-' + + [guid]::NewGuid().ToString('N')) + $sha = (Get-FileHash -LiteralPath $fixture -Algorithm SHA256).Hash.ToLowerInvariant() + $result = Invoke-Task8RunnerProcess -PackagePath $fixture -PackageSha256 $sha ` + -AuthMode Certificate -DryRun -HookKind $HookKind + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string]($trace | Where-Object event -eq 'root-created').data.root + $mutations = @($trace | Where-Object event -eq 'cleanup-container-mutated') + $outside = if ($mutations.Count -eq 1) { + [string]$mutations[0].data.outside + } + else { $null } + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $sha + $mutations.Count | Should -Be 1 + $mutations[0].data.phase | Should -BeExactly $Phase + $mutations[0].data.relative | Should -BeExactly $Relative + $mutations[0].data.root | Should -BeExactly $root + [string]::IsNullOrWhiteSpace($outside) | Should -BeFalse + (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue + (Test-Path -LiteralPath $outside -PathType Container) | Should -BeTrue + if ($Relative -ceq 'module') { + (Test-Path -LiteralPath (Join-Path $root 'module') -PathType Container) | + Should -BeTrue + } + if ($Phase -ceq 'AfterWritable') { + (Test-Path -LiteralPath (Join-Path $root 'module/GraphKit.psm1') -PathType Leaf) | + Should -BeTrue + } + } + finally { + Remove-Task8ResidualFixturePath -Path $root + Remove-Task8ResidualFixturePath -Path $outside + } + (Test-Path -LiteralPath $root) | Should -BeFalse + (Test-Path -LiteralPath $outside) | Should -BeFalse + } + + It 'rejects a byte-identical extracted-file replacement by native identity' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind ExtractedFileReplacement + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string] ($trace | Where-Object event -eq 'root-created').data.root + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + } + finally { + Remove-Task8ResidualFixturePath -Path $root + } + } + + It 'rejects a hard-link substitution and never deletes its outside target' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind ExtractedHardLink + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string] ($trace | Where-Object event -eq 'root-created').data.root + $outside = [string] ($trace | Where-Object event -eq 'link-substituted').data.outside + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + (Test-Path -LiteralPath $outside -PathType Leaf) | Should -BeTrue + } + finally { + Remove-Task8ResidualFixturePath -Path $root + Remove-Task8ResidualFixturePath -Path $outside + } + } + + It 'rejects an extracted module-directory replacement by exact identity' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind ModuleDirectoryReplacement + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string] ($trace | Where-Object event -eq 'root-created').data.root + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + } + finally { + Remove-Task8ResidualFixturePath -Path $root + } + } + + It 'rejects an extraction-root replacement and refuses ambiguous cleanup' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind RootReplacement + $trace = Get-Task8TraceRecords $result.TracePath + $replacement = [string] ($trace | Where-Object event -eq 'root-replaced').data.replacement + $backup = [string] ($trace | Where-Object event -eq 'root-replaced').data.backup + try { + Assert-Task8SafeFailure -Invocation $result -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + (Test-Path -LiteralPath $replacement -PathType Container) | Should -BeTrue + (Test-Path -LiteralPath $backup -PathType Container) | Should -BeTrue + } + finally { + Remove-Task8ResidualFixturePath -Path $replacement + Remove-Task8ResidualFixturePath -Path $backup + } + } + + It 'isolates two sequential imports from the cleanup owner and preserves only outside siblings' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind OutsideSentinel -Repeat 2 + $trace = Get-Task8TraceRecords $result.TracePath + $roots = @($trace | Where-Object event -eq 'root-created') + $outside = @($trace | Where-Object event -eq 'outside-created') + $imports = @($trace | Where-Object event -eq 'imported') + $exits = @($trace | Where-Object event -eq 'worker-exited') + $cleanups = @($trace | Where-Object event -eq 'cleanup-started') + $afterRuns = @($trace | Where-Object event -eq 'parent-after-run') + try { + $result.ExitCode | Should -Be 0 + $result.OutputLineCount | Should -Be 2 + $result.JsonCount | Should -Be 2 + $result.DataRecords.Count | Should -Be 2 + @($result.DataRecords | Where-Object state -cne 'Passed').Count | Should -Be 0 + $roots.Count | Should -Be 2 + $outside.Count | Should -Be 2 + $imports.Count | Should -Be 2 + $exits.Count | Should -Be 2 + $cleanups.Count | Should -Be 2 + $afterRuns.Count | Should -Be 2 + foreach ($index in 0..1) { + [int]$imports[$index].data.processId | + Should -Not -Be ([int]$cleanups[$index].data.processId) + [int]$exits[$index].data.workerProcessId | + Should -Be ([int]$imports[$index].data.processId) + [int]$exits[$index].data.processId | + Should -Be ([int]$cleanups[$index].data.processId) + [int]$cleanups[$index].data.processId | + Should -Be ([int]$afterRuns[$index].data.processId) + [int]$afterRuns[$index].data.graphKitCount | Should -Be 0 + [int]$afterRuns[$index].data.contractsCount | Should -Be 0 + (Test-Path -LiteralPath ([string]$roots[$index].data.root)) | Should -BeFalse + (Test-Path -LiteralPath ([string]$outside[$index].data.path) -PathType Leaf) | + Should -BeTrue + [Array]::IndexOf($trace, $imports[$index]) | + Should -BeLessThan ([Array]::IndexOf($trace, $exits[$index])) + [Array]::IndexOf($trace, $exits[$index]) | + Should -BeLessThan ([Array]::IndexOf($trace, $cleanups[$index])) + } + $finished = $trace | Where-Object event -eq 'wrapper-finished' + $finished.data.modulePathRestored | Should -BeTrue + $finished.data.graphKitLoaded | Should -BeFalse + } + finally { + foreach ($record in $outside) { + $outsidePath = [string]$record.data.path + if (Test-Path -LiteralPath $outsidePath -PathType Leaf) { + Remove-Item -LiteralPath $outsidePath -Force + } + } + } + + $absent = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind AbsentModulePath + $absent.JsonCount | Should -Be 1 + $absent.OutputLineCount | Should -Be 1 + $absentFinished = Get-Task8TraceRecords $absent.TracePath | + Where-Object event -eq 'wrapper-finished' + $absentFinished.data.modulePathRestored | Should -BeTrue + $absentFinished.data.modulePathPresent | Should -BeFalse + + $treeRun = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind WorkerGrandchild + $treeTrace = Get-Task8TraceRecords $treeRun.TracePath + $treeRoot = @($treeTrace | Where-Object event -eq 'root-created') + $treeOutside = @($treeTrace | Where-Object event -eq 'outside-created') + $grandchild = @($treeTrace | Where-Object event -eq 'grandchild-ready') + $rootExit = @($treeTrace | Where-Object event -eq 'worker-root-exited') + $termination = @($treeTrace | + Where-Object event -eq 'worker-tree-termination-requested') + $treeExit = @($treeTrace | Where-Object event -eq 'worker-tree-exit-confirmed') + $authorizedExit = @($treeTrace | Where-Object event -eq 'worker-exited') + $treeCleanup = @($treeTrace | Where-Object event -eq 'cleanup-started') + try { + Assert-Task8SafeFailure -Invocation $treeRun ` + -Stage Diagnostics -Code DiagnosticsRejected ` + -PackageSha256 $candidate.PackageSha256 + $treeRun.Data.checks.cleanupVerified | Should -BeTrue + $treeRoot.Count | Should -Be 1 + $treeOutside.Count | Should -Be 1 + $grandchild.Count | Should -Be 1 + $rootExit.Count | Should -Be 1 + $termination.Count | Should -Be 1 + $treeExit.Count | Should -Be 1 + $authorizedExit.Count | Should -Be 1 + $treeCleanup.Count | Should -Be 1 + [bool]$termination[0].data.residualTreeDetected | Should -BeTrue + [bool]$treeExit[0].data.terminationRequested | Should -BeTrue + [bool]$treeExit[0].data.streamsDrained | Should -BeTrue + [bool]$authorizedExit[0].data.ownershipEstablished | Should -BeTrue + [bool]$authorizedExit[0].data.requestReleased | Should -BeTrue + [bool]$authorizedExit[0].data.rootExitConfirmed | Should -BeTrue + [bool]$authorizedExit[0].data.treeExitConfirmed | Should -BeTrue + [bool]$authorizedExit[0].data.streamsDrained | Should -BeTrue + [string]$authorizedExit[0].data.protocolFailure | + Should -BeExactly 'ResidualTree' + [Array]::IndexOf($treeTrace, $grandchild[0]) | + Should -BeLessThan ([Array]::IndexOf($treeTrace, $rootExit[0])) + [Array]::IndexOf($treeTrace, $rootExit[0]) | + Should -BeLessThan ([Array]::IndexOf($treeTrace, $termination[0])) + [Array]::IndexOf($treeTrace, $termination[0]) | + Should -BeLessThan ([Array]::IndexOf($treeTrace, $treeExit[0])) + [Array]::IndexOf($treeTrace, $treeExit[0]) | + Should -BeLessThan ([Array]::IndexOf($treeTrace, $treeCleanup[0])) + (Test-Path -LiteralPath ([string]$treeRoot[0].data.root)) | Should -BeFalse + (Test-Path -LiteralPath ([string]$treeOutside[0].data.path) -PathType Leaf) | + Should -BeTrue + $grandchildAlive = $false + try { + $probe = [Diagnostics.Process]::GetProcessById( + [int]$grandchild[0].data.processId) + try { $grandchildAlive = -not $probe.HasExited } + finally { $probe.Dispose() } + } + catch [ArgumentException] {} + $grandchildAlive | Should -BeFalse + } + finally { + if ($grandchild.Count -eq 1) { + try { + $rescue = [Diagnostics.Process]::GetProcessById( + [int]$grandchild[0].data.processId) + try { + if (-not $rescue.HasExited -and + $rescue.StartTime.ToUniversalTime().Ticks -eq + [long]$grandchild[0].data.startTimeUtcTicks) { + $rescue.Kill($true) + $null = $rescue.WaitForExit(5000) + } + } + finally { $rescue.Dispose() } + } + catch [ArgumentException] {} + } + foreach ($record in $treeOutside) { + $outsidePath = [string]$record.data.path + if (Test-Path -LiteralPath $outsidePath -PathType Leaf) { + Remove-Item -LiteralPath $outsidePath -Force + } + } + } + + if (-not $IsWindows) { + $escapeClock = [Diagnostics.Stopwatch]::StartNew() + $escapeRun = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind WorkerSessionEscape + $escapeClock.Stop() + $escapeTrace = Get-Task8TraceRecords $escapeRun.TracePath + $escapeRoot = @($escapeTrace | Where-Object event -eq 'root-created') + $escapeOutside = @($escapeTrace | Where-Object event -eq 'outside-created') + $escapedChild = @($escapeTrace | Where-Object event -eq 'grandchild-ready') + try { + Assert-Task8SafeFailure -Invocation $escapeRun ` + -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + # This is an outer anti-hang ceiling, not the collector's operation + # deadline. It includes fresh pwsh startup, package staging, archive + # verification, extraction, and native-helper compilation. + $escapeClock.Elapsed.TotalSeconds | Should -BeLessThan 30 + $escapeRoot.Count | Should -Be 1 + $escapeOutside.Count | Should -Be 1 + $escapedChild.Count | Should -Be 1 + [bool]$escapedChild[0].data.escapedSession | Should -BeTrue + @($escapeTrace | Where-Object event -eq 'worker-root-exited').Count | + Should -Be 1 + @($escapeTrace | Where-Object event -eq 'worker-tree-exit-confirmed').Count | + Should -Be 0 + @($escapeTrace | Where-Object event -eq 'worker-exited').Count | + Should -Be 0 + @($escapeTrace | Where-Object event -eq 'cleanup-started').Count | + Should -Be 0 + (Test-Path -LiteralPath ([string]$escapeRoot[0].data.root) -PathType Container) | + Should -BeTrue + (Test-Path -LiteralPath ([string]$escapeOutside[0].data.path) -PathType Leaf) | + Should -BeTrue + } + finally { + if ($escapedChild.Count -eq 1) { + try { + $rescue = [Diagnostics.Process]::GetProcessById( + [int]$escapedChild[0].data.processId) + try { + if (-not $rescue.HasExited -and + $rescue.StartTime.ToUniversalTime().Ticks -eq + [long]$escapedChild[0].data.startTimeUtcTicks) { + $rescue.Kill($true) + $null = $rescue.WaitForExit(5000) + } + } + finally { $rescue.Dispose() } + } + catch [ArgumentException] {} + } + foreach ($record in $escapeRoot + $escapeOutside) { + $path = if ($record.event -ceq 'root-created') { + [string]$record.data.root + } + else { [string]$record.data.path } + Remove-Task8ResidualFixturePath -Path $path + } + } + } + } +} + +Describe 'Task 8 protected-live prerequisites' { + It 'pins the selected Verified GET control flow from public route through proof before send' { + $descriptor = Import-PowerShellDataFile -Path ( + Join-Path $script:repoRoot 'source/Data/Operations/ManagedDevice.List.psd1') + [string]$descriptor.IdentityRequirement | Should -BeExactly 'Verified' + [string]$descriptor.PagingStrategy | Should -BeExactly 'NextLink' + Assert-Task8VerifiedGetProofControlFlow -SourceRoot ( + Join-Path $script:repoRoot 'source') | Should -BeTrue + + $mutationRoot = Join-Path $TestDrive ( + 'task8-proof-control-' + [guid]::NewGuid().ToString('N')) + $null = [IO.Directory]::CreateDirectory((Join-Path $mutationRoot 'Public')) + $null = [IO.Directory]::CreateDirectory((Join-Path $mutationRoot 'Private/Transport')) + $relativePaths = @( + 'Public/Get-GraphObject.ps1' + 'Private/Invoke-GraphRetry.ps1' + 'Private/Transport/Send-GraphHttpRequest.ps1' + ) + foreach ($relative in $relativePaths) { + [IO.File]::Copy( + (Join-Path (Join-Path $script:repoRoot 'source') $relative), + (Join-Path $mutationRoot $relative), $false) + } + + $retryPath = Join-Path $mutationRoot 'Private/Invoke-GraphRetry.ps1' + $retryOriginal = [IO.File]::ReadAllText($retryPath) + $retryMutation = $retryOriginal.Replace( + "([string] `$Descriptor.IdentityRequirement -ceq 'Verified')", '$false') + $retryMutation | Should -Not -BeExactly $retryOriginal + [IO.File]::WriteAllText($retryPath, $retryMutation, [Text.UTF8Encoding]::new($false)) + { Assert-Task8VerifiedGetProofControlFlow -SourceRoot $mutationRoot } | Should -Throw + [IO.File]::WriteAllText($retryPath, $retryOriginal, [Text.UTF8Encoding]::new($false)) + + $senderPath = Join-Path $mutationRoot 'Private/Transport/Send-GraphHttpRequest.ps1' + $senderOriginal = [IO.File]::ReadAllText($senderPath) + $awayMutation = $senderOriginal.Replace( + 'Confirm-GraphTenantBinding', 'Confirm-GraphTenantBindingRemoved') + $awayMutation | Should -Not -BeExactly $senderOriginal + [IO.File]::WriteAllText($senderPath, $awayMutation, [Text.UTF8Encoding]::new($false)) + { Assert-Task8VerifiedGetProofControlFlow -SourceRoot $mutationRoot } | Should -Throw + + $tokens = $null + $errors = $null + $awayAst = [Management.Automation.Language.Parser]::ParseFile( + $senderPath, [ref]$tokens, [ref]$errors) + @($errors).Count | Should -Be 0 + $senderFunction = @($awayAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Send-GraphHttpRequest' + }, $true))[0] + $insertAt = $senderFunction.Body.Extent.EndOffset - 1 + $movedMutation = $awayMutation.Insert( + $insertAt, "`n Confirm-GraphTenantBinding`n") + [IO.File]::WriteAllText($senderPath, $movedMutation, [Text.UTF8Encoding]::new($false)) + { Assert-Task8VerifiedGetProofControlFlow -SourceRoot $mutationRoot } | Should -Throw + [IO.File]::WriteAllText($senderPath, $senderOriginal, [Text.UTF8Encoding]::new($false)) + Assert-Task8VerifiedGetProofControlFlow -SourceRoot $mutationRoot | Should -BeTrue + } + + It 'runs the actual top-level Live branch through exact imported public commands' { + $candidate = Get-Task8PackedCandidate + $storePath = Join-Path $TestDrive 'task8-package-live-store.json' + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -StorePath $storePath -HookKind PackageLiveSuccess + + $result.ExitCode | Should -Be 0 + $result.OutputLineCount | Should -Be 1 + $result.JsonCount | Should -Be 1 + $result.StdErr | Should -BeNullOrEmpty + Assert-Task8ModeRecordShape -Record $result.Data -Execution Live ` + -AuthMode Certificate -PackageSha256 $candidate.PackageSha256 + $result.Data.state | Should -BeExactly 'Passed' + $result.Data.failureStage | Should -BeExactly 'None' + $result.Data.failureCode | Should -BeExactly 'None' + $result.Data.checks.contextMatched | Should -BeTrue + $result.Data.checks.sourceMatched | Should -BeTrue + $result.Data.checks.tenantProofVerified | Should -BeTrue + $result.Data.read.attempted | Should -BeTrue + $result.Data.read.succeeded | Should -BeTrue + $result.Data.read.rowCount | Should -Be 2 + + $trace = Get-Task8TraceRecords $result.TracePath + $context = @($trace | Where-Object event -eq 'context-command') + $source = @($trace | Where-Object event -eq 'source-created') + $read = @($trace | Where-Object event -eq 'read-command') + $context.Count | Should -Be 1 + $context[0].data.storePath | Should -BeExactly $storePath + $source.Count | Should -Be 1 + $source[0].data.authMethod | Should -BeExactly 'Certificate' + $read.Count | Should -Be 1 + $read[0].data.type | Should -BeExactly 'ManagedDevice' + $read[0].data.operation | Should -BeExactly 'List' + $read[0].data.maxPages | Should -Be 200 + $read[0].data.firstPageAuthority | Should -BeExactly 'graph.microsoft.com' + $result.Output | Should -Not -Match ( + 'task8-fixture-token-fingerprint|task8-fixture-generation|task8-package-row') + } +} + +Describe 'Task 8 injected live core and closed read evidence' { + It 'validates the exact compiled source and performs one bounded public read for ' -ForEach $task8AuthModes { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode $AuthMode ` + -ProfileId task8-fixture -HookKind LiveSuccess + + $result.ExitCode | Should -Be 0 + $result.OutputLineCount | Should -Be 1 + $result.JsonCount | Should -Be 1 + $result.StdErr | Should -BeNullOrEmpty + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode $AuthMode ` + -State Passed -FailureStage None -FailureCode None + $result.Data.contextMatched | Should -BeTrue + $result.Data.sourceMatched | Should -BeTrue + $result.Data.tenantProofVerified | Should -BeTrue + $result.Data.readAttempted | Should -BeTrue + $result.Data.readSucceeded | Should -BeTrue + $result.Data.rowCount | Should -Be 2 + $trace = Get-Task8TraceRecords $result.TracePath + $contextTrace = @($trace | Where-Object event -eq 'context') + $contextTrace.Count | Should -Be 1 + $contextTrace[0].data.identityState | Should -BeExactly 'NotAcquired' + $read = @($trace | Where-Object event -eq 'read') + $read.Count | Should -Be 1 + $read[0].data.type | Should -BeExactly 'ManagedDevice' + $read[0].data.operation | Should -BeExactly 'List' + $read[0].data.passThruResult | Should -BeTrue + $result.Output | Should -Not -Match 'task8-row-secret|task8-secret-sentinel' + } + + It 'rejects a source that does not implement the exact compiled interface' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -HookKind LiveInterfaceMismatch + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode Certificate ` + -State Failed -FailureStage Context -FailureCode ContextRejected + } + + It 'rejects a source whose selected and reported auth modes differ' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode ManagedIdentity ` + -ProfileId task8-fixture -HookKind LiveModeMismatch + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode ManagedIdentity ` + -State Failed -FailureStage Context -FailureCode ContextRejected + } + + It 'rejects a source whose refresh behavior differs from the literal route' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode BearerToken ` + -ProfileId task8-fixture -HookKind LiveRefreshMismatch + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode BearerToken ` + -State Failed -FailureStage Context -FailureCode ContextRejected + } + + It 'maps a structured adapter acquisition failure without copying the exception' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -HookKind LiveAcquisitionFailure + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode Certificate ` + -State Failed -FailureStage Acquisition -FailureCode AcquisitionFailed + $result.Output | Should -Not -Match 'task8_fixture_acquisition|task8-secret-sentinel|GraphAuthException' + } + + It 'rejects a non-success read envelope' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -HookKind LiveFailedEnvelope + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode Certificate ` + -State Failed -FailureStage Read -FailureCode ReadFailed + } + + It 'rejects a success envelope whose certainty is not Known' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -HookKind LiveIndeterminate + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode Certificate ` + -State Failed -FailureStage Read -FailureCode ReadFailed + } + + It 'rejects a truncated paged result even when its outcome says Succeeded' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -HookKind LiveTruncated + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode Certificate ` + -State Failed -FailureStage Read -FailureCode ReadFailed + } + + It 'rejects unverified and each mismatched tenant provenance component' { + $candidate = Get-Task8PackedCandidate + foreach ($hookKind in @( + 'LiveUnverified','LiveTargetTenantMismatch','LiveActualTenantMismatch', + 'LiveSourceTenantMismatch')) { + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -HookKind $hookKind + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode Certificate ` + -State Failed -FailureStage Read -FailureCode ReadFailed + } + } + + It 'rejects independently invalid exact live proof ' ` + -ForEach $task8LiveProofRejectionCases { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate ` + -ProfileId task8-fixture -HookKind $HookKind + + Assert-Task8LiveCoreResult -Record $result.Data -AuthMode Certificate ` + -State Failed -FailureStage $FailureStage -FailureCode $( + if ($FailureStage -ceq 'Context') { 'ContextRejected' } else { 'ReadFailed' }) + $result.Output | Should -Not -Match ( + 'task8-fixture-token-fingerprint|task8-fixture-generation|00000000-0000-0000-0000-000000000333') + } +} + +Describe 'Task 8 evidence schema and stream guard' { + It 'rejects a regex-valid module version containing a forbidden string directly' { + $record = New-Task8ModeRecordFixture -ModuleVersion '0.4.0-task8-secret-sentinel' + { + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityEvidence ` + -Arguments @{ Record = $record } + } | Should -Throw + } + + It 'sanitizes a forbidden regex-valid module version in the evidence fallback' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind EvidenceMutation -MutationValue '0.4.0-task8-secret-sentinel' + + Assert-Task8SafeFailure -Invocation $result -Stage Evidence -Code EvidenceRejected ` + -PackageSha256 $candidate.PackageSha256 + $result.Data.moduleVersion | Should -BeExactly '0.0.0-rejected' + $result.Output | Should -Not -Match 'task8-secret-sentinel' + } + + It 'rejects evidence mutation and emits only the fixed safe evidence failure' -ForEach $task8EvidenceMutationCases { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind EvidenceMutation -MutationValue $Value + + Assert-Task8SafeFailure -Invocation $result -Stage Evidence -Code EvidenceRejected ` + -PackageSha256 $candidate.PackageSha256 + $result.Output | Should -Not -Match ([regex]::Escape($Value)) + } + + It 'captures success, error, warning, verbose, debug, information, and host sentinels' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind StreamSentinel + + $result.ExitCode | Should -Be 0 + $result.JsonCount | Should -Be 1 + $result.Output | Should -Not -Match 'task8-secret-sentinel' + $streamExit = @(Get-Task8TraceRecords $result.TracePath | + Where-Object event -eq 'worker-exited') + $streamExit.Count | Should -Be 1 + $streamExit[0].data.protocolFailure | Should -BeExactly 'None' + $result.Data.state | Should -BeExactly 'Passed' + @((Get-Task8TraceRecords $result.TracePath) | + Where-Object event -eq 'stream-sentinel-fired').Count | Should -Be 1 + $requestFixturePath = $result.TracePath + '.worker-request.json' + (Test-Path -LiteralPath $requestFixturePath -PathType Leaf) | Should -BeTrue + $requestFixtureJson = [IO.File]::ReadAllText($requestFixturePath) + $requestFixture = $requestFixtureJson | ConvertFrom-Json -Depth 32 -NoEnumerate + $convertedRequest = Invoke-Task8PrivateHelper ` + -FunctionName ConvertFrom-GraphKitAuthParityWorkerState ` + -Arguments @{ Request = $requestFixture } + [string]$convertedRequest.State.CandidateSha256 | + Should -BeExactly $candidate.PackageSha256 + foreach ($collectionName in @( + 'expectedFiles','expectedDirectories','fileEvidence','directoryEvidence')) { + $scalarRequest = $requestFixtureJson | ConvertFrom-Json -Depth 32 -NoEnumerate + $scalarRequest.state.$collectionName = @( + $scalarRequest.state.$collectionName)[0] + { + Invoke-Task8PrivateHelper ` + -FunctionName ConvertFrom-GraphKitAuthParityWorkerState ` + -Arguments @{ Request = $scalarRequest } + } | Should -Throw + } + $digestMismatchRequest = $requestFixtureJson | + ConvertFrom-Json -Depth 32 -NoEnumerate + $digestMismatchRequest.packageSha256 = ('f' * 64) + { + Invoke-Task8PrivateHelper ` + -FunctionName ConvertFrom-GraphKitAuthParityWorkerState ` + -Arguments @{ Request = $digestMismatchRequest } + } | Should -Throw + foreach ($requestMutation in @( + 'missing-top','unknown-top','wrong-type','missing-state','wrong-path')) { + $mutantRequest = $requestFixtureJson | + ConvertFrom-Json -Depth 32 -NoEnumerate + switch ($requestMutation) { + 'missing-top' { + $mutantRequest.PSObject.Properties.Remove('profileId') + } + 'unknown-top' { + $mutantRequest | Add-Member -MemberType NoteProperty ` + -Name unknown -Value $true + } + 'wrong-type' { $mutantRequest.storePathBound = 'false' } + 'missing-state' { + $mutantRequest.state.PSObject.Properties.Remove('sealed') + } + 'wrong-path' { + $mutantRequest.state.moduleRoot = + Join-Path $mutantRequest.state.rootPath 'different-module' + } + } + { + Invoke-Task8PrivateHelper ` + -FunctionName ConvertFrom-GraphKitAuthParityWorkerState ` + -Arguments @{ Request = $mutantRequest } + } | Should -Throw + } + + $ordinary = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind EvidenceMutation -MutationValue task8-secret-sentinel -OrdinaryExecution + $ordinary.OutputLineCount | Should -Be 1 + $ordinary.JsonCount | Should -Be 1 + $ordinary.Data.state | Should -BeExactly 'Passed' + $ordinary.Data.failureStage | Should -BeExactly 'None' + $ordinary.Data.failureCode | Should -BeExactly 'None' + + $workerProtocolCases = @( + @{ HookKind = 'WorkerExtraBlankFrame'; ProtocolFailure = 'Frame' } + @{ HookKind = 'WorkerBomFrame'; ProtocolFailure = 'Frame' } + @{ HookKind = 'WorkerSecondFrame'; ProtocolFailure = 'Frame' } + @{ HookKind = 'WorkerMissingTerminator'; ProtocolFailure = 'Frame' } + @{ HookKind = 'WorkerEmptyFrame'; ProtocolFailure = 'Frame' } + @{ HookKind = 'WorkerInvalidUtf8'; ProtocolFailure = 'StreamDecode' } + @{ HookKind = 'WorkerStderr'; ProtocolFailure = 'Stderr' } + @{ HookKind = 'WorkerStdoutOverflow'; ProtocolFailure = 'StdoutBound' } + @{ HookKind = 'WorkerStderrOverflow'; ProtocolFailure = 'StderrBound' } + @{ HookKind = 'WorkerNonzeroExit'; ProtocolFailure = 'ExitCode' } + @{ HookKind = 'WorkerRequestTrailingLf'; ProtocolFailure = 'Validation' } + @{ HookKind = 'WorkerRequestBom'; ProtocolFailure = 'Validation' } + ) + foreach ($protocolCase in $workerProtocolCases) { + $protocolResult = Invoke-Task8RunnerProcess ` + -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 ` + -AuthMode Certificate -DryRun -HookKind $protocolCase.HookKind + Assert-Task8SafeFailure -Invocation $protocolResult ` + -Stage Diagnostics -Code DiagnosticsRejected ` + -PackageSha256 $candidate.PackageSha256 + $protocolResult.Output | Should -Not -Match 'task8-secret-sentinel' + $protocolResult.Data.checks.cleanupVerified | Should -BeTrue + $protocolExit = @(Get-Task8TraceRecords $protocolResult.TracePath | + Where-Object event -eq 'worker-exited') + $protocolExit.Count | Should -Be 1 + $protocolExit[0].data.protocolFailure | + Should -BeExactly $protocolCase.ProtocolFailure + $protocolExit[0].data.treeExitConfirmed | Should -BeTrue + $protocolExit[0].data.streamsDrained | Should -BeTrue + } + + $versionMismatch = Invoke-Task8RunnerProcess ` + -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 ` + -AuthMode Certificate -DryRun -HookKind WorkerRequestVersionMismatch + Assert-Task8SafeFailure -Invocation $versionMismatch ` + -Stage Import -Code ImportRejected ` + -PackageSha256 $candidate.PackageSha256 + $versionMismatch.Data.checks.cleanupVerified | Should -BeTrue + $versionMismatchExit = @(Get-Task8TraceRecords $versionMismatch.TracePath | + Where-Object event -eq 'worker-exited') + $versionMismatchExit.Count | Should -Be 1 + $versionMismatchExit[0].data.protocolFailure | Should -BeExactly 'None' + $versionMismatchTrace = Get-Task8TraceRecords $versionMismatch.TracePath + @($versionMismatchTrace | Where-Object event -eq 'imported').Count | Should -Be 0 + $versionMismatch.Output | Should -Not -Match '0\.4\.0-r8\.other' + + $pathMismatch = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind WorkerPathMismatch + Assert-Task8SafeFailure -Invocation $pathMismatch ` + -Stage Diagnostics -Code DiagnosticsRejected ` + -PackageSha256 $candidate.PackageSha256 + $pathMismatch.Data.checks.cleanupVerified | Should -BeTrue + + $postStart = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind PostStartSetupFailure + Assert-Task8SafeFailure -Invocation $postStart ` + -Stage Diagnostics -Code DiagnosticsRejected ` + -PackageSha256 $candidate.PackageSha256 + $postStartTrace = Get-Task8TraceRecords $postStart.TracePath + $postStartSetup = @($postStartTrace | Where-Object event -eq 'worker-setup-started') + $postStartExit = @($postStartTrace | Where-Object event -eq 'worker-exited') + $postStartCleanup = @($postStartTrace | Where-Object event -eq 'cleanup-started') + $postStartSetup.Count | Should -Be 1 + $postStartExit.Count | Should -Be 1 + $postStartCleanup.Count | Should -Be 1 + [int]$postStartExit[0].data.workerProcessId | + Should -Be ([int]$postStartSetup[0].data.processId) + [Array]::IndexOf($postStartTrace, $postStartExit[0]) | + Should -BeLessThan ([Array]::IndexOf($postStartTrace, $postStartCleanup[0])) + + $noReadClock = [Diagnostics.Stopwatch]::StartNew() + $noRead = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind WorkerNoRead + $noReadClock.Stop() + Assert-Task8SafeFailure -Invocation $noRead ` + -Stage Diagnostics -Code DiagnosticsRejected ` + -PackageSha256 $candidate.PackageSha256 + $noReadClock.Elapsed.TotalSeconds | Should -BeLessThan 30 + $noRead.Data.checks.cleanupVerified | Should -BeTrue + $noReadTrace = Get-Task8TraceRecords $noRead.TracePath + $noReadExit = @($noReadTrace | Where-Object event -eq 'worker-exited') + $noReadCleanup = @($noReadTrace | Where-Object event -eq 'cleanup-started') + $noReadExit.Count | Should -Be 1 + $noReadExit[0].data.forcedTermination | Should -BeTrue + $noReadExit[0].data.protocolFailure | Should -BeExactly 'Timeout' + [long]$noReadExit[0].data.operationDeadlineMilliseconds | Should -Be 3000 + [long]$noReadExit[0].data.hardDeadlineMilliseconds | Should -Be 8000 + [long]$noReadExit[0].data.elapsedMilliseconds | + Should -BeGreaterOrEqual ( + [long]$noReadExit[0].data.operationDeadlineMilliseconds) + [long]$noReadExit[0].data.elapsedMilliseconds | Should -BeLessOrEqual 10000 + $noReadCleanup.Count | Should -Be 1 + [Array]::IndexOf($noReadTrace, $noReadExit[0]) | + Should -BeLessThan ([Array]::IndexOf($noReadTrace, $noReadCleanup[0])) + + $pollClock = [Diagnostics.Stopwatch]::StartNew() + $pollFailure = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind WorkerPermanentPollFailure + $pollClock.Stop() + $pollTrace = Get-Task8TraceRecords $pollFailure.TracePath + $pollRoot = @($pollTrace | Where-Object event -eq 'root-created') + try { + Assert-Task8SafeFailure -Invocation $pollFailure ` + -Stage Cleanup -Code CleanupFailed ` + -PackageSha256 $candidate.PackageSha256 + # The collector hard bound is enforced independently; this wrapper-level + # assertion also includes process startup, staging, and native compilation. + $pollClock.Elapsed.TotalSeconds | Should -BeLessThan 30 + $pollRoot.Count | Should -Be 1 + @($pollTrace | Where-Object event -eq 'worker-process-failure').Count | + Should -Be 1 + @($pollTrace | Where-Object event -eq 'cleanup-started').Count | Should -Be 0 + (Test-Path -LiteralPath ([string]$pollRoot[0].data.root) -PathType Container) | + Should -BeTrue + } + finally { + foreach ($record in $pollRoot) { + Remove-Task8ResidualFixturePath -Path ([string]$record.data.root) + } + } + + $runnerSource = [IO.File]::ReadAllText($script:runnerPath) + $runnerSource | Should -Match ( + 'public bool IsTreeEmpty\(\)[\s\S]*?if \(_emptyConfirmed\) return true;') + $runnerSource | Should -Match ( + 'else if \(_assigned && _ownershipEstablished && !_emptyConfirmed') + + $workerRequest = [pscustomobject]@{ + nonce = ('a' * 64) + execution = 'DryRun' + authMode = 'Certificate' + packageSha256 = ('b' * 64) + moduleVersion = '0.4.0-r8.fixture' + } + $workerAdapter = [ordered]@{} + foreach ($name in @( + 'abiMarkerExact','contractsDefault','providerCollectibleNonDefault', + 'msalVersionExact','providerMsalSameContext','publicAbiExact')) { + $workerAdapter[$name] = $true + } + $workerResult = [pscustomobject][ordered]@{ + recordKind = 'GraphKit.Task8.ParityWorkerResult/1' + nonce = ('a' * 64) + requestSha256 = ('c' * 64) + execution = 'DryRun' + authMode = 'Certificate' + packageSha256 = ('b' * 64) + moduleVersion = '0.4.0-r8.fixture' + state = 'Passed' + failureStage = 'None' + failureCode = 'None' + exactImport = $true + adapter = [pscustomobject]$workerAdapter + contextMatched = $false + sourceMatched = $false + tenantProofVerified = $false + readAttempted = $false + readSucceeded = $false + rowCount = [long]0 + workerTeardownVerified = $true + } + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityWorkerResult ` + -Arguments @{ + Result = $workerResult + Request = $workerRequest + RequestSha256 = ('c' * 64) + } | Should -BeTrue + { + Invoke-Task8PrivateHelper ` + -FunctionName ConvertFrom-GraphKitAuthParityWorkerJson ` + -Arguments @{ + Json = '{"outer":{"value":1,"value":2}}' + MaximumBytes = [long]1024 + } + } | Should -Throw + foreach ($mutation in @( + 'unknown','missing','wrong-type','wrong-nonce','wrong-hash', + 'wrong-version','wrong-execution','wrong-auth','wrong-package', + 'missing-adapter','unknown-adapter','wrong-adapter-type')) { + $mutant = $workerResult | ConvertTo-Json -Compress -Depth 5 | + ConvertFrom-Json -Depth 5 + switch ($mutation) { + 'unknown' { + $mutant | Add-Member -MemberType NoteProperty ` + -Name unknown -Value $true + } + 'missing' { $mutant.PSObject.Properties.Remove('rowCount') } + 'wrong-type' { $mutant.exactImport = 'true' } + 'wrong-nonce' { $mutant.nonce = ('d' * 64) } + 'wrong-hash' { $mutant.requestSha256 = ('e' * 64) } + 'wrong-version' { $mutant.moduleVersion = '0.4.0-r8.other' } + 'wrong-execution' { $mutant.execution = 'Live' } + 'wrong-auth' { $mutant.authMode = 'BearerToken' } + 'wrong-package' { $mutant.packageSha256 = ('d' * 64) } + 'missing-adapter' { + $mutant.adapter.PSObject.Properties.Remove('publicAbiExact') + } + 'unknown-adapter' { + $mutant.adapter | Add-Member -MemberType NoteProperty ` + -Name unknown -Value $true + } + 'wrong-adapter-type' { $mutant.adapter.publicAbiExact = 'true' } + } + { + Invoke-Task8PrivateHelper ` + -FunctionName Test-GraphKitAuthParityWorkerResult ` + -Arguments @{ + Result = $mutant + Request = $workerRequest + RequestSha256 = ('c' * 64) + } + } | Should -Throw + } + } + + It 'accepts the exact in-memory mode-run scalar types and closed schema' { + $record = New-Task8ModeRecordFixture + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityEvidence ` + -Arguments @{ Record = $record } | Should -BeTrue + } + + It 'rejects a string-valued in-memory row count rather than coercing it' { + $script:runnerPath | Should -Exist + $record = New-Task8ModeRecordFixture + $record.read.rowCount = '0' + { + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityEvidence ` + -Arguments @{ Record = $record } + } | Should -Throw + } + + It 'accepts the exact frozen-artifact schema and scalar types' { + $artifact = New-Task8FrozenArtifactFixture + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityFrozenArtifact ` + -Arguments @{ Record = $artifact } | Should -BeTrue + } + + It 'requires retention to bind four distinct modes to one artifact version and digest' { + $artifact = New-Task8FrozenArtifactFixture + $records = @($script:task8ModeNames | ForEach-Object { + New-Task8ModeRecordFixture -AuthMode $_ -Execution Live + }) + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityRetention ` + -Arguments @{ Artifact = $artifact; ModeRecords = $records } | Should -BeTrue + + $dryRuns = @($script:task8ModeNames | ForEach-Object { + New-Task8ModeRecordFixture -AuthMode $_ + }) + { + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityRetention ` + -Arguments @{ Artifact = $artifact; ModeRecords = $dryRuns } + } | Should -Throw + } + + It 'rejects duplicate retained modes even when every individual record is valid' { + $script:runnerPath | Should -Exist + $artifact = New-Task8FrozenArtifactFixture + $records = @( + New-Task8ModeRecordFixture -AuthMode Certificate -Execution Live + New-Task8ModeRecordFixture -AuthMode ClientSecret -Execution Live + New-Task8ModeRecordFixture -AuthMode ManagedIdentity -Execution Live + New-Task8ModeRecordFixture -AuthMode ManagedIdentity -Execution Live + ) + { + Invoke-Task8PrivateHelper -FunctionName Test-GraphKitAuthParityRetention ` + -Arguments @{ Artifact = $artifact; ModeRecords = $records } + } | Should -Throw + } +} diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 new file mode 100644 index 0000000..dddf2f0 --- /dev/null +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -0,0 +1,3322 @@ +$requiredGraphKitAuthFiles = @( + 'GraphKit.Auth.Contracts.dll' + 'GraphKit.Auth.dll' + 'GraphKit.Auth.deps.json' + 'Microsoft.Identity.Client.dll' + 'Microsoft.IdentityModel.Abstractions.dll' +) + +$graphKitAuthArchiveAliasCases = @( + @{ Kind = 'portable case alias'; Entries = @( + 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + 'assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + ) } + @{ Kind = 'separator alias'; Entries = @( + 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + 'Assemblies\GraphKit.Auth\GraphKit.Auth.Contracts.dll' + ) } + @{ Kind = 'duplicate exact path'; Entries = @( + 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + ) } + @{ Kind = 'Unicode normalization alias'; Entries = @( + "Assemblies/GraphKit.Auth/prob$([char]0x00E9).dll" + "Assemblies/GraphKit.Auth/probe$([char]0x0301).dll" + ) } +) + +$windowsAclMutationCases = if ($IsWindows) { + @( + @{ Kind = 'extra principal' } + @{ Kind = 'unprotected DACL' } + @{ Kind = 'inherited ACE' } + @{ Kind = 'missing owner read rights' } + ) +} +else { + @() +} + +$windowsInitialAccessCases = if ($IsWindows) { @(@{}) } else { @() } +$unixInitialAccessCases = if ($IsWindows) { @() } else { @(@{}) } +$unixRootAliasCases = if ($IsWindows) { @() } else { + @(@{ RootKind = 'auth' }, @{ RootKind = 'capture' }, @{ RootKind = 'stage' }) +} +$windowsRootAliasCases = if ($IsWindows) { + @(@{ RootKind = 'auth' }, @{ RootKind = 'capture' }, @{ RootKind = 'stage' }) +} +else { @() } +$portableRootAliasCases = @( + @{ RootKind = 'auth'; AliasName = 'graphkit.auth' } + @{ RootKind = 'capture'; AliasName = 'Capture' } + @{ RootKind = 'stage'; AliasName = 'Stage' } +) +$portableVersionAliasCases = @( + @{ + Kind = 'case' + ExpectedName = '0.4.0-r8.fixture.version-alias' + AliasName = '0.4.0-r8.fixture.VERSION-ALIAS' + } + @{ + Kind = 'NFC' + ExpectedName = "0.4.0-r8.fixture.v$([char]0x00E9)rsion-alias" + AliasName = ("0.4.0-r8.fixture.v$([char]0x00E9)rsion-alias").Normalize([Text.NormalizationForm]::FormD) + } +) +$linuxAtomicRenameCases = if ($IsLinux) { @(@{}) } else { @() } +$linuxCaseSensitiveStageAliasCases = if ($IsLinux) { @(@{}) } else { @() } + +BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + if (-not ('GraphKitAuthPackageLinkFixture' -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.IO; +using System.Runtime.InteropServices; + +public static class GraphKitAuthPackageLinkFixture +{ + private const int SymbolicLinkFlagAllowUnprivilegedCreate = 0x2; + + public static void CreateHardLink(string linkPath, string existingPath) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("The native package hard-link fixture is Windows-only."); + if (!CreateHardLinkW(ToExtendedWindowsPath(linkPath), ToExtendedWindowsPath(existingPath), IntPtr.Zero)) + throw new IOException($"Native package hard-link creation failed (Win32 {Marshal.GetLastWin32Error()})."); + } + + public static void CreateFileSymbolicLink(string linkPath, string targetPath) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("The native package symbolic-link fixture is Windows-only."); + if (!CreateSymbolicLinkW(ToExtendedWindowsPath(linkPath), ToExtendedWindowsPath(targetPath), + SymbolicLinkFlagAllowUnprivilegedCreate)) + throw new IOException($"Native package symbolic-link creation failed (Win32 {Marshal.GetLastWin32Error()})."); + } + + public static bool IsReparsePoint(string path) + { + return (File.GetAttributes(ToExtendedWindowsPath(path)) & FileAttributes.ReparsePoint) != 0; + } + + private static string ToExtendedWindowsPath(string path) + { + string fullPath = Path.GetFullPath(path); + if (fullPath.StartsWith(@"\\?\", StringComparison.Ordinal)) return fullPath; + if (fullPath.StartsWith(@"\\", StringComparison.Ordinal)) + return @"\\?\UNC\" + fullPath.Substring(2); + return @"\\?\" + fullPath; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + private static extern bool CreateHardLinkW( + string fileName, string existingFileName, IntPtr securityAttributes); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + private static extern bool CreateSymbolicLinkW( + string symbolicFileName, string targetFileName, int flags); +} +"@ + } + $script:requiredGraphKitAuthFiles = @( + 'GraphKit.Auth.Contracts.dll' + 'GraphKit.Auth.dll' + 'GraphKit.Auth.deps.json' + 'Microsoft.Identity.Client.dll' + 'Microsoft.IdentityModel.Abstractions.dll' + ) + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $script:taskPath = Join-Path $script:repoRoot '.build/GraphKitAuth.tasks.ps1' + if (Test-Path -LiteralPath $script:taskPath -PathType Leaf) { + . $script:taskPath -SkipTaskRegistration + } + + $script:sourceManifest = Import-PowerShellDataFile -Path (Join-Path $script:repoRoot 'source/GraphKit.psd1') + $script:baseVersion = [string] $script:sourceManifest.ModuleVersion + $script:builtModuleRoot = Join-Path $script:repoRoot "output/module/GraphKit/$script:baseVersion" + $script:builtManifestPath = Join-Path $script:builtModuleRoot 'GraphKit.psd1' + $script:fullVersion = $null + $script:packagePath = $null + $script:stagePath = $null + $script:packageEntries = @() + + if (Test-Path -LiteralPath $script:builtManifestPath -PathType Leaf) { + $builtManifest = Import-PowerShellDataFile -Path $script:builtManifestPath + $prerelease = [string] $builtManifest.PrivateData.PSData.Prerelease + $script:fullVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { $script:baseVersion } else { "$script:baseVersion-$prerelease" } + $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$script:fullVersion.nupkg" + $stageVersionRoot = Join-Path $script:repoRoot "output/GraphKit.Auth/stage/$script:fullVersion" + if (Test-Path -LiteralPath $stageVersionRoot -PathType Container) { + $stageDirectories = @(Get-ChildItem -LiteralPath $stageVersionRoot -Directory -Force) + if ($stageDirectories.Count -eq 1) { $script:stagePath = $stageDirectories[0].FullName } + } + } + if ($script:packagePath -and (Test-Path -LiteralPath $script:packagePath -PathType Leaf)) { + $archive = [IO.Compression.ZipFile]::OpenRead($script:packagePath) + try { $script:packageEntries = @($archive.Entries) } finally { $archive.Dispose() } + } + + function Assert-GraphKitAuthStageCommands { + foreach ($commandName in @( + 'New-GraphKitAuthSealedStage' + 'Test-GraphKitAuthSealedStage' + 'Invoke-GraphKitAuthPrepareClean' + )) { + if (-not (Get-Command -Name $commandName -CommandType Function -ErrorAction SilentlyContinue)) { + throw "Task 5 staging command '$commandName' is not implemented." + } + } + } + + function Assert-GraphKitAuthArchivePaths { + param([Parameter(Mandatory)] [string[]] $Entries) + $portable = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $normalized = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($entryPath in $Entries) { + $segments = @($entryPath -split '/') + if ([string]::IsNullOrWhiteSpace($entryPath) -or [IO.Path]::IsPathRooted($entryPath) -or + $entryPath -match '^[A-Za-z]:' -or $entryPath.IndexOf('\') -ge 0 -or + $segments -contains '' -or $segments -contains '.' -or $segments -contains '..') { + throw "Unsafe GraphKit.Auth archive entry '$entryPath'." + } + if (-not $portable.Add($entryPath)) { throw "Duplicate or portable-case GraphKit.Auth archive entry '$entryPath'." } + if (-not $normalized.Add($entryPath.Normalize([Text.NormalizationForm]::FormC))) { + throw "Unicode-normalization GraphKit.Auth archive alias '$entryPath'." + } + } + } + + function Get-GraphKitAuthArchiveHash { + param([string] $PackagePath, [string] $EntryPath) + $archive = [IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $archiveMatches = @($archive.Entries | Where-Object FullName -CEQ $EntryPath) + if ($archiveMatches.Count -ne 1) { throw "Expected one '$EntryPath' archive entry." } + $stream = $archiveMatches[0].Open() + try { + $sha = [Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose() } + } + finally { $stream.Dispose() } + } + finally { $archive.Dispose() } + } + + function Test-GraphKitAuthTestAclMutationSafe { + param([Parameter(Mandatory)] $Item) + return [string]::IsNullOrEmpty([string] $Item.LinkType) -and + (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) + } + + function New-GraphKitAuthTestHardLink { + param( + [Parameter(Mandatory)][string] $LinkPath, + [Parameter(Mandatory)][string] $TargetPath + ) + if ($IsWindows) { + [GraphKitAuthPackageLinkFixture]::CreateHardLink($LinkPath, $TargetPath) + } + else { + $null = New-Item -ItemType HardLink -Path $LinkPath -Target $TargetPath ` + -ErrorAction Stop + } + } + + function New-GraphKitAuthTestFileSymbolicLink { + param( + [Parameter(Mandatory)][string] $LinkPath, + [Parameter(Mandatory)][string] $TargetPath + ) + if ($IsWindows) { + [GraphKitAuthPackageLinkFixture]::CreateFileSymbolicLink($LinkPath, $TargetPath) + } + else { + $null = New-Item -ItemType SymbolicLink -Path $LinkPath -Target $TargetPath ` + -ErrorAction Stop + } + } + + function Remove-GraphKitAuthTestMutationArtifacts { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [Collections.Generic.List[object]] $Artifacts + ) + for ($index = $Artifacts.Count - 1; $index -ge 0; $index--) { + $artifact = $Artifacts[$index] + if (-not [bool] $artifact.Created) { continue } + $path = [string] $artifact.Path + $isDirectory = [bool] $artifact.Directory + $isLink = [bool] $artifact.Link + $restorePath = [string] $artifact.RestorePath + $widenParent = [bool] $artifact.WidenParent + if ($isLink) { + $parent = [IO.Path]::GetDirectoryName($path) + if ($IsWindows) { + if (-not [string]::IsNullOrWhiteSpace($restorePath)) { + # A hard link shares its file security descriptor with the + # sealed in-tree name. Grant DELETE on that owned link only; + # rewriting TestDrive's DACL would strip inherited traversal + # rights from every sibling fixture below it. + Set-GraphKitAuthTestWindowsPathWritable -Path $path -Directory $false + } + elseif ($widenParent) { + # Only in-stage reparse fixtures have an intentionally sealed + # parent. External aliases live directly under TestDrive and + # must be removed without rewriting that shared parent DACL. + Set-GraphKitAuthTestWindowsPathWritable -Path $parent -Directory $true + } + } + else { + Set-GraphKitAuthTestUnixPathWritable -Path $parent -Directory $true + } + if ($isDirectory) { [IO.Directory]::Delete($path, $false) } + else { [IO.File]::Delete($path) } + if (-not [string]::IsNullOrWhiteSpace($restorePath)) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + $restorePath, $false, $false) + } + } + elseif ($isDirectory) { + if ([IO.Directory]::Exists($path)) { + Set-GraphKitAuthTestTreeWritable -Path $path + [IO.Directory]::Delete($path, $true) + } + } + elseif ([IO.File]::Exists($path)) { + if ($IsWindows) { + Set-GraphKitAuthTestWindowsPathWritable -Path $path -Directory $false + } + [IO.File]::Delete($path) + } + } + $Artifacts.Clear() + } + + function Set-GraphKitAuthTestStageWritable { + param([Parameter(Mandatory)] [string] $StagePath) + $versionPath = Split-Path $StagePath -Parent + $stageItem = Get-Item -LiteralPath $StagePath -Force -ErrorAction Stop + $versionItem = Get-Item -LiteralPath $versionPath -Force -ErrorAction Stop + if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $stageItem) -or + -not (Test-GraphKitAuthTestAclMutationSafe -Item $versionItem)) { + throw 'GraphKit.Auth test stage cleanup refused a link or reparse root.' + } + $items = @( + Get-ChildItem -LiteralPath $StagePath -Recurse -Force -ErrorAction Stop | + Sort-Object { $_.FullName.Length } -Descending + ) + @($stageItem, $versionItem) + if (@($items | Where-Object { + -not (Test-GraphKitAuthTestAclMutationSafe -Item $_) + }).Count -gt 0) { + throw 'GraphKit.Auth test stage cleanup refused a link or reparse entry.' + } + foreach ($item in $items) { + $directory = [bool] $item.PSIsContainer + if ($IsWindows) { + Set-GraphKitAuthTestWindowsPathWritable ` + -Path $item.FullName -Directory $directory + } + else { + Set-GraphKitAuthTestUnixPathWritable -Path $item.FullName ` + -Directory $directory -Exact + } + } + } + + function Set-GraphKitAuthTestStageSealed { + param( + [Parameter(Mandatory)] [string] $StagePath, + [string] $LeaveWritablePath + ) + Initialize-GraphKitAuthStageCapture + $leave = if ([string]::IsNullOrWhiteSpace($LeaveWritablePath)) { + $null + } + else { + [IO.Path]::GetFullPath($LeaveWritablePath) + } + foreach ($file in @(Get-ChildItem -LiteralPath $StagePath -File -Recurse -Force)) { + if ($null -ne $leave -and [IO.Path]::GetFullPath($file.FullName) -ceq $leave) { continue } + if ($file.LinkType -in @('SymbolicLink','Junction')) { continue } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($file.FullName, $false, $false) + } + foreach ($directory in @( + Get-ChildItem -LiteralPath $StagePath -Directory -Recurse -Force | + Where-Object { $_.LinkType -notin @('SymbolicLink','Junction') } | + Sort-Object { $_.FullName.Length } -Descending + )) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($directory.FullName, $true, $false) + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($StagePath, $true, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Split-Path $StagePath -Parent), $true, $false) + } + + function Get-GraphKitAuthTestDirectorySecurity { + param([Parameter(Mandatory)][string] $Path) + if ($IsWindows) { return (Get-Acl -LiteralPath $Path).Sddl } + return [int][IO.File]::GetUnixFileMode($Path) + } + + function Set-GraphKitAuthTestWindowsPathWritable { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][bool] $Directory + ) + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + $security = if ($Directory) { + [Security.AccessControl.DirectorySecurity]::new() + } + else { + [Security.AccessControl.FileSecurity]::new() + } + $security.SetAccessRuleProtection($true, $false) + # Every descendant is transitioned explicitly by the bounded cleanup walkers. + # A propagating ACE here could follow an in-tree hard-link name and mutate the + # caller-owned file object outside the requested tree. + $inheritance = [Security.AccessControl.InheritanceFlags]::None + $security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + if ($Directory) { + [IO.FileSystemAclExtensions]::SetAccessControl( + [IO.DirectoryInfo]::new($Path), $security) + } + else { + [IO.FileSystemAclExtensions]::SetAccessControl( + [IO.FileInfo]::new($Path), $security) + $attributes = [IO.File]::GetAttributes($Path) + if (($attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) { + $writableAttributes = [IO.FileAttributes]( + [int]$attributes -band (-bnot [int][IO.FileAttributes]::ReadOnly)) + [IO.File]::SetAttributes( + $Path, + $(if ([int]$writableAttributes -eq 0) { + [IO.FileAttributes]::Normal + } + else { $writableAttributes })) + } + } + } + + function Set-GraphKitAuthTestUnixPathWritable { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][bool] $Directory, + [switch] $Exact + ) + $required = [IO.UnixFileMode]::UserRead -bor + [IO.UnixFileMode]::UserWrite + $current = if ($Exact) { + [IO.UnixFileMode]::None + } + else { + [IO.File]::GetUnixFileMode($Path) + } + $anyExecute = [IO.UnixFileMode]::UserExecute -bor + [IO.UnixFileMode]::GroupExecute -bor + [IO.UnixFileMode]::OtherExecute + if ($Directory -or (([int] $current -band [int] $anyExecute) -ne 0)) { + $required = $required -bor [IO.UnixFileMode]::UserExecute + } + [IO.File]::SetUnixFileMode( + $Path, [IO.UnixFileMode]([int] $current -bor [int] $required)) + } + + function Set-GraphKitAuthTestTreeWritable { + param([Parameter(Mandatory)][string] $Path) + if (-not (Test-Path -LiteralPath $Path)) { return } + $rootItem = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $rootItem)) { + throw 'GraphKit.Auth test tree cleanup refused a link or reparse root.' + } + $items = @( + Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction Stop | + Sort-Object { $_.FullName.Length } -Descending + ) + @($rootItem) + if (@($items | Where-Object { + -not (Test-GraphKitAuthTestAclMutationSafe -Item $_) + }).Count -gt 0) { + throw 'GraphKit.Auth test tree cleanup refused a link or reparse entry.' + } + foreach ($item in $items) { + $directory = [bool] $item.PSIsContainer + if ($IsWindows) { + Set-GraphKitAuthTestWindowsPathWritable ` + -Path $item.FullName -Directory $directory + } + else { + Set-GraphKitAuthTestUnixPathWritable -Path $item.FullName ` + -Directory $directory + } + } + } + + function New-GraphKitAuthStageFixture { + param([Parameter(Mandatory)] [string] $Name) + Assert-GraphKitAuthStageCommands + if (-not $script:stagePath) { throw 'The packed candidate has no sealed source stage to use as fixture input.' } + $fixtureOutput = Join-Path $TestDrive ("stage-fixture-$Name-" + [guid]::NewGuid().ToString('N')) + try { + $fixture = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion ("0.4.0-r8.fixture.$Name." + [guid]::NewGuid().ToString('N')) ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + $fixture | Add-Member -NotePropertyName TestOutputRoot -NotePropertyValue $fixtureOutput + return $fixture + } + catch { + $primaryFailure = $_ + try { Remove-GraphKitAuthTestFixtureOutputRoot -OutputRoot $fixtureOutput } + catch { + throw [AggregateException]::new( + 'GraphKit.Auth stage fixture creation and bounded cleanup both failed.', + [Exception[]]@($primaryFailure.Exception, $_.Exception)) + } + throw $primaryFailure + } + } + + function Resolve-GraphKitAuthTestFixtureOutputRoot { + param([Parameter(Mandatory)][string] $OutputRoot) + $outputRoot = [IO.Path]::GetFullPath($OutputRoot) + $testDriveRoot = [IO.Path]::GetFullPath([string] $TestDrive).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { [StringComparison]::Ordinal } + if (-not [string]::Equals( + [IO.Path]::GetDirectoryName($outputRoot), $testDriveRoot, $comparison) -or + [IO.Path]::GetFileName($outputRoot) -notmatch '^stage-fixture-.+-[0-9a-f]{32}$') { + throw 'GraphKit.Auth test stage cleanup refused a non-fixture output root.' + } + return $outputRoot + } + + function Assert-GraphKitAuthTestPhysicalFixtureTree { + param([Parameter(Mandatory)][string] $OutputRoot) + if (-not [IO.Directory]::Exists($OutputRoot)) { return } + $rootItem = Get-Item -LiteralPath $OutputRoot -Force -ErrorAction Stop + if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $rootItem)) { + throw 'GraphKit.Auth test stage cleanup refused a link or reparse root.' + } + $items = @( + Get-ChildItem -LiteralPath $OutputRoot -Recurse -Force -ErrorAction Stop + ) + @($rootItem) + if (@($items | Where-Object { + -not (Test-GraphKitAuthTestAclMutationSafe -Item $_) + }).Count -gt 0) { + throw 'GraphKit.Auth test stage cleanup refused a link or reparse entry.' + } + } + + function Remove-GraphKitAuthTestFixtureOutputRoot { + param([Parameter(Mandatory)][string] $OutputRoot) + $outputRoot = Resolve-GraphKitAuthTestFixtureOutputRoot -OutputRoot $OutputRoot + if ([IO.Directory]::Exists($outputRoot)) { + Assert-GraphKitAuthTestPhysicalFixtureTree -OutputRoot $outputRoot + Set-GraphKitAuthTestTreeWritable -Path $outputRoot + [IO.Directory]::Delete($outputRoot, $true) + } + } + + function Remove-GraphKitAuthTestStageFixture { + param([Parameter(Mandatory)] $Fixture) + $outputRoot = Resolve-GraphKitAuthTestFixtureOutputRoot ` + -OutputRoot ([string] $Fixture.TestOutputRoot) + $stagePath = [IO.Path]::GetFullPath([string] $Fixture.StagePath) + $fullVersion = [string] $Fixture.FullVersion + $manifestSha256 = [string] $Fixture.ManifestSha256 + Assert-GraphKitAuthSafeSegment -Value $fullVersion -Kind 'test fixture full version' + if ($manifestSha256 -cnotmatch '^[0-9a-f]{64}$') { + throw 'GraphKit.Auth test stage cleanup refused an invalid manifest digest.' + } + $expectedStagePath = [IO.Path]::GetFullPath([IO.Path]::Combine( + $outputRoot, + 'GraphKit.Auth', + 'stage', + $fullVersion, + $manifestSha256)) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { [StringComparison]::Ordinal } + if (-not [string]::Equals($stagePath, $expectedStagePath, $comparison)) { + throw 'GraphKit.Auth test stage cleanup refused a mismatched stage path.' + } + Assert-GraphKitAuthTestPhysicalFixtureTree -OutputRoot $outputRoot + if ([IO.Directory]::Exists($stagePath)) { + Set-GraphKitAuthTestStageWritable -StagePath $stagePath + } + Remove-GraphKitAuthTestFixtureOutputRoot -OutputRoot $outputRoot + } + + function Invoke-GraphKitAuthStageMutation { + param( + [string] $Kind, + [string] $StagePath, + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [Collections.Generic.List[object]] $CleanupArtifacts + ) + Set-GraphKitAuthTestStageWritable -StagePath $StagePath + $payloadPath = Join-Path $StagePath 'payload' + $targetPath = Join-Path $payloadPath 'GraphKit.Auth.dll' + switch ($Kind) { + 'missing' { [IO.File]::Delete($targetPath) } + 'extra' { [IO.File]::WriteAllText((Join-Path $payloadPath 'extra.dll'), 'extra') } + 'renamed' { [IO.File]::Move($targetPath, (Join-Path $payloadPath 'GraphKit.Auth.renamed.dll')) } + 'writable' { if ($IsWindows) { (Get-Item $targetPath).IsReadOnly = $false } else { & chmod 0600 $targetPath } } + 'byte-mutated' { [IO.File]::WriteAllText($targetPath, 'mutated') } + 'byte-identical-replaced' { + $bytes = [IO.File]::ReadAllBytes($targetPath) + $replacement = Join-Path $payloadPath ('.replacement-' + [guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllBytes($replacement, $bytes) + [IO.File]::Delete($targetPath) + [IO.File]::Move($replacement, $targetPath) + } + 'hard-link' { + $outsideLink = Join-Path $TestDrive ('GraphKit.Auth.hardlink-' + [guid]::NewGuid().ToString('N') + '.dll') + $linkArtifact = [pscustomobject]@{ + Path = $outsideLink; Directory = $false; Link = $true + RestorePath = $targetPath; WidenParent = $false; Created = $false + } + $CleanupArtifacts.Add($linkArtifact) | Out-Null + New-GraphKitAuthTestHardLink -LinkPath $outsideLink -TargetPath $targetPath + $linkArtifact.Created = $true + $linked = $script:GraphKitAuthStageCaptureType::InspectFile( + $payloadPath, 'GraphKit.Auth.dll') + if ([long] $linked.LinkCount -ne 2) { + throw 'The package hard-link fixture did not establish an exact two-link file.' + } + } + 'escaped-link' { + $outsidePath = Join-Path $TestDrive ('outside-' + [guid]::NewGuid().ToString('N') + '.dll') + $outsideArtifact = [pscustomobject]@{ + Path = $outsidePath; Directory = $false; Link = $false + RestorePath = $null; Created = $false + } + $CleanupArtifacts.Add($outsideArtifact) | Out-Null + $outsideStream = $null + try { + $outsideStream = [IO.File]::Open( + $outsidePath, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::None) + $outsideArtifact.Created = $true + $outsideBytes = [Text.Encoding]::UTF8.GetBytes('outside') + $outsideStream.Write($outsideBytes, 0, $outsideBytes.Length) + } + finally { + if ($null -ne $outsideStream) { $outsideStream.Dispose() } + } + [IO.File]::Delete($targetPath) + $linkArtifact = [pscustomobject]@{ + Path = $targetPath; Directory = $false; Link = $true + RestorePath = $null; WidenParent = $true; Created = $false + } + $CleanupArtifacts.Add($linkArtifact) | Out-Null + New-GraphKitAuthTestFileSymbolicLink -LinkPath $targetPath -TargetPath $outsidePath + $linkArtifact.Created = $true + if ($IsWindows) { + if (-not [GraphKitAuthPackageLinkFixture]::IsReparsePoint($targetPath)) { + throw 'The package symbolic-link fixture did not create a reparse point.' + } + } + elseif ((Get-Item -LiteralPath $targetPath -Force).LinkType -ne 'SymbolicLink') { + throw 'The package symbolic-link fixture did not create a symbolic link.' + } + } + 'case-alias' { + $temporary = Join-Path $payloadPath ('.case-' + [guid]::NewGuid().ToString('N')) + [IO.File]::Move($targetPath, $temporary) + [IO.File]::Move($temporary, (Join-Path $payloadPath 'graphkit.auth.dll')) + } + 'separator-alias' { + if ($IsWindows) { + $manifestPath = Join-Path $StagePath 'manifest.json' + [IO.File]::WriteAllText($manifestPath, ([IO.File]::ReadAllText($manifestPath).Replace('payload/GraphKit.Auth.dll', 'payload\GraphKit.Auth.dll'))) + } + else { + [IO.File]::Copy($targetPath, [IO.Path]::Combine( + $payloadPath, 'GraphKit.Auth\GraphKit.Auth.dll')) + } + } + 'unicode-alias' { + [IO.File]::Copy($targetPath, (Join-Path $payloadPath "prob$([char]0x00E9).dll")) + try { [IO.File]::Copy($targetPath, (Join-Path $payloadPath "probe$([char]0x0301).dll")) } + catch [IO.IOException] { + # APFS commonly aliases composed and decomposed names. The first extra + # file is still a zero-skip normalization mutation for stage validation. + } + } + 'platform-directory-alias' { + $outsidePayload = Join-Path $TestDrive ('payload-alias-target-' + [guid]::NewGuid().ToString('N')) + $outsideArtifact = [pscustomobject]@{ + Path = $outsidePayload; Directory = $true; Link = $false + RestorePath = $null; Created = $false + } + $CleanupArtifacts.Add($outsideArtifact) | Out-Null + $null = New-Item -ItemType Directory -Path $outsidePayload -ErrorAction Stop + $outsideArtifact.Created = $true + foreach ($file in @(Get-ChildItem -LiteralPath $payloadPath -File -Force)) { + [IO.File]::Copy($file.FullName, (Join-Path $outsidePayload $file.Name)) + } + Remove-Item -LiteralPath $payloadPath -Recurse -Force + $kind = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' } + $linkArtifact = [pscustomobject]@{ + Path = $payloadPath; Directory = $true; Link = $true + RestorePath = $null; WidenParent = $true; Created = $false + } + $CleanupArtifacts.Add($linkArtifact) | Out-Null + $null = New-Item -ItemType $kind -Path $payloadPath -Target $outsidePayload -ErrorAction Stop + $linkArtifact.Created = $true + } + default { throw "Unknown mutation '$Kind'." } + } + $leaveWritable = if ($Kind -ceq 'writable') { $targetPath } else { $null } + Set-GraphKitAuthTestStageSealed -StagePath $StagePath -LeaveWritablePath $leaveWritable + } + + function Set-GraphKitAuthWindowsAclMutation { + param( + [Parameter(Mandatory)] [string] $StagePath, + [Parameter(Mandatory)] [string] $Kind + ) + if (-not $IsWindows) { throw 'Windows ACL mutations are Windows-only.' } + $payloadPath = Join-Path $StagePath 'payload' + $targetPath = Join-Path $payloadPath 'GraphKit.Auth.dll' + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $worldSid = [Security.Principal.SecurityIdentifier]::new( + [Security.Principal.WellKnownSidType]::WorldSid, $null) + switch ($Kind) { + 'extra principal' { + $acl = Get-Acl -LiteralPath $targetPath + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $worldSid, [Security.AccessControl.FileSystemRights]::Read, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $targetPath -AclObject $acl + } + 'unprotected DACL' { + $acl = Get-Acl -LiteralPath $targetPath + $acl.SetAccessRuleProtection($false, $false) + Set-Acl -LiteralPath $targetPath -AclObject $acl + } + 'inherited ACE' { + $parentAcl = Get-Acl -LiteralPath $payloadPath + $parentAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $worldSid, [Security.AccessControl.FileSystemRights]::Read, + [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $payloadPath -AclObject $parentAcl + $acl = Get-Acl -LiteralPath $targetPath + $acl.SetAccessRuleProtection($false, $false) + Set-Acl -LiteralPath $targetPath -AclObject $acl + } + 'missing owner read rights' { + $acl = Get-Acl -LiteralPath $targetPath + $acl.PurgeAccessRules($currentSid) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $currentSid, [Security.AccessControl.FileSystemRights]::ReadAttributes, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $targetPath -AclObject $acl + } + default { throw "Unknown Windows ACL mutation '$Kind'." } + } + } + + function Invoke-GraphKitAuthSealedPayloadProbe { + param([Parameter(Mandatory)] [string] $PayloadRoot) + $probePath = Join-Path $TestDrive ('Probe-GraphKitAuthPackage-' + [guid]::NewGuid().ToString('N') + '.ps1') + $defaultMsalPath = Join-Path $script:repoRoot ` + 'output/RequiredModules/Microsoft.Graph.Authentication/2.38.1/Dependencies/Core/Microsoft.Identity.Client.dll' + if (-not (Test-Path -LiteralPath $defaultMsalPath -PathType Leaf)) { + throw "The package probe prerequisite '$defaultMsalPath' is missing." + } + Set-Content -LiteralPath $probePath -NoNewline -Encoding utf8NoBOM -Value @' +param( + [Parameter(Mandatory)] [string] $PayloadRoot, + [Parameter(Mandatory)] [string] $DefaultMsalPath +) +$ErrorActionPreference = 'Stop' +$defaultContext = [Runtime.Loader.AssemblyLoadContext]::Default +$defaultMsalAssembly = $defaultContext.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $DefaultMsalPath).ProviderPath) +$defaultMsalBeforeMvid = $defaultMsalAssembly.ManifestModule.ModuleVersionId +$defaultMsalBeforeLocation = $defaultMsalAssembly.Location +Add-Type -TypeDefinition @" +using System; +using System.Reflection; + +public static class GraphKitAuthPackageProbeInspector +{ + public static int ReadAcquireCount(object source) + { + const BindingFlags Flags = BindingFlags.Instance | BindingFlags.NonPublic; + object inner = source.GetType().GetField("_inner", Flags)?.GetValue(source) + ?? throw new InvalidOperationException("The package source proxy has no provider inner source."); + object client = inner.GetType().GetField("_client", Flags)?.GetValue(inner) + ?? throw new InvalidOperationException("The provider source has no authentication client."); + PropertyInfo property = client.GetType().GetProperty("AcquireCount", Flags) + ?? throw new InvalidOperationException("The provider client has no acquisition counter."); + return (int)(property.GetValue(client) + ?? throw new InvalidOperationException("The provider acquisition counter is null.")); + } +} +"@ +$contracts = [Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath (Join-Path $PayloadRoot 'GraphKit.Auth.Contracts.dll')).ProviderPath) +function Get-PackageAssemblyEvidence { + param([Parameter(Mandatory)][Reflection.Assembly] $Assembly) + $identity = $Assembly.GetName() + $location = [IO.Path]::GetFullPath($Assembly.Location) + $sha256 = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData([IO.File]::ReadAllBytes($location))).ToLowerInvariant() + return [pscustomobject]@{ + Identity = "$($identity.Name), Version=$($identity.Version)" + Location = $location + Mvid = $Assembly.ManifestModule.ModuleVersionId.ToString('D') + Sha256 = $sha256 + } +} +function Invoke-PackageRuntimeBoundary { + param( + [string] $Root, + [Reflection.Assembly] $DefaultMsalAssembly + ) + $rsa = [Security.Cryptography.RSA]::Create(2048) + try { + $certificateRequest = [Security.Cryptography.X509Certificates.CertificateRequest]::new('CN=GraphKit package probe',$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) + $certificate = $certificateRequest.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-1),[DateTimeOffset]::UtcNow.AddMinutes(5)) + $credential = [GraphKit.Auth.CertificateCredential]::new($certificate,$true) + $request = [GraphKit.Auth.GraphTokenRequest]::new('Global',[guid]'00000000-0000-0000-0000-000000000001',[uri]'https://login.microsoftonline.com',[uri]'https://graph.microsoft.com',[Nullable[guid]][guid]'00000000-0000-0000-0000-000000000002',[GraphKit.Auth.GraphAuthMode]::Certificate,$credential,'package-probe') + $authHost = [GraphKit.Auth.GraphAuthHost]::new($Root,[version]'1.0.0.0',[timespan]::FromSeconds(2)) + $source = $authHost.CreateSource($request) + $weakReference = $authHost.LoadContextWeakReference + $providerAssembly = [GraphKit.Auth.GraphAuthHost].GetField('_providerAssembly',[Reflection.BindingFlags]'Instance,NonPublic').GetValue($authHost) + $providerContext = [Runtime.Loader.AssemblyLoadContext]::GetLoadContext($providerAssembly) + $providerMsalAssemblies = @($providerContext.Assemblies | Where-Object { $_.GetName().Name -ceq 'Microsoft.Identity.Client' }) + if ($providerMsalAssemblies.Count -ne 1) { + throw "The provider context contained $($providerMsalAssemblies.Count) Microsoft.Identity.Client assemblies." + } + $providerMsal = $providerMsalAssemblies[0] + $providerIdentityModelAssemblies = @($providerContext.Assemblies | Where-Object { + $_.GetName().Name -ceq 'Microsoft.IdentityModel.Abstractions' + }) + if ($providerIdentityModelAssemblies.Count -ne 1) { + throw "The provider context contained $($providerIdentityModelAssemblies.Count) Microsoft.IdentityModel.Abstractions assemblies." + } + $providerIdentityModel = $providerIdentityModelAssemblies[0] + $names = @($providerContext.Assemblies | ForEach-Object { $_.GetName().Name } | Sort-Object -Unique) + $canRefresh = $source.CanRefresh + $providerMsalDistinctFromDefault = -not [object]::ReferenceEquals($providerMsal, $DefaultMsalAssembly) + $providerMsalContextName = $providerContext.Name + $providerMsalContextCollectible = $providerContext.IsCollectible + $providerAcquireCount = [GraphKitAuthPackageProbeInspector]::ReadAcquireCount($source) + $providerEvidence = Get-PackageAssemblyEvidence -Assembly $providerAssembly + $providerMsalEvidence = Get-PackageAssemblyEvidence -Assembly $providerMsal + $providerIdentityModelEvidence = Get-PackageAssemblyEvidence -Assembly $providerIdentityModel + $providerIdentityModel = $null + $providerIdentityModelAssemblies = $null + $providerMsal = $null + $providerMsalAssemblies = $null + $providerContext = $null + $providerAssembly = $null + $source.Dispose() + $source = $null + $authHost.Dispose() + $authHost = $null + return [pscustomobject]@{ + WeakReference = $weakReference + CollectibleAssemblies = $names + CanRefresh = $canRefresh + ProviderMsalDistinctFromDefault = $providerMsalDistinctFromDefault + ProviderMsalContextName = $providerMsalContextName + ProviderMsalContextCollectible = $providerMsalContextCollectible + ProviderAcquireCount = $providerAcquireCount + ProviderIdentity = $providerEvidence.Identity + ProviderLocation = $providerEvidence.Location + ProviderMvid = $providerEvidence.Mvid + ProviderSha256 = $providerEvidence.Sha256 + ProviderMsalIdentity = $providerMsalEvidence.Identity + ProviderMsalLocation = $providerMsalEvidence.Location + ProviderMsalMvid = $providerMsalEvidence.Mvid + ProviderMsalSha256 = $providerMsalEvidence.Sha256 + ProviderIdentityModelIdentity = $providerIdentityModelEvidence.Identity + ProviderIdentityModelLocation = $providerIdentityModelEvidence.Location + ProviderIdentityModelMvid = $providerIdentityModelEvidence.Mvid + ProviderIdentityModelSha256 = $providerIdentityModelEvidence.Sha256 + } + } + finally { + if ($null -ne $source) { try { $source.Dispose() } catch {} } + if ($null -ne $authHost) { try { $authHost.Dispose() } catch {} } + $rsa.Dispose() + } + +} +$runtime = Invoke-PackageRuntimeBoundary -Root $PayloadRoot -DefaultMsalAssembly $defaultMsalAssembly +for ($i=0; $i -lt 30 -and $runtime.WeakReference.IsAlive; $i++) { [GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect() } +$contractsLoaded = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'GraphKit.Auth.Contracts' }) +$defaultMsalAfter = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -ceq 'Microsoft.Identity.Client' -and + [Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) -eq $defaultContext +}) +$defaultMsalReferenceUnchanged = $defaultMsalAfter.Count -eq 1 -and + [object]::ReferenceEquals($defaultMsalAfter[0], $defaultMsalAssembly) +[pscustomobject]@{ + ContractsCount = $contractsLoaded.Count + ContractsContext = [Runtime.Loader.AssemblyLoadContext]::GetLoadContext($contractsLoaded[0]).Name + CollectibleAssemblies = $runtime.CollectibleAssemblies + DefaultMsalPreloaded = $null -ne $defaultMsalAssembly + DefaultMsalReferenceUnchanged = $defaultMsalReferenceUnchanged + DefaultMsalMvidUnchanged = $defaultMsalReferenceUnchanged -and + $defaultMsalAfter[0].ManifestModule.ModuleVersionId -eq $defaultMsalBeforeMvid + DefaultMsalLocationUnchanged = $defaultMsalReferenceUnchanged -and + $defaultMsalAfter[0].Location -ceq $defaultMsalBeforeLocation + DefaultMsalUnchanged = $defaultMsalReferenceUnchanged -and + $defaultMsalAfter[0].ManifestModule.ModuleVersionId -eq $defaultMsalBeforeMvid -and + $defaultMsalAfter[0].Location -ceq $defaultMsalBeforeLocation + ProviderMsalDistinctFromDefault = $runtime.ProviderMsalDistinctFromDefault + ProviderMsalContextName = $runtime.ProviderMsalContextName + ProviderMsalContextCollectible = $runtime.ProviderMsalContextCollectible + ProviderAcquireCount = $runtime.ProviderAcquireCount + ProviderIdentity = $runtime.ProviderIdentity + ProviderLocation = $runtime.ProviderLocation + ProviderMvid = $runtime.ProviderMvid + ProviderSha256 = $runtime.ProviderSha256 + ProviderMsalIdentity = $runtime.ProviderMsalIdentity + ProviderMsalMvid = $runtime.ProviderMsalMvid + ProviderMsalLocation = $runtime.ProviderMsalLocation + ProviderMsalSha256 = $runtime.ProviderMsalSha256 + ProviderIdentityModelIdentity = $runtime.ProviderIdentityModelIdentity + ProviderIdentityModelLocation = $runtime.ProviderIdentityModelLocation + ProviderIdentityModelMvid = $runtime.ProviderIdentityModelMvid + ProviderIdentityModelSha256 = $runtime.ProviderIdentityModelSha256 + CanRefresh = $runtime.CanRefresh + LoadContextAlive = $runtime.WeakReference.IsAlive +} | ConvertTo-Json -Compress +'@ + $raw = & pwsh -NoLogo -NoProfile -File $probePath -PayloadRoot $PayloadRoot ` + -DefaultMsalPath $defaultMsalPath 2>&1 + $json = @($raw | Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') }) | Select-Object -Last 1 + [pscustomobject]@{ ExitCode=$LASTEXITCODE; Data=if ($json) { $json | ConvertFrom-Json } else { $null }; Output=($raw | Out-String).Trim() } + } +} + +Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { + It 'provides the private build task and native capture helper' { + Test-Path -LiteralPath $script:taskPath -PathType Leaf | Should -BeTrue + $taskSource = Get-Content -LiteralPath $script:taskPath -Raw + $helperPath = Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs' + Test-Path -LiteralPath $helperPath -PathType Leaf | Should -BeTrue + $helperSource = Get-Content -LiteralPath $helperPath -Raw + $helperSource | Should -Match 'EntryPoint = "fstat\$INODE64"' + $helperSource | Should -Match 'Architecture\.X64 => fstat_inode64\(' + $helperSource | Should -Match 'EntryPoint = "__fxstat"' ` + -Because 'glibc before 2.33 exposes the compatibility fstat symbol instead of public fstat' + $helperSource | Should -Match '(?s)catch \(EntryPointNotFoundException\s+\w+\).*?fxstat\(' ` + -Because 'Linux stage capture must fall back only when the modern libc symbol is absent' + $taskSource | Should -Match 'if \(\$LASTEXITCODE -ne 1\)' ` + -Because 'only git check-ignore exit 1 proves the unrelated sentinel is not ignored' + { Assert-GraphKitAuthStageCommands } | Should -Not -Throw + + $fixtureRoot = Join-Path $TestDrive ('quarantine-' + [guid]::NewGuid().ToString('N')) + $generatedRoot = Join-Path $fixtureRoot 'src/GraphKit.Auth/GraphKit.Auth/bin' + $quarantine = $null + try { + $null = [IO.Directory]::CreateDirectory($generatedRoot) + [IO.File]::WriteAllText((Join-Path $generatedRoot 'generated.dll'), 'fixture') + + $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $fixtureRoot + + $relativeQuarantine = [IO.Path]::GetRelativePath( + [IO.Path]::GetFullPath($fixtureRoot), + [IO.Path]::GetFullPath($quarantine)) + [IO.Path]::IsPathRooted($relativeQuarantine) | Should -BeFalse + $relativeQuarantine | Should -Not -Match '^\.\.(?:[\\/]|$)' ` + -Because 'generated roots must be renamed onto the repository volume' + Test-Path -LiteralPath $generatedRoot | Should -BeFalse + Test-Path -LiteralPath ( + Join-Path $quarantine 'src__GraphKit.Auth__GraphKit.Auth__bin/generated.dll') ` + -PathType Leaf | Should -BeTrue + } + finally { + if ($quarantine -and (Test-Path -LiteralPath $quarantine)) { + Remove-Item -LiteralPath $quarantine -Recurse -Force + } + if (Test-Path -LiteralPath $fixtureRoot) { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force + } + } + + $observed = & { + $tasks = @{} + function Register-GraphKitAuthTaskCapture { + param([string] $Name, [scriptblock] $Action) + $tasks[$Name] = $Action + } + Set-Alias -Name task -Value Register-GraphKitAuthTaskCapture -Scope Local + + . $script:taskPath + + $cleanupCalls = [Collections.Generic.List[string]]::new() + function Initialize-GraphKitAuthStageCapture {} + function Initialize-GraphKitAuthBuildAuthorityRoot { + throw [InvalidOperationException]::new('injected primary build failure') + } + function New-GraphKitAuthBuildWorkRoot { + [pscustomobject]@{ Path='fixture-work'; Name='.build-fixture'; Evidence='fixture-evidence' } + } + function Move-GraphKitAuthBuildWorkToQuarantine {} + function New-GraphKitAuthTaskQuarantineRoot { + [pscustomobject]@{ Path='fixture-work-quarantine' } + } + function Invoke-GraphKitAuthLiteralQuarantine { + param([string] $RepositoryRoot) + $cleanupCalls.Add($RepositoryRoot) + throw [IO.IOException]::new('injected secondary quarantine failure') + } + + $BuildRoot = Join-Path $TestDrive ('primary-failure-' + [guid]::NewGuid().ToString('N')) + $primaryFailure = $null + try { + & $tasks['Build_GraphKitAuth'] + } + catch { + $primaryFailure = $_ + } + $primaryCleanupCalls = $cleanupCalls.Count + + function Initialize-GraphKitAuthBuildAuthorityRoot {} + $dotnetCalls = [Collections.Generic.List[string]]::new() + function dotnet { + param([Parameter(ValueFromRemainingArguments)][object[]] $Arguments) + $call = [string] ($Arguments -join ' ') + $dotnetCalls.Add($call) + if ($call -ceq '--version') { + $global:LASTEXITCODE = 0 + 'injected diagnostic line' + '' + '10.0.400' + return + } + $global:LASTEXITCODE = 1 + } + + $lastExitCodeVariable = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $normalizedVersionFailure = $null + try { + & $tasks['Build_GraphKitAuth'] + } + catch { + $normalizedVersionFailure = $_ + } + finally { + if ($null -eq $lastExitCodeVariable) { + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + } + else { + $global:LASTEXITCODE = $lastExitCodeVariable.Value + } + } + + [pscustomobject]@{ + PrimaryFailure = $primaryFailure + PrimaryCleanupCalls = $primaryCleanupCalls + NormalizedVersionFailure = $normalizedVersionFailure + DotnetCalls = @($dotnetCalls) + } + } + + $observed.PrimaryCleanupCalls | Should -Be 1 + $observed.PrimaryFailure | Should -Not -BeNullOrEmpty + $observed.PrimaryFailure.Exception.GetType().FullName | Should -BeExactly 'System.InvalidOperationException' + $observed.PrimaryFailure.Exception.Message | Should -BeExactly 'injected primary build failure' + $observed.DotnetCalls[0] | Should -BeExactly '--version' + $observed.DotnetCalls[1] | Should -BeLike 'restore *' + $observed.NormalizedVersionFailure.Exception.Message | Should -BeExactly 'GraphKit.Auth locked restore failed.' + } + + It 'refuses an existing full-version stage without changing its bytes' { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-existing-' + [guid]::NewGuid().ToString('N')) + $fixtureVersion = '0.4.0-r8.fixture.existing' + try { + $first = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput -FullVersion $fixtureVersion -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + $before = (Get-FileHash -LiteralPath (Join-Path $first.StagePath 'manifest.json') -Algorithm SHA256).Hash + { New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput -FullVersion $fixtureVersion -PayloadSourceRoot (Join-Path $script:stagePath 'payload') } | Should -Throw '*already exists*' + (Get-FileHash -LiteralPath (Join-Path $first.StagePath 'manifest.json') -Algorithm SHA256).Hash | Should -BeExactly $before + } + finally { + try { + Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput | Out-Null + } + finally { + if ([IO.Directory]::Exists($fixtureOutput)) { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + [IO.Directory]::Delete($fixtureOutput, $true) + } + } + } + } + + It 'refuses to unseal a forged prior stage and leaves it in place' { + $fixture = New-GraphKitAuthStageFixture -Name 'forged-clean' + try { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + $manifestPath = Join-Path $fixture.StagePath 'manifest.json' + [IO.File]::WriteAllText($manifestPath, '{"forged":true}') + Set-GraphKitAuthTestStageSealed -StagePath $fixture.StagePath + + { Invoke-GraphKitAuthPrepareClean -OutputRoot $fixture.TestOutputRoot } | + Should -Throw '*manifest digest does not match*' + Test-Path -LiteralPath $fixture.StagePath -PathType Container | Should -BeTrue + (Get-Content -LiteralPath $manifestPath -Raw) | Should -BeExactly '{"forged":true}' + } + finally { + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'rejects a sealed stage after mutation' -ForEach @( + @{ Kind='missing'; ExpectedDiagnostic='payload closure is not exact' } + @{ Kind='extra'; ExpectedDiagnostic='payload closure is not exact' } + @{ Kind='renamed'; ExpectedDiagnostic='payload closure is not exact' } + @{ Kind='writable'; ExpectedDiagnostic='payload evidence failed' } + @{ Kind='byte-mutated'; ExpectedDiagnostic='payload evidence failed' } + @{ Kind='byte-identical-replaced'; ExpectedDiagnostic='payload evidence failed' } + @{ Kind='hard-link'; ExpectedDiagnostic='payload evidence failed' } + @{ Kind='escaped-link'; ExpectedDiagnostic='not the required no-follow regular file|without following a link|without following a reparse point' } + @{ Kind='case-alias'; ExpectedDiagnostic='payload closure is not exact' } + @{ Kind='separator-alias'; ExpectedDiagnostic='manifest digest does not match|unsafe or non-NFC name' } + @{ Kind='unicode-alias'; ExpectedDiagnostic='unsafe or non-NFC name|portable alias|payload closure is not exact' } + @{ Kind='platform-directory-alias'; ExpectedDiagnostic='not the required no-follow directory|without following a link|without following a reparse point' } + ) { + $fixture = New-GraphKitAuthStageFixture -Name $Kind + $cleanupArtifacts = [Collections.Generic.List[object]]::new() + try { + Invoke-GraphKitAuthStageMutation -Kind $Kind -StagePath $fixture.StagePath ` + -CleanupArtifacts $cleanupArtifacts + $failure = $null + try { $null = Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } + catch { $failure = $_.Exception.Message } + $failure | Should -Match $ExpectedDiagnostic + $failure | Should -Not -Match 'version, envelope, or manifest is writable' + } + finally { + Remove-GraphKitAuthTestMutationArtifacts -Artifacts $cleanupArtifacts + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'accepts an unmutated fixture after its exact sealed permissions are restored' { + $fixture = New-GraphKitAuthStageFixture -Name 'resealed-control' + try { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + Set-GraphKitAuthTestStageSealed -StagePath $fixture.StagePath + + { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | + Should -Not -Throw + } + finally { + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'rejects a manifest hard link without an extra stage entry masking link count' { + $fixture = New-GraphKitAuthStageFixture -Name 'manifest-hard-link' + $cleanupArtifacts = [Collections.Generic.List[object]]::new() + try { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + $manifestPath = Join-Path $fixture.StagePath 'manifest.json' + $outsideLink = Join-Path $TestDrive ('manifest-hard-link-' + [guid]::NewGuid().ToString('N') + '.json') + $linkArtifact = [pscustomobject]@{ + Path = $outsideLink; Directory = $false; Link = $true + RestorePath = $manifestPath; WidenParent = $false; Created = $false + } + $cleanupArtifacts.Add($linkArtifact) | Out-Null + New-GraphKitAuthTestHardLink -LinkPath $outsideLink -TargetPath $manifestPath + $linkArtifact.Created = $true + $linked = $script:GraphKitAuthStageCaptureType::InspectFile( + $fixture.StagePath, 'manifest.json') + [long] $linked.LinkCount | Should -Be 2 + Set-GraphKitAuthTestStageSealed -StagePath $fixture.StagePath + + { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | + Should -Throw '*manifest is not link-count one*' + } + finally { + Remove-GraphKitAuthTestMutationArtifacts -Artifacts $cleanupArtifacts + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'allows exactly one atomic same-version creator after both candidates reach the install barrier' { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-concurrent-' + [guid]::NewGuid().ToString('N')) + $fixtureVersion = '0.4.0-r8.fixture.concurrent' + $barrierKey = 'GraphKit.Task5.StageBarrier.' + [guid]::NewGuid().ToString('N') + $barrier = [Threading.Barrier]::new(2) + [AppDomain]::CurrentDomain.SetData($barrierKey, $barrier) + $workers = @() + try { + foreach ($workerId in 1..2) { + $worker = [PowerShell]::Create() + $null = $worker.AddScript({ + param($TaskPath, $OutputRoot, $Version, $PayloadRoot, $BarrierKey, $WorkerId) + $ErrorActionPreference = 'Stop' + . $TaskPath -SkipTaskRegistration + try { + $stage = New-GraphKitAuthSealedStage -OutputRoot $OutputRoot ` + -FullVersion $Version -PayloadSourceRoot $PayloadRoot ` + -AfterVersionDestinationCheck { + $shared = [AppDomain]::CurrentDomain.GetData($BarrierKey) + if (-not $shared.SignalAndWait([TimeSpan]::FromSeconds(30))) { + throw 'The same-version install barrier timed out.' + } + } + [pscustomobject]@{ Worker = $WorkerId; Succeeded = $true; StagePath = $stage.StagePath; Error = $null } + } + catch { + [pscustomobject]@{ Worker = $WorkerId; Succeeded = $false; StagePath = $null; Error = $_.Exception.Message } + } + }).AddArgument($script:taskPath).AddArgument($fixtureOutput).AddArgument($fixtureVersion). + AddArgument((Join-Path $script:stagePath 'payload')).AddArgument($barrierKey).AddArgument($workerId) + $workers += [pscustomobject]@{ PowerShell = $worker; Async = $worker.BeginInvoke() } + } + $results = @($workers | ForEach-Object { @($_.PowerShell.EndInvoke($_.Async)) }) + $resultSummary = $results | ConvertTo-Json -Depth 4 -Compress + @($results | Where-Object Succeeded).Count | Should -Be 1 -Because $resultSummary + @($results | Where-Object { -not $_.Succeeded }).Count | Should -Be 1 + $loser = @($results | Where-Object { -not $_.Succeeded })[0] + $loser.Error | Should -Match 'atomic destination collision' + $loser.Error | Should -Not -Match 'ambiguous cleanup|changed identity|resealing was refused|barrier timed out' + $versionRoot = Join-Path $fixtureOutput "GraphKit.Auth/stage/$fixtureVersion" + $entries = @([IO.Directory]::EnumerateFileSystemEntries($versionRoot)) + $entries.Count | Should -Be 1 + { Test-GraphKitAuthSealedStage -StagePath $entries[0] -FullVersion $fixtureVersion } | + Should -Not -Throw + @([IO.Directory]::EnumerateFileSystemEntries((Join-Path $fixtureOutput 'GraphKit.Auth/capture'))).Count | + Should -Be 0 + @([IO.Directory]::EnumerateFileSystemEntries((Join-Path $fixtureOutput 'GraphKit.Auth/stage')) | + Where-Object { [IO.Path]::GetFileName($_) -cne $fixtureVersion }).Count | Should -Be 0 + } + finally { + foreach ($worker in $workers) { $worker.PowerShell.Dispose() } + $barrier.Dispose() + [AppDomain]::CurrentDomain.SetData($barrierKey, $null) + if (Test-Path -LiteralPath (Join-Path $fixtureOutput 'GraphKit.Auth/stage')) { + Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput | Out-Null + } + } + } + + It 'preserves an identity-ambiguous losing install candidate without changing the winning version' { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-ambiguous-loser-' + [guid]::NewGuid().ToString('N')) + $fixtureVersion = '0.4.0-r8.fixture.ambiguous-loser' + $stageRoot = Join-Path $fixtureOutput 'GraphKit.Auth/stage' + try { + $winner = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion $fixtureVersion -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + $winningManifestHash = (Get-FileHash -LiteralPath $winner.ManifestPath -Algorithm SHA256).Hash + + { + New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion $fixtureVersion -PayloadSourceRoot (Join-Path $script:stagePath 'payload') ` + -BeforeVersionInstall { + param($temporaryVersionRoot) + $candidateEntries = @([IO.Directory]::EnumerateFileSystemEntries($temporaryVersionRoot)) + if ($candidateEntries.Count -ne 1) { + throw 'The ambiguous-loser fixture did not receive one digest envelope.' + } + Set-GraphKitAuthTestStageWritable -StagePath $candidateEntries[0] + [IO.File]::WriteAllText( + (Join-Path $candidateEntries[0] 'payload/GraphKit.Auth.dll'), + 'identity-ambiguous losing candidate') + Set-GraphKitAuthTestStageSealed -StagePath $candidateEntries[0] + } + } | Should -Throw '*ambiguous cleanup was refused*' + + (Get-FileHash -LiteralPath $winner.ManifestPath -Algorithm SHA256).Hash | + Should -BeExactly $winningManifestHash + { Test-GraphKitAuthSealedStage -StagePath $winner.StagePath -FullVersion $fixtureVersion } | + Should -Not -Throw + $installRoots = @([IO.Directory]::EnumerateFileSystemEntries($stageRoot) | Where-Object { + [IO.Path]::GetFileName($_).StartsWith('.install-', [StringComparison]::Ordinal) + }) + $installRoots.Count | Should -Be 1 + $loserVersions = @([IO.Directory]::EnumerateFileSystemEntries($installRoots[0])) + $loserVersions.Count | Should -Be 1 + [IO.Path]::GetFileName($loserVersions[0]) | Should -BeExactly $fixtureVersion + $loserDigests = @([IO.Directory]::EnumerateFileSystemEntries($loserVersions[0])) + $loserDigests.Count | Should -Be 1 + (Get-Content -LiteralPath (Join-Path $loserDigests[0] 'payload/GraphKit.Auth.dll') -Raw) | + Should -BeExactly 'identity-ambiguous losing candidate' + @([IO.Directory]::EnumerateFileSystemEntries((Join-Path $fixtureOutput 'GraphKit.Auth/capture'))).Count | + Should -Be 0 + } + finally { + if (Test-Path -LiteralPath $stageRoot -PathType Container) { + foreach ($installRoot in @([IO.Directory]::EnumerateFileSystemEntries($stageRoot) | Where-Object { + [IO.Path]::GetFileName($_).StartsWith('.install-', [StringComparison]::Ordinal) + })) { + foreach ($file in @(Get-ChildItem -LiteralPath $installRoot -File -Recurse -Force)) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($file.FullName, $false, $true) + } + foreach ($directory in @(Get-ChildItem -LiteralPath $installRoot -Directory -Recurse -Force | + Sort-Object { $_.FullName.Length } -Descending)) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($directory.FullName, $true, $true) + } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($installRoot, $true, $true) + Remove-Item -LiteralPath $installRoot -Recurse -Force + } + Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput | Out-Null + } + } + } + + It 'preserves an ambiguous install wrapper after before cleanup' -ForEach @( + @{ MutationKind = 'unexpected sibling' } + @{ MutationKind = 'wrapper replacement' } + ) { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-wrapper-cleanup-' + [guid]::NewGuid().ToString('N')) + $fixtureVersion = '0.4.0-r8.fixture.wrapper-cleanup' + $stageRoot = Join-Path $fixtureOutput 'GraphKit.Auth/stage' + try { + $failure = $null + try { + $null = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion $fixtureVersion -PayloadSourceRoot (Join-Path $script:stagePath 'payload') ` + -BeforeVersionInstall { + param($temporaryVersionRoot) + $installRoot = Split-Path $temporaryVersionRoot -Parent + $digestEntries = @([IO.Directory]::EnumerateFileSystemEntries($temporaryVersionRoot)) + if ($digestEntries.Count -ne 1) { throw 'The wrapper-cleanup fixture expected one digest.' } + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersionRoot, $true, $true) + if ($MutationKind -ceq 'unexpected sibling') { + $sibling = Join-Path $temporaryVersionRoot 'retained-unexpected-sibling' + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $temporaryVersionRoot, 'retained-unexpected-sibling') + $write = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $sibling, 'caller-owned.bin', + [Text.UTF8Encoding]::new($false).GetBytes('retained unexpected sibling'), $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + $write.Destination.PhysicalPath, $false, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($sibling, $true, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersionRoot, $true, $false) + } + else { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($installRoot, $true, $true) + $backup = Join-Path $installRoot 'retained-original-wrapper' + [IO.Directory]::Move($temporaryVersionRoot, $backup) + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $installRoot, $fixtureVersion) + $replacement = Join-Path $installRoot $fixtureVersion + $digestName = [IO.Path]::GetFileName($digestEntries[0]) + $digestSource = Join-Path $backup $digestName + $digestDestination = Join-Path $replacement $digestName + Set-GraphKitAuthTestStageWritable -StagePath $digestSource + [IO.Directory]::Move($digestSource, $digestDestination) + Set-GraphKitAuthTestStageSealed -StagePath $digestDestination + $write = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $backup, 'caller-owned.bin', + [Text.UTF8Encoding]::new($false).GetBytes('retained original wrapper'), $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + $write.Destination.PhysicalPath, $false, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($backup, $true, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($replacement, $true, $false) + } + throw "injected $MutationKind before install" + } + } + catch { $failure = $_.Exception.Message } + + $failure | Should -Match 'ambiguous cleanup was refused' + $failure | Should -Match ([regex]::Escape("injected $MutationKind before install")) + if ($MutationKind -ceq 'unexpected sibling') { + $failure | Should -Match 'temporary version wrapper closure is not exact' + } + else { + $failure | Should -Match 'temporary version wrapper changed identity' + } + $installRoots = @([IO.Directory]::EnumerateFileSystemEntries($stageRoot) | Where-Object { + [IO.Path]::GetFileName($_).StartsWith('.install-', [StringComparison]::Ordinal) + }) + $installRoots.Count | Should -Be 1 + $retained = @(Get-ChildItem -LiteralPath $installRoots[0] -Filter 'caller-owned.bin' -File -Recurse -Force) + $retained.Count | Should -Be 1 + (Get-Content -LiteralPath $retained[0].FullName -Raw) | Should -Match '^retained ' + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'removes only an identity-bound partial stage candidate after source link rejection' { + Assert-GraphKitAuthStageCommands + $fixtureRoot = Join-Path $TestDrive ('stage-partial-source-' + [guid]::NewGuid().ToString('N')) + $fixtureOutput = Join-Path $fixtureRoot 'output' + $source = Join-Path $fixtureRoot 'source' + $null = New-Item -ItemType Directory -Path $source, $fixtureOutput -Force + foreach ($name in $script:requiredGraphKitAuthFiles) { + Copy-Item -LiteralPath (Join-Path $script:stagePath "payload/$name") ` + -Destination (Join-Path $source $name) + } + $outsideLink = Join-Path $fixtureRoot 'contracts-second-link.dll' + $null = New-Item -ItemType HardLink -Path $outsideLink ` + -Target (Join-Path $source 'GraphKit.Auth.Contracts.dll') -ErrorAction Stop + try { + $failure = $null + try { + $null = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion '0.4.0-r8.fixture.partial-source' -PayloadSourceRoot $source + } + catch { $failure = $_.Exception.Message } + $failure | Should -Match "capture source or destination 'GraphKit.Auth.Contracts.dll' is not link-count one" + $failure | Should -Not -Match 'ambiguous cleanup|Original failure' + @([IO.Directory]::EnumerateFileSystemEntries( + (Join-Path $fixtureOutput 'GraphKit.Auth/capture'))).Count | Should -Be 0 + @([IO.Directory]::EnumerateFileSystemEntries( + (Join-Path $fixtureOutput 'GraphKit.Auth/stage'))).Count | Should -Be 0 + } + finally { + if (Test-Path -LiteralPath $fixtureOutput) { + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force + } + } + } + + It 'leaves only recoverable authority state after injected creation failure' -ForEach @( + @{ FailureKind = 'auth root' } + @{ FailureKind = 'capture root' } + @{ FailureKind = 'stage root' } + @{ FailureKind = 'capture payload' } + @{ FailureKind = 'temporary install root' } + ) { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-initialization-failure-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $fixtureOutput + $failure = $null + try { + $null = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion ('0.4.0-r8.fixture.initialization-' + $FailureKind.Replace(' ', '-')) ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') ` + -AfterOwnedDirectoryCreate { + param($kind) + if ($kind -ceq $FailureKind) { + throw "injected $kind creation failure" + } + } + } + catch { $failure = $_.Exception.Message } + + $failure | Should -Match ([regex]::Escape("injected $FailureKind creation failure")) + $failure | Should -Not -Match 'ambiguous cleanup|Original failure' + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + Test-Path -LiteralPath $authRoot -PathType Container | Should -BeTrue + { Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput } | + Should -Not -Throw + foreach ($rootName in @('capture','stage')) { + $root = Join-Path $authRoot $rootName + if (Test-Path -LiteralPath $root -PathType Container) { + @([IO.Directory]::EnumerateFileSystemEntries($root)).Count | Should -Be 0 + } + } + + $recoveryVersion = '0.4.0-r8.fixture.recovery-' + $FailureKind.Replace(' ', '-') + { + $null = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion $recoveryVersion ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + } | Should -Not -Throw + { Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput } | + Should -Not -Throw + } + + It 'creates with exact owner-only initial directory access' -ForEach @( + @{ DirectoryKind = 'auth root' } + @{ DirectoryKind = 'capture root' } + @{ DirectoryKind = 'stage root' } + @{ DirectoryKind = 'capture envelope' } + @{ DirectoryKind = 'capture payload' } + @{ DirectoryKind = 'temporary install root' } + @{ DirectoryKind = 'temporary version root' } + ) { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-initial-directory-access-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $fixtureOutput + if (-not $IsWindows) { & chmod 0755 $fixtureOutput } + $observed = [Collections.Generic.List[object]]::new() + try { + $fixture = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion ('0.4.0-r8.fixture.initial-directory-' + $DirectoryKind.Replace(' ', '-')) ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') ` + -AfterOwnedDirectoryCreate { + param($kind, $path, $initialEvidence) + if ($kind -ceq $DirectoryKind) { $observed.Add($initialEvidence) } + } + + $observed.Count | Should -Be 1 + if ($IsWindows) { + $observed[0].OwnerSid | Should -BeExactly $observed[0].CurrentOwnerSid + $observed[0].CurrentIdentitySid | Should -BeExactly ( + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + $observed[0].AccessRulesProtected | Should -BeTrue + $observed[0].HasInheritedAccessRules | Should -BeFalse + $observed[0].ExactWritableOwnerOnlyDirectoryAccess | Should -BeTrue + } + else { + $observed[0].UnixMode | Should -Be 0x1C0 + } + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'refuses to claim a widened existing authority root without changing it' -ForEach @( + @{ RootKind = 'auth' } + @{ RootKind = 'capture' } + @{ RootKind = 'stage' } + ) { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-existing-root-policy-' + [guid]::NewGuid().ToString('N')) + $baseline = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion ('0.4.0-r8.fixture.existing-root-baseline-' + $RootKind) ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + $authRoot = Split-Path (Split-Path (Split-Path $baseline.StagePath -Parent) -Parent) -Parent + $roots = [ordered]@{ + auth = $authRoot + capture = Join-Path $authRoot 'capture' + stage = Join-Path $authRoot 'stage' + } + $target = $roots[$RootKind] + $marker = Join-Path $target 'caller-owned-marker.bin' + $markerBytes = [Text.UTF8Encoding]::new($false).GetBytes("caller-owned-$RootKind") + $null = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $target, 'caller-owned-marker.bin', $markerBytes, $false) + if ($IsWindows) { + $acl = Get-Acl -LiteralPath $target + $acl.SetAccessRuleProtection($false, $false) + Set-Acl -LiteralPath $target -AclObject $acl + } + else { + & chmod 0755 $target + if ($LASTEXITCODE -ne 0) { throw "Could not widen the existing $RootKind authority root." } + } + $evidenceBefore = [ordered]@{} + $securityBefore = [ordered]@{} + $entriesBefore = [ordered]@{} + foreach ($entry in $roots.GetEnumerator()) { + $evidenceBefore[$entry.Key] = $script:GraphKitAuthStageCaptureType::InspectDirectory( + (Split-Path $entry.Value -Parent), [IO.Path]::GetFileName($entry.Value)) + $securityBefore[$entry.Key] = Get-GraphKitAuthTestDirectorySecurity -Path $entry.Value + $entriesBefore[$entry.Key] = @([IO.Directory]::EnumerateFileSystemEntries($entry.Value) | + ForEach-Object { [IO.Path]::GetFileName($_) } | Sort-Object) + } + $markerHash = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + try { + $failure = $null + try { + $null = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion ('0.4.0-r8.fixture.existing-root-candidate-' + $RootKind) ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + } + catch { $failure = $_.Exception.Message } + + $failure | Should -Match ([regex]::Escape("$RootKind root") + '.*exact current-owner-only writable.*before reuse') + foreach ($entry in $roots.GetEnumerator()) { + $current = $script:GraphKitAuthStageCaptureType::InspectDirectory( + (Split-Path $entry.Value -Parent), [IO.Path]::GetFileName($entry.Value)) + $current.NativeIdentity | Should -BeExactly $evidenceBefore[$entry.Key].NativeIdentity + $current.PhysicalPath | Should -BeExactly $evidenceBefore[$entry.Key].PhysicalPath + (Get-GraphKitAuthTestDirectorySecurity -Path $entry.Value) | + Should -BeExactly $securityBefore[$entry.Key] + @([IO.Directory]::EnumerateFileSystemEntries($entry.Value) | + ForEach-Object { [IO.Path]::GetFileName($_) } | Sort-Object) | + Should -BeExactly $entriesBefore[$entry.Key] + } + (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash | Should -BeExactly $markerHash + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'creates the build authority root atomically before mutable build children' { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('build-authority-root-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $fixtureOutput + $observed = [Collections.Generic.List[object]]::new() + try { + $evidence = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot $fixtureOutput ` + -AfterChildInspection { + param($kind, $path, $initialEvidence) + $observed.Add([pscustomobject]@{ Kind=$kind; Evidence=$initialEvidence }) + } + $observed.Count | Should -Be 2 + (@($observed.Kind) -join '|') | Should -BeExactly 'build auth root|build capture root' + foreach ($item in $observed) { + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($item.Evidence) | + Should -BeTrue + } + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($evidence) | + Should -BeTrue + $taskSource = Get-Content -LiteralPath $script:taskPath -Raw + $initializeIndex = $taskSource.IndexOf('Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot') + $firstMutableChildIndex = $taskSource.IndexOf('[IO.Directory]::CreateDirectory($resultRoot)') + $initializeIndex | Should -BeGreaterOrEqual 0 + $initializeIndex | Should -BeLessThan $firstMutableChildIndex + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'creates one owner-only build workspace before routing mutable build output beneath it' { + Assert-GraphKitAuthStageCommands + $fixtureRoot = Join-Path $TestDrive ('build-workspace-owner-' + [guid]::NewGuid().ToString('N')) + $fixtureOutput = Join-Path $fixtureRoot 'output' + $null = New-Item -ItemType Directory -Path $fixtureOutput + try { + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot $fixtureOutput + $runId = '1' * 48 + $workspace = New-GraphKitAuthBuildWorkRoot -OutputRoot $fixtureOutput -RunId $runId + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + $captureRoot = Join-Path $authRoot 'capture' + + $workspace.Name | Should -BeExactly ".build-$runId" + $workspace.Path | Should -BeExactly (Join-Path $captureRoot ".build-$runId") + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess( + $workspace.Evidence) | Should -BeTrue + $current = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $captureRoot, $workspace.Name) + $current.NativeIdentity | Should -BeExactly $workspace.Evidence.NativeIdentity + $current.PhysicalPath | Should -BeExactly $workspace.Evidence.PhysicalPath + + $taskSource = Get-Content -LiteralPath $script:taskPath -Raw + $buildTaskSource = [regex]::Match( + $taskSource, + '(?ms)^\s*task Build_GraphKitAuth \{.*?^\s*\}\r?\n\r?\n\s*task Copy_GraphKitAuth_Into_BuiltModule' + ).Value + $workspaceIndex = $buildTaskSource.IndexOf( + '$buildWork = New-GraphKitAuthBuildWorkRoot -OutputRoot') + $resultIndex = $buildTaskSource.IndexOf( + '$resultRoot = Join-Path $buildWork.Path ''dotnet-test''') + $publishIndex = $buildTaskSource.IndexOf( + '$publishRoot = Join-Path $buildWork.Path ''publish''') + $firstMutableIndex = $buildTaskSource.IndexOf( + '[IO.Directory]::CreateDirectory($resultRoot)') + $workspaceIndex | Should -BeGreaterOrEqual 0 + $resultIndex | Should -BeGreaterThan $workspaceIndex + $publishIndex | Should -BeGreaterThan $workspaceIndex + $firstMutableIndex | Should -BeGreaterThan $workspaceIndex + $buildTaskSource | Should -Not -Match '\$authOutput\s+["''](?:publish|dotnet-test)/\$runId' ` + -Because 'no mutable build artifact may be a top-level authority-root child' + $finallyIndex = $buildTaskSource.LastIndexOf('finally {') + $sourceQuarantineIndex = $buildTaskSource.IndexOf( + 'Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot') + $versionIndex = $buildTaskSource.IndexOf( + "scripts/Get-GraphKitTrainVersion.ps1") + $stageIndex = $buildTaskSource.IndexOf('New-GraphKitAuthSealedStage -OutputRoot') + $workspaceQuarantineIndex = $buildTaskSource.IndexOf( + 'Move-GraphKitAuthBuildWorkToQuarantine', $finallyIndex) + $sourceQuarantineIndex | Should -BeGreaterThan $workspaceIndex + $versionIndex | Should -BeGreaterThan $sourceQuarantineIndex + $stageIndex | Should -BeGreaterThan $versionIndex + $workspaceQuarantineIndex | Should -BeGreaterThan $finallyIndex + + $normalQuarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $fixtureRoot + $null = Move-GraphKitAuthBuildWorkToQuarantine -BuildWork $workspace ` + -QuarantineRoot $normalQuarantine + $createdEvidence = [Collections.Generic.List[object]]::new() + $failedRunId = '4' * 48 + { + New-GraphKitAuthBuildWorkRoot -OutputRoot $fixtureOutput -RunId $failedRunId ` + -AfterCreate { + param($evidence) + $createdEvidence.Add($evidence) + throw 'injected workspace post-create validation failure' + } + } | Should -Throw 'injected workspace post-create validation failure' + $createdEvidence.Count | Should -Be 1 + @([IO.Directory]::EnumerateFileSystemEntries($captureRoot)).Count | Should -Be 0 + $recovered = @(Get-ChildItem -LiteralPath $fixtureOutput -Directory -Force | Where-Object { + $_.Name -match '^GraphKit\.Auth\.quarantine-[0-9a-f]{32}$' -and + (Test-Path -LiteralPath (Join-Path $_.FullName ".build-$failedRunId") -PathType Container) + }) + $recovered.Count | Should -Be 1 + $recoveredEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $recovered[0].FullName, ".build-$failedRunId") + $recoveredEvidence.NativeIdentity | Should -BeExactly $createdEvidence[0].NativeIdentity + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureRoot + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'quarantines the exact completed workspace and restores a Prepare-authorized topology' { + Assert-GraphKitAuthStageCommands + $fixtureRoot = Join-Path $TestDrive ('build-workspace-complete-' + [guid]::NewGuid().ToString('N')) + $fixtureOutput = Join-Path $fixtureRoot 'output' + $null = New-Item -ItemType Directory -Path $fixtureOutput + try { + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot $fixtureOutput + $workspace = New-GraphKitAuthBuildWorkRoot -OutputRoot $fixtureOutput -RunId ('2' * 48) + $publish = Join-Path $workspace.Path 'publish' + $results = Join-Path $workspace.Path 'dotnet-test' + $null = [IO.Directory]::CreateDirectory($publish) + $null = [IO.Directory]::CreateDirectory($results) + [IO.File]::WriteAllText((Join-Path $publish 'provider.bin'), 'provider payload') + [IO.File]::WriteAllText((Join-Path $results 'GraphKit.Auth.trx'), 'test result') + $publishHash = (Get-FileHash -LiteralPath (Join-Path $publish 'provider.bin') -Algorithm SHA256).Hash + $resultHash = (Get-FileHash -LiteralPath (Join-Path $results 'GraphKit.Auth.trx') -Algorithm SHA256).Hash + $stageVersion = '0.4.0-r8.fixture.build-workspace-complete' + $stage = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion $stageVersion ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + + $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $fixtureRoot + $moved = Move-GraphKitAuthBuildWorkToQuarantine -BuildWork $workspace ` + -QuarantineRoot $quarantine + + $moved.Evidence.NativeIdentity | Should -BeExactly $workspace.Evidence.NativeIdentity + $moved.Path | Should -BeExactly (Join-Path $quarantine $workspace.Name) + Test-Path -LiteralPath $workspace.Path | Should -BeFalse + (Get-FileHash -LiteralPath (Join-Path $moved.Path 'publish/provider.bin') -Algorithm SHA256).Hash | + Should -BeExactly $publishHash + (Get-FileHash -LiteralPath (Join-Path $moved.Path 'dotnet-test/GraphKit.Auth.trx') -Algorithm SHA256).Hash | + Should -BeExactly $resultHash + $captureRoot = Join-Path $fixtureOutput 'GraphKit.Auth/capture' + @([IO.Directory]::EnumerateFileSystemEntries($captureRoot)).Count | Should -Be 0 + $prepared = @(Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput) + $prepared.Count | Should -Be 1 + $prepared[0].StagePath | Should -BeExactly $stage.StagePath + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureRoot + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'fails closed before moving a workspace whose captured identity was replaced' { + Assert-GraphKitAuthStageCommands + $fixtureRoot = Join-Path $TestDrive ('build-workspace-tamper-' + [guid]::NewGuid().ToString('N')) + $fixtureOutput = Join-Path $fixtureRoot 'output' + $null = New-Item -ItemType Directory -Path $fixtureOutput + try { + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot $fixtureOutput + $workspace = New-GraphKitAuthBuildWorkRoot -OutputRoot $fixtureOutput -RunId ('3' * 48) + [IO.File]::WriteAllText((Join-Path $workspace.Path 'original.bin'), 'captured workspace') + $captureRoot = Split-Path $workspace.Path -Parent + $preserved = Join-Path $captureRoot '.preserved-original' + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $fixtureRoot, 'foreign') + $foreignQuarantineName = 'GraphKit.Auth.quarantine-' + ('5' * 32) + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + (Join-Path $fixtureRoot 'foreign'), $foreignQuarantineName) + $foreignQuarantine = Join-Path (Join-Path $fixtureRoot 'foreign') $foreignQuarantineName + { + Move-GraphKitAuthBuildWorkToQuarantine -BuildWork $workspace ` + -QuarantineRoot $foreignQuarantine + } | Should -Throw '*not beneath the exact captured output root*' + Test-Path -LiteralPath $workspace.Path -PathType Container | Should -BeTrue + Test-Path -LiteralPath (Join-Path $foreignQuarantine $workspace.Name) | Should -BeFalse + + $replacedQuarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $fixtureRoot + $replacedQuarantineName = [IO.Path]::GetFileName($replacedQuarantine) + $preservedQuarantine = Join-Path $fixtureOutput '.preserved-quarantine' + $replacedQuarantineEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $fixtureOutput, $replacedQuarantineName) + { + Move-GraphKitAuthBuildWorkToQuarantine -BuildWork $workspace ` + -QuarantineRoot $replacedQuarantine -BeforeMove { + [IO.Directory]::Move($replacedQuarantine, $preservedQuarantine) + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $fixtureOutput, $replacedQuarantineName) + } + } | Should -Throw '*quarantine changed identity before the move*ambiguous cleanup was refused*' + $preservedQuarantineEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $fixtureOutput, '.preserved-quarantine') + $preservedQuarantineEvidence.NativeIdentity | + Should -BeExactly $replacedQuarantineEvidence.NativeIdentity + Test-Path -LiteralPath $workspace.Path -PathType Container | Should -BeTrue + Test-Path -LiteralPath (Join-Path $replacedQuarantine $workspace.Name) | Should -BeFalse + + $collisionQuarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $fixtureRoot + $collisionDestination = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $collisionQuarantine, $workspace.Name) + [IO.File]::WriteAllText( + (Join-Path $collisionQuarantine "$($workspace.Name)/caller.bin"), 'caller destination') + $sourceBeforeCollision = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $captureRoot, $workspace.Name) + { + Move-GraphKitAuthBuildWorkToQuarantine -BuildWork $workspace ` + -QuarantineRoot $collisionQuarantine + } | Should -Throw '*destination*already exists*no move was attempted*' + $sourceAfterCollision = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $captureRoot, $workspace.Name) + $destinationAfterCollision = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $collisionQuarantine, $workspace.Name) + $sourceAfterCollision.NativeIdentity | Should -BeExactly $sourceBeforeCollision.NativeIdentity + $destinationAfterCollision.NativeIdentity | Should -BeExactly $collisionDestination.NativeIdentity + (Get-Content -LiteralPath ( + Join-Path $collisionQuarantine "$($workspace.Name)/caller.bin") -Raw) | + Should -BeExactly 'caller destination' + + $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $fixtureRoot + + { + Move-GraphKitAuthBuildWorkToQuarantine -BuildWork $workspace ` + -QuarantineRoot $quarantine -BeforeMove { + [IO.Directory]::Move($workspace.Path, $preserved) + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $captureRoot, $workspace.Name) + [IO.File]::WriteAllText( + (Join-Path $workspace.Path 'replacement.bin'), 'caller replacement') + } + } | Should -Throw '*changed identity*ambiguous cleanup was refused*' + + $preservedEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $captureRoot, '.preserved-original') + $preservedEvidence.NativeIdentity | Should -BeExactly $workspace.Evidence.NativeIdentity + Test-Path -LiteralPath (Join-Path $workspace.Path 'replacement.bin') -PathType Leaf | + Should -BeTrue + Test-Path -LiteralPath (Join-Path $quarantine $workspace.Name) | Should -BeFalse + + $ancestorFixtureRoot = Join-Path $fixtureRoot 'ancestor-case' + $ancestorOutput = Join-Path $ancestorFixtureRoot 'output' + $null = [IO.Directory]::CreateDirectory($ancestorOutput) + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot $ancestorOutput + $ancestorWork = New-GraphKitAuthBuildWorkRoot ` + -OutputRoot $ancestorOutput -RunId ('6' * 48) + $ancestorCapture = Split-Path $ancestorWork.Path -Parent + $ancestorQuarantine = Invoke-GraphKitAuthLiteralQuarantine ` + -RepositoryRoot $ancestorFixtureRoot + $ancestorQuarantineName = [IO.Path]::GetFileName($ancestorQuarantine) + $ancestorCaptureEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath( + $ancestorCapture) + $ancestorQuarantineEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $ancestorOutput, $ancestorQuarantineName) + $preservedAncestorOutput = Join-Path $ancestorFixtureRoot 'preserved-output' + { + Move-GraphKitAuthBuildWorkToQuarantine -BuildWork $ancestorWork ` + -QuarantineRoot $ancestorQuarantine -BeforeMove { + [IO.Directory]::Move($ancestorOutput, $preservedAncestorOutput) + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $ancestorFixtureRoot, 'output') + $replacementAuth = Join-Path $ancestorOutput 'GraphKit.Auth' + $null = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $ancestorOutput, 'GraphKit.Auth') + [IO.Directory]::Move( + (Join-Path $preservedAncestorOutput 'GraphKit.Auth/capture'), + (Join-Path $replacementAuth 'capture')) + [IO.Directory]::Move( + (Join-Path $preservedAncestorOutput $ancestorQuarantineName), + (Join-Path $ancestorOutput $ancestorQuarantineName)) + } + } | Should -Throw '*output parent changed identity before the move*ambiguous cleanup was refused*' + $ancestorCaptureAfter = $script:GraphKitAuthStageCaptureType::InspectDirectory( + (Join-Path $ancestorOutput 'GraphKit.Auth'), 'capture') + $ancestorQuarantineAfter = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $ancestorOutput, $ancestorQuarantineName) + $ancestorCaptureAfter.NativeIdentity | + Should -BeExactly $ancestorCaptureEvidence.NativeIdentity + $ancestorQuarantineAfter.NativeIdentity | + Should -BeExactly $ancestorQuarantineEvidence.NativeIdentity + Test-Path -LiteralPath $ancestorWork.Path -PathType Container | Should -BeTrue + Test-Path -LiteralPath (Join-Path $ancestorQuarantine $ancestorWork.Name) | + Should -BeFalse + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureRoot + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'quarantines one partial workspace in finally without replacing the primary build failure' { + $observed = & { + $tasks = @{} + function Register-GraphKitAuthTaskCapture { + param([string] $Name, [scriptblock] $Action) + $tasks[$Name] = $Action + } + Set-Alias -Name task -Value Register-GraphKitAuthTaskCapture -Scope Local + . $script:taskPath + + $sourceQuarantines = [Collections.Generic.List[string]]::new() + $workspaceQuarantines = [Collections.Generic.List[object]]::new() + function Initialize-GraphKitAuthStageCapture {} + function Initialize-GraphKitAuthBuildAuthorityRoot {} + function New-GraphKitAuthBuildWorkRoot { + [pscustomobject]@{ Path='fixture-work'; Name='.build-fixture'; Evidence='fixture-evidence' } + } + function Invoke-GraphKitAuthLiteralQuarantine { + param([string] $RepositoryRoot) + $sourceQuarantines.Add($RepositoryRoot) + throw 'injected source quarantine failure' + } + function New-GraphKitAuthTaskQuarantineRoot { + param([string] $OutputRoot) + [pscustomobject]@{ Path='fixture-work-quarantine' } + } + function Move-GraphKitAuthBuildWorkToQuarantine { + param($BuildWork, [string] $QuarantineRoot) + $workspaceQuarantines.Add([pscustomobject]@{ + BuildWork = $BuildWork + QuarantineRoot = $QuarantineRoot + }) + [pscustomobject]@{ Path=(Join-Path $QuarantineRoot $BuildWork.Name) } + } + function dotnet { + param([Parameter(ValueFromRemainingArguments)][object[]] $Arguments) + if (($Arguments -join ' ') -ceq '--version') { + $global:LASTEXITCODE = 0 + '10.0.400' + return + } + $global:LASTEXITCODE = 1 + } + + $BuildRoot = Join-Path $TestDrive ('build-workspace-primary-' + [guid]::NewGuid().ToString('N')) + $failure = $null + $lastExitCodeVariable = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $savedWarningPreference = $WarningPreference + try { + $WarningPreference = 'Stop' + try { & $tasks['Build_GraphKitAuth'] } + catch { $failure = $_ } + } + finally { + $WarningPreference = $savedWarningPreference + if ($null -eq $lastExitCodeVariable) { + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + } + else { + $global:LASTEXITCODE = $lastExitCodeVariable.Value + } + } + [pscustomobject]@{ + Failure = $failure + SourceQuarantines = @($sourceQuarantines) + WorkspaceQuarantines = @($workspaceQuarantines) + } + } + + $observed.Failure.Exception.Message | Should -BeExactly 'GraphKit.Auth locked restore failed.' + $observed.SourceQuarantines.Count | Should -Be 1 + $observed.WorkspaceQuarantines.Count | Should -Be 1 + $observed.WorkspaceQuarantines[0].BuildWork.Path | Should -BeExactly 'fixture-work' + $observed.WorkspaceQuarantines[0].QuarantineRoot | + Should -BeExactly 'fixture-work-quarantine' + } + + It 'leaves an exact Prepare-authorized topology after failure immediately following build authority initialization' { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('build-authority-failure-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $fixtureOutput + $failure = $null + try { + try { + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot $fixtureOutput + throw 'injected failure after build authority initialization' + } + catch { $failure = $_.Exception.Message } + + $failure | Should -BeExactly 'injected failure after build authority initialization' + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + $captureRoot = Join-Path $authRoot 'capture' + $authEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $fixtureOutput, 'GraphKit.Auth') + $captureEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $authRoot, 'capture') + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($authEvidence) | + Should -BeTrue + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($captureEvidence) | + Should -BeTrue + @([IO.Directory]::EnumerateFileSystemEntries($captureRoot)).Count | Should -Be 0 + { Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput } | Should -Not -Throw + @(Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput).Count | Should -Be 0 + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'preserves a recoverable build authority root when capture initialization fails' { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('build-authority-capture-failure-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $fixtureOutput + $failure = $null + try { + try { + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot $fixtureOutput ` + -AfterChildInspection { + param($kind) + if ($kind -ceq 'build capture root') { + throw 'injected build capture root failure' + } + } + } + catch { $failure = $_.Exception.Message } + + $failure | Should -BeExactly 'injected build capture root failure' + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + Test-Path -LiteralPath $authRoot -PathType Container | Should -BeTrue + $captureRoot = Join-Path $authRoot 'capture' + Test-Path -LiteralPath $captureRoot -PathType Container | Should -BeTrue + @([IO.Directory]::EnumerateFileSystemEntries($captureRoot)).Count | Should -Be 0 + { Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput } | + Should -Not -Throw + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'rejects a portable root alias before changing its bytes or permissions' -ForEach $portableRootAliasCases { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-portable-root-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $fixtureOutput + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + $aliasParent = switch ($RootKind) { + 'auth' { $fixtureOutput } + 'capture' { + $null = New-Item -ItemType Directory -Path $authRoot + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($authRoot, $true, $true) + $authRoot + } + 'stage' { + $null = New-Item -ItemType Directory -Path (Join-Path $authRoot 'capture') -Force + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($authRoot, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + (Join-Path $authRoot 'capture'), $true, $true) + $authRoot + } + } + $requestedName = switch ($RootKind) { + 'auth' { 'GraphKit.Auth' } + 'capture' { 'capture' } + 'stage' { 'stage' } + } + $null = New-Item -ItemType Directory -Path (Join-Path $aliasParent $AliasName) + $aliasEntry = @([IO.Directory]::EnumerateFileSystemEntries($aliasParent) | Where-Object { + [IO.Path]::GetFileName($_).Normalize([Text.NormalizationForm]::FormC).Equals( + $requestedName.Normalize([Text.NormalizationForm]::FormC), + [StringComparison]::OrdinalIgnoreCase) + })[0] + $marker = Join-Path $aliasEntry 'caller-owned.txt' + [IO.File]::WriteAllText($marker, 'caller-owned-portable-root') + if (-not $IsWindows) { & chmod 0755 $aliasEntry } + $securityBefore = Get-GraphKitAuthTestDirectorySecurity -Path $aliasEntry + $hashBefore = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + try { + $failure = $null + try { + $null = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion ("0.4.0-r8.fixture.portable-root-$RootKind") ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + } + catch { $failure = $_.Exception.Message } + + $failure | Should -Match 'portable alias' + (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash | Should -BeExactly $hashBefore + (Get-GraphKitAuthTestDirectorySecurity -Path $aliasEntry) | Should -BeExactly $securityBefore + @([IO.Directory]::EnumerateFileSystemEntries($aliasEntry) | ForEach-Object { + [IO.Path]::GetFileName($_) + }) | Should -BeExactly @('caller-owned.txt') + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'rejects a portable version alias before atomic install without changing it' -ForEach $portableVersionAliasCases { + Assert-GraphKitAuthStageCommands + $fixtureOutput = Join-Path $TestDrive ('stage-portable-version-' + [guid]::NewGuid().ToString('N')) + $stageRoot = Join-Path $fixtureOutput 'GraphKit.Auth/stage' + $captureRoot = Join-Path $fixtureOutput 'GraphKit.Auth/capture' + $null = New-Item -ItemType Directory -Path $stageRoot, $captureRoot -Force + $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Join-Path $fixtureOutput 'GraphKit.Auth'), $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captureRoot, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRoot, $true, $true) + $null = New-Item -ItemType Directory -Path (Join-Path $stageRoot $AliasName) + $aliasEntry = @([IO.Directory]::EnumerateFileSystemEntries($stageRoot) | Where-Object { + [IO.Path]::GetFileName($_).Normalize([Text.NormalizationForm]::FormC).Equals( + $ExpectedName.Normalize([Text.NormalizationForm]::FormC), + [StringComparison]::OrdinalIgnoreCase) + })[0] + $marker = Join-Path $aliasEntry 'caller-owned.txt' + [IO.File]::WriteAllText($marker, 'caller-owned-portable-version') + if (-not $IsWindows) { & chmod 0755 $aliasEntry } + $securityBefore = Get-GraphKitAuthTestDirectorySecurity -Path $aliasEntry + $hashBefore = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + try { + $failure = $null + try { + $null = New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion $ExpectedName -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + } + catch { $failure = $_.Exception.Message } + + $failure | Should -Match 'portable alias|stage version .* already exists\.$' + $failure | Should -Not -Match 'MoveDirectoryCreateNew|atomically install|already exists or won' + (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash | Should -BeExactly $hashBefore + (Get-GraphKitAuthTestDirectorySecurity -Path $aliasEntry) | Should -BeExactly $securityBefore + @([IO.Directory]::EnumerateFileSystemEntries($aliasEntry) | ForEach-Object { + [IO.Path]::GetFileName($_) + }) | Should -BeExactly @('caller-owned.txt') + @([IO.Directory]::EnumerateFileSystemEntries($captureRoot)).Count | Should -Be 0 + @([IO.Directory]::EnumerateFileSystemEntries($stageRoot)).Count | Should -Be 1 + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'refuses Prepare when the capture root retains an unverified entry without changing it' { + Assert-GraphKitAuthStageCommands + Initialize-GraphKitAuthStageCapture + $fixtureOutput = Join-Path $TestDrive ('stage-prepare-capture-' + [guid]::NewGuid().ToString('N')) + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + $captureRoot = Join-Path $authRoot 'capture' + $null = New-Item -ItemType Directory -Path $captureRoot -Force + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($authRoot, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captureRoot, $true, $true) + $marker = Join-Path $captureRoot 'retained-ambiguous.bin' + [IO.File]::WriteAllText($marker, 'retained-ambiguous-capture') + $captureBefore = $script:GraphKitAuthStageCaptureType::InspectDirectory($authRoot, 'capture') + $securityBefore = Get-GraphKitAuthTestDirectorySecurity -Path $captureRoot + $hashBefore = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + try { + { Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput } | + Should -Throw '*capture root*empty*' + $captureAfter = $script:GraphKitAuthStageCaptureType::InspectDirectory($authRoot, 'capture') + $captureAfter.NativeIdentity | Should -BeExactly $captureBefore.NativeIdentity + $captureAfter.PhysicalPath | Should -BeExactly $captureBefore.PhysicalPath + (Get-GraphKitAuthTestDirectorySecurity -Path $captureRoot) | Should -BeExactly $securityBefore + (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash | Should -BeExactly $hashBefore + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'refuses Prepare when the authority root is not exact owner-only writable' -ForEach @( + @{ RootKind = 'auth' } + @{ RootKind = 'capture' } + @{ RootKind = 'stage' } + ) { + $fixture = New-GraphKitAuthStageFixture -Name ('prepare-root-policy-' + $RootKind) + $outputRoot = $fixture.TestOutputRoot + $authRoot = Join-Path $outputRoot 'GraphKit.Auth' + $roots = [ordered]@{ + auth = $authRoot + capture = Join-Path $authRoot 'capture' + stage = Join-Path $authRoot 'stage' + } + $target = $roots[$RootKind] + if ($IsWindows) { + $acl = Get-Acl -LiteralPath $target + $acl.SetAccessRuleProtection($false, $false) + Set-Acl -LiteralPath $target -AclObject $acl + } + else { + & chmod 0755 $target + if ($LASTEXITCODE -ne 0) { throw "Could not widen the $RootKind authority root fixture." } + } + $evidenceBefore = [ordered]@{} + $securityBefore = [ordered]@{} + foreach ($entry in $roots.GetEnumerator()) { + $parent = Split-Path $entry.Value -Parent + $name = [IO.Path]::GetFileName($entry.Value) + $evidenceBefore[$entry.Key] = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $name) + $securityBefore[$entry.Key] = Get-GraphKitAuthTestDirectorySecurity -Path $entry.Value + } + $manifestHashBefore = (Get-FileHash -LiteralPath $fixture.ManifestPath -Algorithm SHA256).Hash + $versionSecurityBefore = Get-GraphKitAuthTestDirectorySecurity -Path (Split-Path $fixture.StagePath -Parent) + try { + { Invoke-GraphKitAuthPrepareClean -OutputRoot $outputRoot } | + Should -Throw "*Prepare $RootKind root*owner-only writable*" + foreach ($entry in $roots.GetEnumerator()) { + $current = $script:GraphKitAuthStageCaptureType::InspectDirectory( + (Split-Path $entry.Value -Parent), [IO.Path]::GetFileName($entry.Value)) + $current.NativeIdentity | Should -BeExactly $evidenceBefore[$entry.Key].NativeIdentity + $current.PhysicalPath | Should -BeExactly $evidenceBefore[$entry.Key].PhysicalPath + (Get-GraphKitAuthTestDirectorySecurity -Path $entry.Value) | + Should -BeExactly $securityBefore[$entry.Key] + } + (Get-FileHash -LiteralPath $fixture.ManifestPath -Algorithm SHA256).Hash | + Should -BeExactly $manifestHashBefore + (Get-GraphKitAuthTestDirectorySecurity -Path (Split-Path $fixture.StagePath -Parent)) | + Should -BeExactly $versionSecurityBefore + } + finally { + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'rejects a portable collision in a complete directory-name set' { + { Assert-GraphKitAuthPortableNameSet -Names @('release-v1', 'RELEASE-V1') ` + -Kind 'stage version namespace' } | Should -Throw '*portable alias*' + } + + It 'refuses two independently valid portable-alias versions before changing either' -ForEach $linuxCaseSensitiveStageAliasCases -AllowNullOrEmptyForEach { + $fixtureRoot = Join-Path ([IO.Path]::GetTempPath()) ('stage-prepare-version-alias-' + [guid]::NewGuid().ToString('N')) + $outputA = Join-Path $fixtureRoot 'output-a' + $outputB = Join-Path $fixtureRoot 'output-b' + $lowerVersion = '0.4.0-r8.fixture.prepare-alias' + $upperVersion = '0.4.0-r8.fixture.PREPARE-ALIAS' + try { + $first = New-GraphKitAuthSealedStage -OutputRoot $outputA -FullVersion $lowerVersion ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + $second = New-GraphKitAuthSealedStage -OutputRoot $outputB -FullVersion $upperVersion ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + $stageRootA = Join-Path $outputA 'GraphKit.Auth/stage' + $stageRootB = Join-Path $outputB 'GraphKit.Auth/stage' + $versionRootA = Split-Path $first.StagePath -Parent + $versionRootB = Split-Path $second.StagePath -Parent + $movedVersionRootB = Join-Path $stageRootA $upperVersion + try { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootA, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootB, $true, $true) + # Linux Directory.Move probes both version directories in addition to their + # rename parents. Temporarily restore owner-write on those sealed wrappers, + # then reseal them before the assertions inspect either candidate. + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($versionRootA, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($versionRootB, $true, $true) + [IO.Directory]::Move($versionRootB, $movedVersionRootB) + } + finally { + if (Test-Path -LiteralPath $versionRootA -PathType Container) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($versionRootA, $true, $false) + } + if (Test-Path -LiteralPath $versionRootB -PathType Container) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($versionRootB, $true, $false) + } + if (Test-Path -LiteralPath $movedVersionRootB -PathType Container) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($movedVersionRootB, $true, $false) + } + if (Test-Path -LiteralPath $stageRootA -PathType Container) { + # Prepare owns mutations beneath this authority root, so the fixture + # must leave the parent writable while both candidate versions remain sealed. + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootA, $true, $true) + } + if (Test-Path -LiteralPath $stageRootB -PathType Container) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootB, $true, $false) + } + } + $movedSecondStage = Join-Path $movedVersionRootB ([IO.Path]::GetFileName($second.StagePath)) + { Test-GraphKitAuthSealedStage -StagePath $first.StagePath -FullVersion $lowerVersion } | + Should -Not -Throw + { Test-GraphKitAuthSealedStage -StagePath $movedSecondStage -FullVersion $upperVersion } | + Should -Not -Throw + $versionPaths = @($versionRootA, (Split-Path $movedSecondStage -Parent)) + $securityBefore = @($versionPaths | ForEach-Object { + Get-GraphKitAuthTestDirectorySecurity -Path $_ + }) + $hashesBefore = @($first.ManifestPath, (Join-Path $movedSecondStage 'manifest.json') | ForEach-Object { + (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash + }) + + { Invoke-GraphKitAuthPrepareClean -OutputRoot $outputA } | + Should -Throw '*stage version namespace*portable alias*' + for ($index = 0; $index -lt $versionPaths.Count; $index++) { + (Get-GraphKitAuthTestDirectorySecurity -Path $versionPaths[$index]) | + Should -BeExactly $securityBefore[$index] + } + @($first.ManifestPath, (Join-Path $movedSecondStage 'manifest.json') | ForEach-Object { + (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash + }) | Should -BeExactly $hashesBefore + } + finally { + Set-GraphKitAuthTestTreeWritable -Path $fixtureRoot + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'normalizes native Windows paths and handles unavailable Linux renameat2 fail-closed' { + $helper = Get-Content -LiteralPath ( + Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs') -Raw + $helper | Should -Match 'catch\s*\(EntryPointNotFoundException' + $helper | Should -Match 'ENOSYS|errno\s*==\s*38' + $helper | Should -Match 'renameat2[^\r\n]*unavailable[^\r\n]*no fallback' + + Initialize-GraphKitAuthStageCapture + $normalizer = $script:GraphKitAuthStageCaptureType.GetMethod( + 'NormalizeWindowsPhysicalPath', + [Reflection.BindingFlags]'NonPublic, Static') + $normalizer | Should -Not -BeNullOrEmpty + $normalizer.Invoke($null, [object[]] @('\\?\UNC\server\share\file.bin')) | + Should -BeExactly '\\server\share\file.bin' + $normalizer.Invoke($null, [object[]] @('\\?\C:\repo\file.bin')) | + Should -BeExactly 'C:\repo\file.bin' + $extender = $script:GraphKitAuthStageCaptureType.GetMethod( + 'ToExtendedWindowsPath', + [Reflection.BindingFlags]'NonPublic, Static') + $extender | Should -Not -BeNullOrEmpty + $extender.Invoke($null, [object[]] @('C:\repo\file.bin')) | + Should -BeExactly '\\?\C:\repo\file.bin' + $extender.Invoke($null, [object[]] @('\\server\share\file.bin')) | + Should -BeExactly '\\?\UNC\server\share\file.bin' + $extender.Invoke($null, [object[]] @('\\?\C:\repo\file.bin')) | + Should -BeExactly '\\?\C:\repo\file.bin' + { $extender.Invoke($null, [object[]] @('relative\file.bin')) } | + Should -Throw '*fully qualified Windows path*' + { $extender.Invoke($null, [object[]] @('\\.\PhysicalDrive0')) } | + Should -Throw '*Windows device path*' + + $linkSafetyRoot = Join-Path $TestDrive ( + 'acl-link-safety-' + [guid]::NewGuid().ToString('N')) + $targetPath = Join-Path $TestDrive ( + 'acl-link-target-' + [guid]::NewGuid().ToString('N') + '.bin') + $null = New-Item -ItemType Directory -Path $linkSafetyRoot + try { + $regularPath = Join-Path $linkSafetyRoot 'regular.bin' + $hardLinkPath = Join-Path $linkSafetyRoot 'hard-link.bin' + [IO.File]::WriteAllText($regularPath, 'regular') + [IO.File]::WriteAllText($targetPath, 'shared') + $null = New-Item -ItemType HardLink -Path $hardLinkPath ` + -Target $targetPath -ErrorAction Stop + + (Test-GraphKitAuthTestAclMutationSafe -Item ( + Get-Item -LiteralPath $regularPath -Force)) | Should -BeTrue + (Test-GraphKitAuthTestAclMutationSafe -Item ( + Get-Item -LiteralPath $hardLinkPath -Force)) | Should -BeFalse + (Test-GraphKitAuthTestAclMutationSafe -Item ([pscustomobject] @{ + LinkType = $null + Attributes = [IO.FileAttributes]::ReparsePoint + })) | Should -BeFalse + + if ($IsWindows) { + $targetAclBefore = (Get-Acl -LiteralPath $targetPath).Sddl + $targetAttributesBefore = [IO.File]::GetAttributes($targetPath) + } + else { + [IO.File]::SetUnixFileMode( + $targetPath, [IO.UnixFileMode]::UserRead) + $targetUnixModeBefore = [IO.File]::GetUnixFileMode($targetPath) + } + { Set-GraphKitAuthTestTreeWritable -Path $linkSafetyRoot } | + Should -Throw '*refused a link or reparse entry*' + if ($IsWindows) { + (Get-Acl -LiteralPath $targetPath).Sddl | + Should -BeExactly $targetAclBefore + [IO.File]::GetAttributes($targetPath) | + Should -Be $targetAttributesBefore + } + else { + [IO.File]::GetUnixFileMode($targetPath) | + Should -Be $targetUnixModeBefore + } + + Remove-Item -LiteralPath $linkSafetyRoot -Recurse -Force + (Test-Path -LiteralPath $hardLinkPath) | Should -BeFalse + (Test-Path -LiteralPath $targetPath -PathType Leaf) | Should -BeTrue + } + finally { + Remove-Item -LiteralPath $linkSafetyRoot -Recurse -Force ` + -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue + } + + $liveParityTestPath = Join-Path -Path $script:repoRoot ` + -ChildPath 'tests/QA/GraphKitAuthLiveParity.tests.ps1' + $liveParityTest = Get-Content -LiteralPath $liveParityTestPath -Raw + $liveParityTest | Should -Match ( + '(?s)IsNullOrEmpty\(\[string\]\s*\$item\.LinkType\).*?ReparsePoint') + + $aliasVersion = Join-Path $TestDrive ( + 'acl-alias-version-' + [guid]::NewGuid().ToString('N')) + $outsideStage = Join-Path $TestDrive ( + 'acl-alias-target-' + [guid]::NewGuid().ToString('N')) + $stageAlias = Join-Path $aliasVersion 'stage' + $null = New-Item -ItemType Directory -Path $aliasVersion, ( + Join-Path $outsideStage 'payload') -Force + try { + $outsideSecurityBefore = if ($IsWindows) { + (Get-Acl -LiteralPath $outsideStage).Sddl + } + else { + [IO.File]::GetUnixFileMode($outsideStage) + } + $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { + 'SymbolicLink' + }) -Path $stageAlias -Target $outsideStage -ErrorAction Stop + + { Set-GraphKitAuthTestStageWritable -StagePath $stageAlias } | + Should -Throw '*refused a link or reparse root*' + if ($IsWindows) { + (Get-Acl -LiteralPath $outsideStage).Sddl | + Should -BeExactly $outsideSecurityBefore + } + else { + [IO.File]::GetUnixFileMode($outsideStage) | + Should -Be $outsideSecurityBefore + } + } + finally { + Remove-Item -LiteralPath $stageAlias -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $aliasVersion -Recurse -Force ` + -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $outsideStage -Recurse -Force ` + -ErrorAction SilentlyContinue + } + } + + It 'reports an existing atomic destination as a collision and changes neither directory' { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ('atomic-destination-collision-' + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source-version' + $destination = Join-Path $root 'final-version' + $null = New-Item -ItemType Directory -Path $source, $destination -Force + [IO.File]::WriteAllText((Join-Path $source 'source.bin'), 'source-unchanged') + [IO.File]::WriteAllText((Join-Path $destination 'destination.bin'), 'destination-unchanged') + $sourceBefore = $script:GraphKitAuthStageCaptureType::InspectDirectory($root, 'source-version') + $destinationBefore = $script:GraphKitAuthStageCaptureType::InspectDirectory($root, 'final-version') + $sourceHash = (Get-FileHash -LiteralPath (Join-Path $source 'source.bin') -Algorithm SHA256).Hash + $destinationHash = (Get-FileHash -LiteralPath (Join-Path $destination 'destination.bin') -Algorithm SHA256).Hash + + { $script:GraphKitAuthStageCaptureType::MoveDirectoryCreateNew($source, $destination) } | + Should -Throw '*atomic destination collision*' + + $sourceAfter = $script:GraphKitAuthStageCaptureType::InspectDirectory($root, 'source-version') + $destinationAfter = $script:GraphKitAuthStageCaptureType::InspectDirectory($root, 'final-version') + $sourceAfter.NativeIdentity | Should -BeExactly $sourceBefore.NativeIdentity + $destinationAfter.NativeIdentity | Should -BeExactly $destinationBefore.NativeIdentity + (Get-FileHash -LiteralPath (Join-Path $source 'source.bin') -Algorithm SHA256).Hash | + Should -BeExactly $sourceHash + (Get-FileHash -LiteralPath (Join-Path $destination 'destination.bin') -Algorithm SHA256).Hash | + Should -BeExactly $destinationHash + } + + It 'leaves source and destination unchanged when injected Linux renameat2 is unavailable' -ForEach $linuxAtomicRenameCases -AllowNullOrEmptyForEach { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ('linux-renameat2-unavailable-' + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source-version' + $destination = Join-Path $root 'final-version' + $null = New-Item -ItemType Directory -Path $source -Force + $marker = Join-Path $source 'marker.bin' + [IO.File]::WriteAllText($marker, 'atomic-source-unchanged') + $sourceBefore = $script:GraphKitAuthStageCaptureType::InspectDirectory($root, 'source-version') + $hashBefore = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + + { $script:GraphKitAuthStageCaptureType::MoveDirectoryCreateNew($source, $destination, $true) } | + Should -Throw '*Linux renameat2*unavailable*no fallback*' + + Test-Path -LiteralPath $destination | Should -BeFalse + $sourceAfter = $script:GraphKitAuthStageCaptureType::InspectDirectory($root, 'source-version') + $sourceAfter.NativeIdentity | Should -BeExactly $sourceBefore.NativeIdentity + $sourceAfter.PhysicalPath | Should -BeExactly $sourceBefore.PhysicalPath + (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash | Should -BeExactly $hashBefore + } + + It 'rejects an existing Unix symlink root without touching its target' -ForEach $unixRootAliasCases -AllowNullOrEmptyForEach { + Assert-GraphKitAuthStageCommands + $fixtureRoot = Join-Path $TestDrive ('stage-symlink-root-' + [guid]::NewGuid().ToString('N')) + $fixtureOutput = Join-Path $fixtureRoot 'output' + $external = Join-Path $fixtureRoot 'external' + $null = New-Item -ItemType Directory -Path $fixtureOutput, $external -Force + $marker = Join-Path $external 'caller-owned.txt' + [IO.File]::WriteAllText($marker, 'caller-owned') + & chmod 0755 $external + $modeBefore = [IO.File]::GetUnixFileMode($external) + $hashBefore = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + $linkPath = switch ($RootKind) { + 'auth' { $authRoot } + 'capture' { + $null = New-Item -ItemType Directory -Path $authRoot + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($authRoot, $true, $true) + Join-Path $authRoot 'capture' + } + 'stage' { + $null = New-Item -ItemType Directory -Path (Join-Path $authRoot 'capture') -Force + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($authRoot, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + (Join-Path $authRoot 'capture'), $true, $true) + Join-Path $authRoot 'stage' + } + } + $null = New-Item -ItemType SymbolicLink -Path $linkPath -Target $external + try { + { New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput -FullVersion "0.4.0-r8.fixture.symlink-$RootKind" ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') } | Should -Throw '*without following*' + + (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash | Should -BeExactly $hashBefore + [IO.File]::GetUnixFileMode($external) | Should -Be $modeBefore + @([IO.Directory]::EnumerateFileSystemEntries($external) | ForEach-Object { [IO.Path]::GetFileName($_) }) | + Should -BeExactly @('caller-owned.txt') + } + finally { + $stageItem = Get-Item -LiteralPath (Join-Path $authRoot 'stage') -Force -ErrorAction SilentlyContinue + if ($null -ne $stageItem -and $stageItem.LinkType -notin @('SymbolicLink','Junction')) { + Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput | Out-Null + } + & chmod -R u+rwX $external + [IO.File]::SetUnixFileMode($external, $modeBefore) + } + } + + It 'rejects an existing Windows junction root without touching its target' -ForEach $windowsRootAliasCases -AllowNullOrEmptyForEach { + Assert-GraphKitAuthStageCommands + $fixtureRoot = Join-Path $TestDrive ('stage-junction-root-' + [guid]::NewGuid().ToString('N')) + $fixtureOutput = Join-Path $fixtureRoot 'output' + $external = Join-Path $fixtureRoot 'external' + $null = New-Item -ItemType Directory -Path $fixtureOutput, $external -Force + $marker = Join-Path $external 'caller-owned.txt' + [IO.File]::WriteAllText($marker, 'caller-owned') + $aclBefore = (Get-Acl -LiteralPath $external).Sddl + $hashBefore = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + $authRoot = Join-Path $fixtureOutput 'GraphKit.Auth' + $linkPath = switch ($RootKind) { + 'auth' { $authRoot } + 'capture' { + $null = New-Item -ItemType Directory -Path $authRoot + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($authRoot, $true, $true) + Join-Path $authRoot 'capture' + } + 'stage' { + $null = New-Item -ItemType Directory -Path (Join-Path $authRoot 'capture') -Force + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($authRoot, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly( + (Join-Path $authRoot 'capture'), $true, $true) + Join-Path $authRoot 'stage' + } + } + $linkCreated = $false + try { + $null = New-Item -ItemType Junction -Path $linkPath -Target $external ` + -ErrorAction Stop + $linkCreated = $true + { New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput -FullVersion "0.4.0-r8.fixture.junction-$RootKind" ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') } | + Should -Throw '*not the required no-follow directory*' + + (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash | Should -BeExactly $hashBefore + (Get-Acl -LiteralPath $external).Sddl | Should -BeExactly $aclBefore + @([IO.Directory]::EnumerateFileSystemEntries($external) | ForEach-Object { [IO.Path]::GetFileName($_) }) | + Should -BeExactly @('caller-owned.txt') + } + finally { + try { + if ($linkCreated -and [IO.Directory]::Exists($linkPath)) { + [IO.Directory]::Delete($linkPath, $false) + } + } + finally { + if ([IO.Directory]::Exists($fixtureRoot)) { + Set-GraphKitAuthTestTreeWritable -Path $fixtureRoot + [IO.Directory]::Delete($fixtureRoot, $true) + } + } + } + } + + It 'returns create-new initial-access evidence for the sealed manifest' { + $fixture = New-GraphKitAuthStageFixture -Name 'manifest-initial' + try { + $fixture.PSObject.Properties.Name | Should -Contain 'ManifestInitialEvidence' + $fixture.ManifestInitialEvidence.IsRegularFile | Should -BeTrue + if ($IsWindows) { + $fixture.ManifestInitialEvidence.OwnerOnlyAccess | Should -BeTrue + $fixture.ManifestInitialEvidence.OwnerSid | + Should -BeExactly $fixture.ManifestInitialEvidence.CurrentOwnerSid + $fixture.ManifestInitialEvidence.CurrentIdentitySid | Should -BeExactly ( + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + } + else { + $fixture.ManifestInitialEvidence.UnixMode | Should -Be 0x180 + } + } + finally { + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'rejects owner-mismatched initial and sealed evidence' { + Initialize-GraphKitAuthStageCapture + $evidence = [Activator]::CreateInstance($script:GraphKitAuthStageCaptureType.Assembly.GetType( + $script:GraphKitAuthStageCaptureType.Namespace + '.GraphKitAuthPathEvidence')) + $evidence.OwnerOnlyAccess = $true + $evidence.OwnerSid = 'S-1-5-21-111' + $evidence.CurrentIdentitySid = 'S-1-5-21-333' + $evidence.CurrentOwnerSid = 'S-1-5-21-222' + + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyAccess($evidence) | Should -BeFalse + if (-not $IsWindows) { + $evidence.UnixMode = 0x100 + $evidence.OwnerUid = [uint32] 1 + $evidence.EffectiveUid = [uint32] 2 + (Test-GraphKitAuthSealedPermission -Evidence $evidence -Directory $false) | + Should -BeFalse + } + } + + It 'records link count one for regular files but not directories' { + Assert-GraphKitAuthStageCommands + $verified = Test-GraphKitAuthSealedStage -StagePath $script:stagePath -FullVersion $script:fullVersion + @($verified.Manifest.files).Count | Should -Be 5 + @($verified.Manifest.files | Where-Object linkCount -NE 1).Count | Should -Be 0 + $verified.Manifest.manifest.linkCount | Should -Be 1 + $verified.Manifest.directories.envelope.PSObject.Properties.Name | Should -Not -Contain 'linkCount' + $verified.Manifest.directories.payload.PSObject.Properties.Name | Should -Not -Contain 'linkCount' + } + + It 'restores every inherited process Git configuration value after a partial scope failure' { + $before = @(Get-ChildItem Env: | Where-Object Name -Like 'GIT_CONFIG_*' | Sort-Object Name | + ForEach-Object { "$($_.Name)=$($_.Value)" }) + $patterns = 1..5 | ForEach-Object { "/task5-exact-fixture-$_.dll" } + { Enable-GraphKitAuthAbiTestGitExcludes -RepositoryRoot $script:repoRoot -Patterns $patterns ` + -AfterFirstEnvironmentWrite { throw 'injected partial environment failure' } } | + Should -Throw '*injected partial environment failure*' + $after = @(Get-ChildItem Env: | Where-Object Name -Like 'GIT_CONFIG_*' | Sort-Object Name | + ForEach-Object { "$($_.Name)=$($_.Value)" }) + ($after -join '|') | Should -BeExactly ($before -join '|') + } + + It 'does not delete a pre-existing projection path when cleanup has no fixture state' { + $root = Join-Path $TestDrive ('projection-null-state-' + [guid]::NewGuid().ToString('N')) + $preexisting = Join-Path $root 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.dll' + $null = New-Item -ItemType Directory -Path (Split-Path $preexisting -Parent) -Force + [IO.File]::WriteAllText($preexisting, 'caller-owned') + $script:GraphKitAuthAbiFixtureState = $null + + { Remove-GraphKitAuthAbiTestFixture -RepositoryRoot $root } | Should -Not -Throw + + Test-Path -LiteralPath $preexisting -PathType Leaf | Should -BeTrue + [IO.File]::ReadAllText($preexisting) | Should -BeExactly 'caller-owned' + } + + It 'removes recorded empty projection directories after setup fails before a file is copied' { + $root = Join-Path $TestDrive ('projection-directory-state-' + [guid]::NewGuid().ToString('N')) + $bin = Join-Path $root 'src/GraphKit.Auth/GraphKit.Auth/bin' + $release = Join-Path $bin 'Release' + $destination = Join-Path $release 'net8.0' + $null = [IO.Directory]::CreateDirectory($destination) + $createdDirectories = [Collections.Generic.List[string]]::new() + foreach ($directory in @($bin, $release, $destination)) { + $createdDirectories.Add($directory) + } + $script:GraphKitAuthAbiFixtureState = [pscustomobject]@{ + BaselineState = $null + StatusBefore = @() + CreatedPaths = [Collections.Generic.List[string]]::new() + CreatedDirectories = $createdDirectories + Completed = $false + ExpectedEvidence = [ordered]@{} + } + + { Remove-GraphKitAuthAbiTestFixture -RepositoryRoot $root } | Should -Not -Throw + + Test-Path -LiteralPath $bin | Should -BeFalse + } + + It 'records a copied ABI projection before any subsequent validation' { + $task = Get-Content -LiteralPath $script:taskPath -Raw + $fixtureSource = [regex]::Match( + $task, + '(?ms)^function New-GraphKitAuthAbiTestFixture \{.*?^\}' + ).Value + $copyIndex = $fixtureSource.IndexOf( + '$copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew(') + $createdPathIndex = $fixtureSource.IndexOf( + '$script:GraphKitAuthAbiFixtureState.CreatedPaths.Add($destinationFile)', + $copyIndex) + $evidenceIndex = $fixtureSource.IndexOf( + '$script:GraphKitAuthAbiFixtureState.ExpectedEvidence[$destinationFile] = $copy.Destination', + $createdPathIndex) + $validationIndex = $fixtureSource.IndexOf( + '$manifestRecord = @($verified.Manifest.files', + $evidenceIndex) + + $copyIndex | Should -BeGreaterOrEqual 0 + $createdPathIndex | Should -BeGreaterThan $copyIndex + $evidenceIndex | Should -BeGreaterThan $createdPathIndex + $validationIndex | Should -BeGreaterThan $evidenceIndex + } + + It 'uses the platform-safe failure policy before evidence with owner-only=' -ForEach @( + @{ Operation = 'copy'; OwnerOnly = $false } + @{ Operation = 'copy'; OwnerOnly = $true } + @{ Operation = 'write'; OwnerOnly = $false } + @{ Operation = 'write'; OwnerOnly = $true } + ) { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ( + "$Operation-post-create-failure-$OwnerOnly-" + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'destination' + $null = New-Item -ItemType Directory -Path $source, $destination -Force + [IO.File]::WriteAllBytes((Join-Path $source 'candidate.dll'), [byte[]](1..32)) + $captured = Join-Path $destination 'candidate.dll' + $sourceHash = (Get-FileHash -LiteralPath (Join-Path $source 'candidate.dll') ` + -Algorithm SHA256).Hash + $parentBefore = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($destination) + + $failure = $null + try { + if ($Operation -ceq 'copy') { + $null = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $destination, 'candidate.dll', + $OwnerOnly, [long]::MaxValue, $true) + } + else { + $null = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $destination, 'candidate.dll', [byte[]](33..64), + $OwnerOnly, $true) + } + } + catch { $failure = $_.Exception.Message } + + $failure | Should -Match "Injected post-create $Operation failure" + $parentAfter = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($destination) + $parentAfter.NativeIdentity | Should -BeExactly $parentBefore.NativeIdentity + $parentAfter.PhysicalPath | Should -BeExactly $parentBefore.PhysicalPath + (Get-FileHash -LiteralPath (Join-Path $source 'candidate.dll') -Algorithm SHA256).Hash | + Should -BeExactly $sourceHash + if ($IsWindows) { + Test-Path -LiteralPath $captured | Should -BeFalse + $failure | Should -Not -Match 'no path deletion|zero-byte collision|explicitly recover' + } + else { + Test-Path -LiteralPath $captured -PathType Leaf | Should -BeTrue + (Get-Item -LiteralPath $captured).Length | Should -Be 0 + $failure | Should -Match 'Unix has no portable exact-handle path-deletion primitive' + $failure | Should -Match 'did not delete any path' + $failure | Should -Match 'explicitly recover the zero-byte collision' + } + } + + It 'deletes only a recorded projection and preserves an unrecorded partial materialization' { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ('projection-partial-state-' + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0' + $null = New-Item -ItemType Directory -Path $source, $destination -Force + [IO.File]::WriteAllBytes((Join-Path $source 'GraphKit.Auth.dll'), [byte[]](1..32)) + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'GraphKit.Auth.dll', $destination, 'GraphKit.Auth.dll') + $recorded = Join-Path $destination 'GraphKit.Auth.dll' + $unrecorded = Join-Path $destination 'GraphKit.Auth.deps.json' + [IO.File]::WriteAllText($unrecorded, 'partial-unregistered') + $created = [Collections.Generic.List[string]]::new() + $created.Add($recorded) + $script:GraphKitAuthAbiFixtureState = [pscustomobject]@{ + BaselineState = $null + StatusBefore = @() + CreatedPaths = $created + Completed = $false + ExpectedEvidence = [ordered]@{ $recorded = $copy.Destination } + } + + { Remove-GraphKitAuthAbiTestFixture -RepositoryRoot $root } | Should -Throw '*non-empty projected parent*' + + Test-Path -LiteralPath $recorded -PathType Leaf | Should -BeFalse + Test-Path -LiteralPath $unrecorded -PathType Leaf | Should -BeTrue + [IO.File]::ReadAllText($unrecorded) | Should -BeExactly 'partial-unregistered' + } + + It 'declares exact Windows ACL evidence and preserves handle-bound mutation ordering' { + $helper = Get-Content -LiteralPath ( + Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs') -Raw + $task = Get-Content -LiteralPath $script:taskPath -Raw + Get-Command Set-GraphKitAuthWindowsAclMutation -CommandType Function -ErrorAction Stop | + Should -Not -BeNullOrEmpty + foreach ($property in @( + 'OwnerSid', 'CurrentIdentitySid', 'CurrentOwnerSid', 'AccessRulesProtected', + 'HasInheritedAccessRules', 'ExactOwnerOnlyAccess' + )) { + $helper | Should -Match ([regex]::Escape($property + ' { get; init; }')) + $task | Should -Match ([regex]::Escape('$Evidence.' + $property)) + } + $helper | Should -Match ( + 'FileSystemRights expectedRights\s*=\s*FileSystemRights\.ReadAndExecute\s*\|\s*FileSystemRights\.Synchronize') + $helper | Should -Match ( + 'writable\s*\?\s*FileSystemRights\.FullControl\s*:\s*FileSystemRights\.ReadAndExecute') + $task | Should -Not -Match "'windows-owner-read'" + $task | Should -Match ( + "permissions\.file -cne .*?'windows-owner-read-execute'") + $helper | Should -Match 'InheritanceFlags\.None' + $helper | Should -Match ([regex]::Escape( + 'private const uint WriteDacAccess = 0x00040000;')) + $helper | Should -Match ([regex]::Escape( + 'private const uint WriteOwnerAccess = 0x00080000;')) + @([regex]::Matches($helper, + 'SetOwnerOnlyWritableFile\(\s*destinationStream,\s*destinationPath\)')).Count | + Should -Be 2 + $handleSetter = [regex]::Match($helper, + '(?ms)^ private static void SetOwnerOnlyWritableFile\(.*?(?=^ private static )').Value + $handleSetter | Should -Not -BeNullOrEmpty + $handleSetter | Should -Match ( + 'FileSystemAclExtensions\.SetAccessControl\(\s*stream,\s*security\)') + $handleSetter | Should -Not -Match 'SetOwnerOnlyWindows|new FileInfo|File\.SetAttributes' + $nativeFacts = [regex]::Match($helper, + '(?ms)^ private static NativeFacts GetNativeFacts\(.*?(?=^ private static )').Value + $nativeFacts | Should -Match ( + 'GetWindowsPermissionFacts\(\s*handle,\s*directory,\s*info\.FileAttributes\)') + $nativeFacts | Should -Match 'ownerUid\s*=\s*BitConverter\.ToUInt32\(stat,\s*16\)' + $nativeFacts | Should -Match 'ownerUid\s*=\s*BitConverter\.ToUInt32\(stat,\s*24\)' + $nativeFacts | Should -Match 'ownerUid\s*=\s*BitConverter\.ToUInt32\(stat,\s*28\)' + $nativeFacts | Should -Match 'effectiveUid\s*=\s*geteuid\(\)' + $permissionReader = [regex]::Match($helper, + '(?ms)^ private static WindowsPermissionFacts GetWindowsPermissionFacts\(.*?(?=^ private static )').Value + $permissionReader | Should -Match 'GetSecurityInfo\(\s*handle,' + $permissionReader | Should -Match 'fileAttributes\s*&\s*FileAttributeReadOnly' + $permissionReader | Should -Match 'GetCurrentTokenOwnerSid\(\)' + $permissionReader | Should -Not -Match 'new DirectoryInfo|new FileInfo|File\.GetAttributes' + $helper | Should -Match 'GetTokenInformation\(\s*identity\.Token,\s*TokenOwner' + $openDestination = [regex]::Match($helper, + '(?ms)^ private static FileStream OpenDestinationCreateNew\(.*?(?=^ private static )').Value + $openDestination | Should -Match ( + 'GenericRead \| GenericWrite \| DeleteAccess \| WriteDacAccess \| WriteOwnerAccess') + $openDestination | Should -Match '(?s)CreateFileWithSecurityW\(.*?ShareRead,' + $openDestination | Should -Match '(?s)CreateFileW\(.*?ShareRead,' + $openDestination | Should -Not -Match 'ShareWrite|ShareDelete' + $pathSetter = [regex]::Match($helper, + '(?ms)^ private static void SetOwnerOnlyWindows\(.*?(?=^ private )').Value + $pathSetter | Should -Match ( + '(?s)currentTokenOwner = GetCurrentTokenOwnerSid\(\);.*?if \(!currentOwner\.Equals\(currentTokenOwner\)\).*?throw new IOException') + $pathSetter | Should -Match ( + 'CreateOwnerOnlyWindowsSecurity\(\s*directory,\s*writable,\s*setOwner: false\)') + $pathSetter | Should -Not -Match 'security\.SetOwner|setOwner\s*=' + $sealAttributes = $pathSetter.IndexOf('if (!directory && !writable &&') + $applyAcl = $pathSetter.IndexOf('FileSystemAclExtensions.SetAccessControl') + $unsealAttributes = $pathSetter.IndexOf('if (!directory && writable &&') + $pathSetter | Should -Match ( + 'FileAttributes attributes = directory \? default : File\.GetAttributes\(path\);') + $pathSetter | Should -Match ( + '(?s)!writable.*?== 0.*?File\.SetAttributes\(\s*path,\s*\(attributes & ~FileAttributes\.Normal\) \|\s*FileAttributes\.ReadOnly\)') + $pathSetter | Should -Match ( + '(?s)writable.*?!= 0.*?FileAttributes writableAttributes =\s*attributes & ~FileAttributes\.ReadOnly;.*?writableAttributes == 0 \? FileAttributes\.Normal : writableAttributes') + $sealAttributes | Should -BeGreaterOrEqual 0 + $applyAcl | Should -BeGreaterThan $sealAttributes + $unsealAttributes | Should -BeGreaterThan $applyAcl + } + + It 'orders owner-only parent security before child creation and records initial child access' { + $helper = Get-Content -LiteralPath ( + Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs') -Raw + $task = Get-Content -LiteralPath $script:taskPath -Raw + $helper | Should -Match ([regex]::Escape('DestinationInitial { get; init; }')) + $helper | Should -Match ([regex]::Escape('OwnerOnlyAccess { get; init; }')) + $helper | Should -Match ([regex]::Escape( + 'options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite')) + $helper | Should -Match 'writable.*ContainerInherit.*ObjectInherit' + @([regex]::Matches($helper, + 'OpenDestinationCreateNew\(\s*destinationPath,\s*requireInitialOwnerOnly\)')).Count | + Should -Be 2 + $helper | Should -Match '(?s)FileSecurity security = new\(\);.*security\.SetOwner\(owner\);.*security\.SetAccessRuleProtection\(isProtected: true, preserveInheritance: false\);.*CreateFileWithSecurityW' + $newStage = [regex]::Match($task, + '(?ms)^function New-GraphKitAuthSealedStage \{.*?^\}').Value + $captureRootSecure = $newStage.IndexOf("-ChildName 'capture' -Kind 'capture root'") + $captureSecure = $newStage.IndexOf('-ChildName $runId -Kind ''capture envelope''') + $payloadSecure = $newStage.IndexOf("-ChildName 'payload' -Kind 'capture payload'") + $captureRootSecure | Should -BeGreaterOrEqual 0 + $captureSecure | Should -BeGreaterThan $captureRootSecure + $payloadSecure | Should -BeGreaterThan $captureSecure + $initializer = [regex]::Match($task, + '(?ms)^function Initialize-GraphKitAuthOwnerDirectory \{.*?^\}').Value + $inspectBefore = $initializer.IndexOf('InspectDirectory($parent, $ChildName)') + $validateExisting = $initializer.LastIndexOf('HasInitialOwnerOnlyDirectoryAccess($before)') + $postCreateMutation = $initializer.IndexOf('SetOwnerOnly($child, $true, $true)') + $inspectAfter = $initializer.LastIndexOf('InspectDirectory($parent, $ChildName)') + $inspectBefore | Should -BeGreaterOrEqual 0 + $validateExisting | Should -BeGreaterThan $inspectBefore + $postCreateMutation | Should -Be -1 + $inspectAfter | Should -BeGreaterThan $validateExisting + } + + It 'requires initial owner-only access only for the sealed capture copy call' { + $helper = Get-Content -LiteralPath ( + Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs') -Raw + $task = Get-Content -LiteralPath $script:taskPath -Raw + $helper | Should -Match 'bool requireInitialOwnerOnly\s*=\s*false' + $trueCalls = @([regex]::Matches($task, + '(?s)CopyFileCreateNew\([^;]+?,\s*\$true\s*\)')) + $trueCalls.Count | Should -Be 1 + $newStage = [regex]::Match($task, + '(?ms)^function New-GraphKitAuthSealedStage \{.*?^\}').Value + $newStage | Should -Match '(?s)CopyFileCreateNew\([^;]+?,\s*\$true\s*\)' + } + + It 'creates a Unix child as mode 0600 beneath a pre-secured mode 0700 parent' -ForEach $unixInitialAccessCases -AllowNullOrEmptyForEach { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ('unix-initial-access-' + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'destination' + $null = New-Item -ItemType Directory -Path $source, $destination -Force + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($destination, $true, $true) + [IO.File]::WriteAllBytes((Join-Path $source 'candidate.dll'), [byte[]](1..32)) + + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $destination, 'candidate.dll') + + $parentEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($root, 'destination') + $parentEvidence.UnixMode | Should -Be 0x1C0 + $parentEvidence.PSObject.Properties.Name | Should -Contain 'OwnerUid' + $parentEvidence.PSObject.Properties.Name | Should -Contain 'EffectiveUid' + $parentEvidence.OwnerUid | Should -Be $parentEvidence.EffectiveUid + $wrongDirectoryOwner = [Activator]::CreateInstance($parentEvidence.GetType()) + $wrongDirectoryOwner.GetType().GetProperty('UnixMode').SetValue( + $wrongDirectoryOwner, [int] 0x1C0) + $wrongDirectoryOwner.GetType().GetProperty('IsDirectory').SetValue( + $wrongDirectoryOwner, $true) + $wrongDirectoryOwner.GetType().GetProperty('OwnerUid').SetValue( + $wrongDirectoryOwner, [uint32] 1) + $wrongDirectoryOwner.GetType().GetProperty('EffectiveUid').SetValue( + $wrongDirectoryOwner, [uint32] 2) + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess( + $wrongDirectoryOwner) | Should -BeFalse + $copy.DestinationInitial.UnixMode | Should -Be 0x180 + $copy.DestinationInitial.OwnerUid | Should -Be $copy.DestinationInitial.EffectiveUid + $wrongFileOwner = [Activator]::CreateInstance($copy.DestinationInitial.GetType()) + $wrongFileOwner.GetType().GetProperty('UnixMode').SetValue( + $wrongFileOwner, [int] 0x180) + $wrongFileOwner.GetType().GetProperty('OwnerUid').SetValue( + $wrongFileOwner, [uint32] 1) + $wrongFileOwner.GetType().GetProperty('EffectiveUid').SetValue( + $wrongFileOwner, [uint32] 2) + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyAccess( + $wrongFileOwner) | Should -BeFalse + $copy.Destination.UnixMode | Should -Be 0x180 + } + + It 'creates a Windows child with current-identity access and round trips repeated seal transitions' -ForEach $windowsInitialAccessCases -AllowNullOrEmptyForEach { + Initialize-GraphKitAuthStageCapture + $longParent = Join-Path $TestDrive ('windows-initial-access-' + ('a' * 120)) + $root = Join-Path $longParent ('nested-' + ('b' * 120)) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'destination' + $null = New-Item -ItemType Directory -Path $source, $destination -Force + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $worldSid = [Security.Principal.SecurityIdentifier]::new( + [Security.Principal.WellKnownSidType]::WorldSid, $null) + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $currentSid, [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $worldSid, [Security.AccessControl.FileSystemRights]::Read, + [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + [IO.FileSystemAclExtensions]::SetAccessControl( + [IO.DirectoryInfo]::new($destination), $acl) + [IO.File]::WriteAllBytes((Join-Path $source 'candidate.dll'), [byte[]](1..32)) + + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $destination, 'candidate.dll', $true) + $ordinaryBytes = [Text.Encoding]::UTF8.GetBytes('ordinary-long-path-write') + $ordinaryWrite = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $destination, 'ordinary.bin', $ordinaryBytes, $false) + + $copy.DestinationInitial.OwnerOnlyAccess | Should -BeTrue + $copy.DestinationInitial.OwnerSid | + Should -BeExactly $copy.DestinationInitial.CurrentOwnerSid + $copy.DestinationInitial.CurrentIdentitySid | Should -BeExactly $currentSid.Value + $copy.DestinationInitial.AccessRulesProtected | Should -BeTrue + $copy.DestinationInitial.HasInheritedAccessRules | Should -BeFalse + $copy.Destination.PhysicalPath.StartsWith('\\?\', [StringComparison]::Ordinal) | + Should -BeFalse + $ordinaryWrite.Destination.PhysicalPath.StartsWith( + '\\?\', [StringComparison]::Ordinal) | Should -BeFalse + $ordinaryWrite.Destination.Sha256 | Should -BeExactly ( + [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($ordinaryBytes)).ToLowerInvariant()) + $ordinaryPath = Join-Path $destination 'ordinary.bin' + $ordinaryOwner = [IO.FileSystemAclExtensions]::GetAccessControl( + [IO.FileInfo]::new($ordinaryPath), + [Security.AccessControl.AccessControlSections]::Owner + ).GetOwner([Security.Principal.SecurityIdentifier]).Value + $ordinaryWrite.Destination.OwnerSid | Should -BeExactly $ordinaryOwner + $ordinaryWrite.Destination.CurrentOwnerSid | Should -BeExactly $ordinaryOwner + $ordinaryWrite.Destination.CurrentIdentitySid | Should -BeExactly $currentSid.Value + { $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $destination, 'ordinary.bin', $ordinaryBytes, $false) } | + Should -Throw '*Atomic file destination collision*' + + $raceOriginalName = 'permission-original.bin' + $raceReplacementName = 'permission-replacement.bin' + $raceParkedName = 'permission-original-parked.bin' + $raceOriginal = Join-Path $destination $raceOriginalName + $raceReplacement = Join-Path $destination $raceReplacementName + $raceParked = Join-Path $destination $raceParkedName + $raceWrite = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $destination, $raceOriginalName, + [Text.Encoding]::UTF8.GetBytes('original-handle-object'), $true) + [IO.File]::WriteAllText($raceReplacement, 'replacement-path-object') + $privateStatic = [Reflection.BindingFlags]'NonPublic, Static' + $openReadNoFollow = $script:GraphKitAuthStageCaptureType.GetMethod( + 'OpenReadNoFollow', $privateStatic) + $getNativeFacts = $script:GraphKitAuthStageCaptureType.GetMethod( + 'GetNativeFacts', $privateStatic) + $openReadNoFollow | Should -Not -BeNullOrEmpty + $getNativeFacts | Should -Not -BeNullOrEmpty + $raceHandle = $openReadNoFollow.Invoke( + $null, [object[]] @([string] $raceOriginal, [bool] $false)) + try { + [IO.File]::Move($raceOriginal, $raceParked) + [IO.File]::Move($raceReplacement, $raceOriginal) + [IO.File]::SetAttributes($raceOriginal, [IO.FileAttributes]::ReadOnly) + $handleFacts = $getNativeFacts.Invoke( + $null, [object[]] @( + [Microsoft.Win32.SafeHandles.SafeFileHandle] $raceHandle, + [string] $raceOriginal)) + $factsType = $handleFacts.GetType() + $instanceNonPublic = [Reflection.BindingFlags]'Instance, NonPublic' + $factsType.GetProperty('Identity', $instanceNonPublic).GetValue($handleFacts) | + Should -BeExactly $raceWrite.Destination.NativeIdentity + $factsType.GetProperty('PermissionEvidence', $instanceNonPublic).GetValue($handleFacts) | + Should -BeExactly $raceWrite.Destination.PermissionEvidence + $factsType.GetProperty('OwnerOnlyAccess', $instanceNonPublic).GetValue($handleFacts) | + Should -BeTrue + $factsType.GetProperty('FileReadOnly', $instanceNonPublic).GetValue($handleFacts) | + Should -BeFalse + } + finally { + $raceHandle.Dispose() + if (Test-Path -LiteralPath $raceOriginal -PathType Leaf) { + [IO.File]::SetAttributes($raceOriginal, [IO.FileAttributes]::Normal) + } + } + $moveSource = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly( + $root, 'move-source') + $moveDestination = Join-Path $root 'move-destination' + $script:GraphKitAuthStageCaptureType::MoveDirectoryCreateNew( + $moveSource.PhysicalPath, $moveDestination) + $moved = $script:GraphKitAuthStageCaptureType::InspectDirectory( + $root, 'move-destination') + $moved.NativeIdentity | Should -BeExactly $moveSource.NativeIdentity + $moved.PhysicalPath.StartsWith('\\?\', [StringComparison]::Ordinal) | + Should -BeFalse + $captured = Join-Path $destination 'candidate.dll' + [IO.File]::SetAttributes($captured, [IO.FileAttributes]::Archive) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $false) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $false) + $sealed = $script:GraphKitAuthStageCaptureType::InspectFile($destination, 'candidate.dll') + $sealed.OwnerSid | Should -BeExactly $sealed.CurrentOwnerSid + $sealed.CurrentIdentitySid | Should -BeExactly $currentSid.Value + $sealed.ExactOwnerOnlyAccess | Should -BeTrue + $sealed.OwnerWritable | Should -BeFalse + $sealed.FileReadOnly | Should -BeTrue + ([IO.File]::GetAttributes($captured) -band [IO.FileAttributes]::Archive) | + Should -Be ([IO.FileAttributes]::Archive) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $true) + $unsealed = $script:GraphKitAuthStageCaptureType::InspectFile($destination, 'candidate.dll') + $unsealed.OwnerSid | Should -BeExactly $unsealed.CurrentOwnerSid + $unsealed.CurrentIdentitySid | Should -BeExactly $currentSid.Value + $unsealed.AccessRulesProtected | Should -BeTrue + $unsealed.HasInheritedAccessRules | Should -BeFalse + $unsealed.OwnerOnlyAccess | Should -BeTrue + $unsealed.OwnerWritable | Should -BeTrue + $unsealed.FileReadOnly | Should -BeFalse + ([IO.File]::GetAttributes($captured) -band [IO.FileAttributes]::Archive) | + Should -Be ([IO.FileAttributes]::Archive) + $renamed = Join-Path $destination 'candidate-renamed.dll' + [IO.File]::Move($captured, $renamed) + [IO.File]::Delete($renamed) + Test-Path -LiteralPath $renamed | Should -BeFalse + } + + It 'preserves ordinary Windows inheritance but overrides it for a sealed copy' -ForEach $windowsInitialAccessCases -AllowNullOrEmptyForEach { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ('windows-scoped-initial-gate-' + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'ordinary' + $sealedDestination = Join-Path $root 'sealed-required' + $null = New-Item -ItemType Directory -Path $source, $destination, $sealedDestination -Force + [IO.File]::WriteAllBytes((Join-Path $source 'candidate.dll'), [byte[]](1..32)) + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $worldSid = [Security.Principal.SecurityIdentifier]::new( + [Security.Principal.WellKnownSidType]::WorldSid, $null) + foreach ($directory in @($destination, $sealedDestination)) { + $acl = Get-Acl -LiteralPath $directory + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $currentSid, [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $worldSid, [Security.AccessControl.FileSystemRights]::Read, + [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $directory -AclObject $acl + } + + $ordinary = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $destination, 'candidate.dll', $false) + $ordinary.DestinationInitial.OwnerOnlyAccess | Should -BeFalse + $ordinary.Destination.OwnerSid | Should -BeExactly $ordinary.Destination.CurrentOwnerSid + $ordinary.Destination.CurrentIdentitySid | Should -BeExactly $currentSid.Value + $ordinary.Destination.AccessRulesProtected | Should -BeTrue + $ordinary.Destination.HasInheritedAccessRules | Should -BeFalse + $ordinary.Destination.OwnerOnlyAccess | Should -BeTrue + $ordinary.Destination.OwnerWritable | Should -BeTrue + + $sealed = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $sealedDestination, 'candidate.dll', $true) + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyAccess($sealed.DestinationInitial) | + Should -BeTrue + $sealed.DestinationInitial.AccessRulesProtected | Should -BeTrue + $sealed.DestinationInitial.HasInheritedAccessRules | Should -BeFalse + $sealed.Destination.OwnerSid | Should -BeExactly $sealed.Destination.CurrentOwnerSid + $sealed.Destination.CurrentIdentitySid | Should -BeExactly $currentSid.Value + $sealed.Destination.AccessRulesProtected | Should -BeTrue + $sealed.Destination.HasInheritedAccessRules | Should -BeFalse + $sealed.Destination.OwnerOnlyAccess | Should -BeTrue + $sealed.Destination.OwnerWritable | Should -BeTrue + + $sealedPath = Join-Path $sealedDestination 'candidate.dll' + $sealedHash = (Get-FileHash -LiteralPath $sealedPath -Algorithm SHA256).Hash + { $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $sealedDestination, 'candidate.dll', $true) } | + Should -Throw '*destination collision*' + (Get-FileHash -LiteralPath $sealedPath -Algorithm SHA256).Hash | + Should -BeExactly $sealedHash + } + + It 'rejects a sealed stage after Windows ACL mutation' -ForEach $windowsAclMutationCases -AllowNullOrEmptyForEach { + $fixture = New-GraphKitAuthStageFixture -Name ('windows-acl-' + $Kind.Replace(' ', '-')) + try { + Set-GraphKitAuthWindowsAclMutation -StagePath $fixture.StagePath -Kind $Kind + { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | + Should -Throw + } + finally { + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'rejects a Windows permission record whose owner is not the current identity' -ForEach $( + if ($IsWindows) { @(@{}) } else { @() } + ) -AllowNullOrEmptyForEach { + $fixture = New-GraphKitAuthStageFixture -Name 'windows-wrong-owner-evidence' + try { + $evidence = $script:GraphKitAuthStageCaptureType::InspectFile($fixture.StagePath, 'manifest.json') + $mutated = $evidence | Select-Object * + $mutated.OwnerSid = [Security.Principal.SecurityIdentifier]::new( + [Security.Principal.WellKnownSidType]::WorldSid, $null).Value + (Test-GraphKitAuthSealedPermission -Evidence $mutated -Directory $false) | + Should -BeFalse + } + finally { + Remove-GraphKitAuthTestStageFixture -Fixture $fixture + } + } + + It 'rejects a projected file after without deleting it' -ForEach @( + @{ Kind = 'byte mutation' } + @{ Kind = 'replacement' } + @{ Kind = 'hard link' } + ) { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ("projection-$($Kind.Replace(' ', '-'))-" + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'destination' + $null = New-Item -ItemType Directory -Path $source, $destination -Force + [IO.File]::WriteAllBytes((Join-Path $source 'candidate.dll'), [byte[]](1..32)) + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew($source, 'candidate.dll', $destination, 'candidate.dll') + { Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $root ` + -RelativePath 'destination/candidate.dll' -Expected $copy.Destination } | Should -Not -Throw + if ($Kind -ceq 'byte mutation') { + $aliasCleanup = [Collections.Generic.List[object]]::new() + $physicalAncestor = Join-Path $TestDrive ('projection-physical-ancestor-' + [guid]::NewGuid().ToString('N')) + $physicalRepository = Join-Path $physicalAncestor 'nested/repository' + $aliasAncestor = Join-Path $TestDrive ('projection-alias-ancestor-' + [guid]::NewGuid().ToString('N')) + try { + $physicalArtifact = [pscustomobject]@{ + Path = $physicalAncestor; Directory = $true; Link = $false + RestorePath = ''; Created = $false + } + $aliasCleanup.Add($physicalArtifact) | Out-Null + $null = New-Item -ItemType Directory -Path $physicalAncestor -ErrorAction Stop + $physicalArtifact.Created = $true + $null = New-Item -ItemType Directory -Path ( + Join-Path $physicalRepository 'source'), ( + Join-Path $physicalRepository 'destination') -Force -ErrorAction Stop + + $aliasArtifact = [pscustomobject]@{ + Path = $aliasAncestor; Directory = $true; Link = $true + RestorePath = ''; WidenParent = $false; Created = $false + } + $aliasCleanup.Add($aliasArtifact) | Out-Null + $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) ` + -Path $aliasAncestor -Target $physicalAncestor -ErrorAction Stop + $aliasArtifact.Created = $true + $aliasRepository = Join-Path $aliasAncestor 'nested/repository' + [IO.File]::WriteAllBytes((Join-Path $aliasRepository 'source/candidate.dll'), [byte[]](1..32)) + $aliasCopy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + (Join-Path $aliasRepository 'source'), 'candidate.dll', + (Join-Path $aliasRepository 'destination'), 'candidate.dll') + + { Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $aliasRepository ` + -RelativePath 'destination/candidate.dll' -Expected $aliasCopy.Destination } | + Should -Not -Throw -Because ( + 'containment must compare the resolved physical repository root when an ' + + 'otherwise physical repository has an aliased ancestor') + + $repositoryAlias = Join-Path $TestDrive ( + 'projection-repository-alias-' + [guid]::NewGuid().ToString('N')) + $repositoryAliasArtifact = [pscustomobject]@{ + Path = $repositoryAlias; Directory = $true; Link = $true + RestorePath = ''; WidenParent = $false; Created = $false + } + $aliasCleanup.Add($repositoryAliasArtifact) | Out-Null + $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) ` + -Path $repositoryAlias -Target $physicalRepository -ErrorAction Stop + $repositoryAliasArtifact.Created = $true + { Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $repositoryAlias ` + -RelativePath 'destination/candidate.dll' -Expected $aliasCopy.Destination } | + Should -Throw -Because 'the repository root itself must remain one no-follow directory' + } + finally { + Remove-GraphKitAuthTestMutationArtifacts -Artifacts $aliasCleanup + } + } + $candidate = Join-Path $destination 'candidate.dll' + switch ($Kind) { + 'byte mutation' { [IO.File]::WriteAllBytes($candidate, [byte[]](33..64)) } + 'replacement' { + $replacement = Join-Path $destination 'replacement.dll' + [IO.File]::WriteAllBytes($replacement, [byte[]](1..32)) + [IO.File]::Move($replacement, $candidate, $true) + } + 'hard link' { + $null = New-Item -ItemType HardLink -Path (Join-Path $destination 'candidate.link.dll') ` + -Target $candidate -ErrorAction Stop + } + } + { Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $root ` + -RelativePath 'destination/candidate.dll' -Expected $copy.Destination } | Should -Throw + Test-Path -LiteralPath $candidate -PathType Leaf | Should -BeTrue + } +} + +Describe 'Packed GraphKit.Auth boundary' -Tag 'QA' { + It 'rejects an exact path set containing a ' -ForEach $graphKitAuthArchiveAliasCases { + { Assert-GraphKitAuthArchivePaths -Entries $Entries } | Should -Throw + } + + It 'keeps source RequiredAssemblies empty and builds exactly the contracts prerequisite' { + @($script:sourceManifest.RequiredAssemblies | Where-Object { $null -ne $_ }).Count | Should -Be 0 + $built = Import-PowerShellDataFile -LiteralPath $script:builtManifestPath + (@($built.RequiredAssemblies | Where-Object { $null -ne $_ }) -join '|') | + Should -BeExactly 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + } + + It 'contains exactly the fixed five-file GraphKit.Auth subtree in build and archive' { + $script:packagePath | Should -Exist + $builtPaths = @(Get-ChildItem -LiteralPath (Join-Path $script:builtModuleRoot 'Assemblies/GraphKit.Auth') -File -Force | ForEach-Object Name | Sort-Object) + $archivePaths = @($script:packageEntries.FullName | Where-Object { $_ -like 'Assemblies/GraphKit.Auth/*' } | ForEach-Object { $_.Substring('Assemblies/GraphKit.Auth/'.Length) } | Sort-Object) + $expected = @($script:requiredGraphKitAuthFiles | Sort-Object) + ($builtPaths -join '|') | Should -BeExactly ($expected -join '|') + ($archivePaths -join '|') | Should -BeExactly ($expected -join '|') + { Assert-GraphKitAuthArchivePaths -Entries @($script:packageEntries.FullName) } | Should -Not -Throw + } + + It 'matches every sealed payload digest in the built module and archive' { + Assert-GraphKitAuthStageCommands + $verified = Test-GraphKitAuthSealedStage -StagePath $script:stagePath -FullVersion $script:fullVersion + foreach ($file in @($verified.Manifest.files)) { + $name = Split-Path ([string] $file.path) -Leaf + (Get-FileHash -LiteralPath (Join-Path $script:builtModuleRoot "Assemblies/GraphKit.Auth/$name") -Algorithm SHA256).Hash.ToLowerInvariant() | Should -BeExactly ([string] $file.sha256) -Because $name + Get-GraphKitAuthArchiveHash -PackagePath $script:packagePath -EntryPath "Assemblies/GraphKit.Auth/$name" | Should -BeExactly ([string] $file.sha256) -Because $name + } + } + + It 'constructs from the reverified sealed payload and unloads its private runtime' { + Assert-GraphKitAuthStageCommands + $verified = Test-GraphKitAuthSealedStage -StagePath $script:stagePath -FullVersion $script:fullVersion + $result = Invoke-GraphKitAuthSealedPayloadProbe -PayloadRoot $verified.PayloadPath + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.ContractsCount | Should -Be 1 + $result.Data.ContractsContext | Should -BeExactly 'Default' + $result.Data.DefaultMsalPreloaded | Should -BeTrue + $result.Data.DefaultMsalReferenceUnchanged | Should -BeTrue + $result.Data.DefaultMsalMvidUnchanged | Should -BeTrue + $result.Data.DefaultMsalLocationUnchanged | Should -BeTrue + $result.Data.ProviderMsalDistinctFromDefault | Should -BeTrue + $result.Data.ProviderMsalContextCollectible | Should -BeTrue + $result.Data.ProviderMsalContextName | Should -Match '^GraphKit\.Auth/[0-9a-f]{32}$' + $result.Data.ProviderAcquireCount | Should -Be 0 + (@($result.Data.CollectibleAssemblies | Sort-Object) -join '|') | + Should -BeExactly 'GraphKit.Auth|Microsoft.Identity.Client|Microsoft.IdentityModel.Abstractions' + $manifestByName = @{} + foreach ($record in @($verified.Manifest.files)) { + $manifestByName[[IO.Path]::GetFileName([string]$record.path)] = $record + } + foreach ($assembly in @( + @{ Prefix='Provider'; Name='GraphKit.Auth.dll'; Identity='GraphKit.Auth, Version=1.0.0.0' } + @{ Prefix='ProviderMsal'; Name='Microsoft.Identity.Client.dll'; Identity='Microsoft.Identity.Client, Version=4.82.1.0' } + @{ Prefix='ProviderIdentityModel'; Name='Microsoft.IdentityModel.Abstractions.dll'; Identity='Microsoft.IdentityModel.Abstractions, Version=8.14.0.0' } + )) { + $expectedLocation = [IO.Path]::GetFullPath((Join-Path $verified.PayloadPath $assembly.Name)) + $result.Data.("$($assembly.Prefix)Location") | Should -BeExactly $expectedLocation + $result.Data.("$($assembly.Prefix)Identity") | Should -BeExactly $assembly.Identity + $result.Data.("$($assembly.Prefix)Sha256") | + Should -BeExactly ([string]$manifestByName[$assembly.Name].sha256) + $result.Data.("$($assembly.Prefix)Mvid") | Should -Match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + } + $result.Data.DefaultMsalUnchanged | Should -BeTrue + $result.Data.CanRefresh | Should -BeTrue + $result.Data.LoadContextAlive | Should -BeFalse + } +} + +Describe 'GraphKit.Auth exact-source CI contract' -Tag 'QA' { + BeforeAll { $script:ci = Get-Content -LiteralPath (Join-Path $script:repoRoot '.github/workflows/ci.yml') -Raw } + It 'covers all six exact operating-system and PowerShell patch rows' { + foreach ($os in @('windows-latest','ubuntu-latest','macos-latest')) { $script:ci | Should -Match ([regex]::Escape($os)) } + foreach ($version in @('7.4.19','7.6.5')) { $script:ci | Should -Match ([regex]::Escape("'$version'")) } + } + It 'selects and asserts the exact event repository and SHA before SDK setup or restore' { + $script:ci | Should -Match '(?m)^\s*branches:\s*\[main,\s*''codex/\*\*''\]\s*$' + $script:ci | Should -Match '(?m)^\s*pull_request:\s*$' + $script:ci | Should -Match '(?m)^\s*workflow_dispatch:\s*$' + $script:ci | Should -Match 'github\.event\.pull_request\.head\.repo\.full_name' + $script:ci | Should -Match 'github\.event\.pull_request\.head\.sha' + $script:ci | Should -Match 'github\.repository' + $script:ci | Should -Match 'github\.sha' + $script:ci | Should -Match '(?m)^\s*fetch-depth:\s*0\s*$' + $script:ci | Should -Match 'git rev-parse HEAD' + $script:ci | Should -Match 'StringComparison\]::Ordinal' + $checkoutIndex=$script:ci.IndexOf('uses: actions/checkout@v4'); $assertIndex=$script:ci.IndexOf('name: Assert exact source revision'); $setupIndex=$script:ci.IndexOf('uses: actions/setup-dotnet@v4'); $restoreIndex=$script:ci.IndexOf('name: Resolve build dependencies') + $checkoutIndex | Should -BeGreaterOrEqual 0 + $assertIndex | Should -BeGreaterThan $checkoutIndex + $setupIndex | Should -BeGreaterThan $assertIndex + $restoreIndex | Should -BeGreaterThan $setupIndex + } + It 'uses one exact SDK setup and asserts the complete running PowerShell version' { + @([regex]::Matches($script:ci,'uses:\s*actions/setup-dotnet@v4')).Count | Should -Be 1 + $script:ci | Should -Match 'dotnet-version:\s*''10\.0\.400''' + $script:ci | Should -Match '\$PSVersionTable\.PSVersion\.ToString\(\)' + $script:ci | Should -Not -Match 'expectedMajorMinor|actualMajorMinor' + $script:ci | Should -Match 'Build_GraphKitAuth' + } +} diff --git a/tests/QA/GraphKitAuthTestResultGate.tests.ps1 b/tests/QA/GraphKitAuthTestResultGate.tests.ps1 new file mode 100644 index 0000000..93ed951 --- /dev/null +++ b/tests/QA/GraphKitAuthTestResultGate.tests.ps1 @@ -0,0 +1,53 @@ +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + . (Join-Path $script:repoRoot '.build/GraphKitAuth.tasks.ps1') -SkipTaskRegistration + + function New-GraphKitAuthTrxResult { + param([Parameter(Mandatory)][int] $Total) + + $results = @( + for ($index = 1; $index -le $Total; $index++) { + '' -f $index + } + ) -join '' + [xml] @" + + $results + + + + +"@ + } +} + +Describe 'GraphKit.Auth machine-readable test result gate' -Tag 'QA' { + It 'wires the authoritative validator into the build and accepts exactly 77 passing tests' { + $taskSource = Get-Content -LiteralPath ( + Join-Path $script:repoRoot '.build/GraphKitAuth.tasks.ps1') -Raw + @([regex]::Matches( + $taskSource, + '(?m)^\s*Assert-GraphKitAuthTestResult\s+-Result\s+\$trx\s*$' + )).Count | Should -Be 1 + + $result = New-GraphKitAuthTrxResult -Total 77 + + { Assert-GraphKitAuthTestResult -Result $result } | Should -Not -Throw + } + + It 'rejects an all-passing result with only 76 discovered tests' { + $result = New-GraphKitAuthTrxResult -Total 76 + + { Assert-GraphKitAuthTestResult -Result $result } | + Should -Throw '*expected exactly 77*' + } + + It 'rejects an all-passing result with 78 discovered tests' { + $result = New-GraphKitAuthTrxResult -Total 78 + + { Assert-GraphKitAuthTestResult -Result $result } | + Should -Throw '*expected exactly 77*' + } +} diff --git a/tests/QA/ImportOrderMatrix.tests.ps1 b/tests/QA/ImportOrderMatrix.tests.ps1 index 2f8938e..1cf32f4 100644 --- a/tests/QA/ImportOrderMatrix.tests.ps1 +++ b/tests/QA/ImportOrderMatrix.tests.ps1 @@ -146,3 +146,84 @@ removing its bundled MSAL"). Re-decide the deferral rather than raising this num } } } + +Describe 'Import-order without SecretManagement' -Skip:($null -eq $script:BuiltBase) { + BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $built = Get-ChildItem -Path (Join-Path $repoRoot 'output/module/GraphKit') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 + $graphAuth = Join-Path $repoRoot 'output/RequiredModules/Microsoft.Graph.Authentication/2.38.1' + if (-not (Test-Path -LiteralPath $graphAuth -PathType Container)) { + throw 'Microsoft.Graph.Authentication 2.38.1 is not available for the isolated import-order probe.' + } + + $modulePath = Join-Path $TestDrive 'import-order-no-vault' + $graphKitDestination = Join-Path $modulePath "GraphKit/$($built.Name)" + $graphAuthDestination = Join-Path $modulePath 'Microsoft.Graph.Authentication/2.38.1' + $null = New-Item -ItemType Directory -Path $graphKitDestination, $graphAuthDestination -Force + Copy-Item -Path (Join-Path $built.FullName '*') -Destination $graphKitDestination -Recurse -Force + Copy-Item -Path (Join-Path $graphAuth '*') -Destination $graphAuthDestination -Recurse -Force + + $escapedModulePath = $modulePath.Replace("'", "''") + $escapedManifest = (Join-Path $graphKitDestination 'GraphKit.psd1').Replace("'", "''") + $probe = @" +`$ErrorActionPreference = 'Stop' +`$result = [ordered]@{ + ImportSucceeded = `$false + GuardError = `$null + DetectedMsalVersion = `$null + SecretManagementLoaded = `$false + SecretManagementAvailable = `$false + OperationName = `$null +} +try { + `$env:PSModulePath = '$escapedModulePath' + Import-Module '$escapedManifest' -ErrorAction Stop + `$result.ImportSucceeded = `$true + `$operation = Get-GraphOperation -Type ManagedDevice -Operation List + `$result.OperationName = "`$(`$operation.Type).`$(`$operation.Operation)" +} +catch { + `$result.GuardError = `$_.Exception.Message +} +`$msal = [AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { `$_.GetName().Name -eq 'Microsoft.Identity.Client' } | + Select-Object -First 1 +if (`$msal) { `$result.DetectedMsalVersion = `$msal.GetName().Version.ToString() } +`$result.SecretManagementLoaded = [bool] (Get-Module Microsoft.PowerShell.SecretManagement) +`$env:PSModulePath = '$escapedModulePath' +`$result.SecretManagementAvailable = [bool] (Get-Module Microsoft.PowerShell.SecretManagement -ListAvailable -Refresh) +[pscustomobject] `$result | ConvertTo-Json -Compress +"@ + + $savedModulePath = $env:PSModulePath + try { + $env:PSModulePath = $modulePath + $raw = & pwsh -NoLogo -NoProfile -Command $probe 2>&1 + } + finally { + $env:PSModulePath = $savedModulePath + } + + $json = @($raw | Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') }) | Select-Object -Last 1 + if (-not $json) { + throw "isolated import-order probe produced no JSON. Raw output:`n$($raw | Out-String)" + } + $script:NoVaultImport = $json | ConvertFrom-Json + } + + It 'imports GraphKit and inspects the catalog without SecretManagement on PSModulePath' { + $script:NoVaultImport.ImportSucceeded | Should -BeTrue -Because $script:NoVaultImport.GuardError + $script:NoVaultImport.GuardError | Should -BeNullOrEmpty + $script:NoVaultImport.OperationName | Should -Be 'ManagedDevice.List' + $script:NoVaultImport.SecretManagementLoaded | Should -BeFalse + $script:NoVaultImport.SecretManagementAvailable | Should -BeFalse + } + + It 'still loads a tested MSAL version when SecretManagement is absent' { + $version = $script:NoVaultImport.DetectedMsalVersion + $version | Should -Not -BeNullOrEmpty + ([version] $version) -ge [version] '4.82.1' | Should -BeTrue + ([version] $version).Major | Should -Be 4 + } +} diff --git a/tests/QA/MinimumTestsRatchetSync.tests.ps1 b/tests/QA/MinimumTestsRatchetSync.tests.ps1 index bb01f63..088bc98 100644 --- a/tests/QA/MinimumTestsRatchetSync.tests.ps1 +++ b/tests/QA/MinimumTestsRatchetSync.tests.ps1 @@ -23,21 +23,53 @@ BeforeAll { } Describe 'MinimumTests ratchet synchronization' -Tag 'QA' { - It 'keeps CI, package verification, operator guidance, and the passing fixture equal' { + It 'keeps every release authority equal to the independently discovered portable floor' { $ci = Get-Content -LiteralPath (Join-Path $script:repoRoot '.github/workflows/ci.yml') -Raw - $publisher = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/Publish-GraphKitPackage.ps1') -Raw + $generator = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/New-GraphKitTestedReleaseProof.ps1') -Raw + $verifier = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/Test-GraphKitReleaseProof.ps1') -Raw + $proofTests = Get-Content -LiteralPath (Join-Path $script:repoRoot 'tests/QA/ReleaseProof.tests.ps1') -Raw $publishTests = Get-Content -LiteralPath (Join-Path $script:repoRoot 'tests/QA/PublishChannel.tests.ps1') -Raw + $agents = Get-Content -LiteralPath (Join-Path $script:repoRoot 'AGENTS.md') -Raw $values = [ordered] @{ CI = Get-SingleRatchetValue -Text $ci -Pattern '-MinimumTests\s+(\d+)\s+-AllowedSkips' -Location '.github/workflows/ci.yml' - PublishCall = Get-SingleRatchetValue -Text $publisher -Pattern '-MinimumTests\s+(\d+)\s+-AllowedSkips' -Location 'scripts/Publish-GraphKitPackage.ps1 gate call' - PublishHint = Get-SingleRatchetValue -Text $publisher -Pattern '-MinimumTests\s+(\d+)[\x27\x22]' -Location 'scripts/Publish-GraphKitPackage.ps1 error hint' - PassingFixture = Get-SingleRatchetValue -Text $publishTests -Pattern '(?s)function New-PassingResult.*?\[int\]\s+\$Total\s*=\s*(\d+)' -Location 'tests/QA/PublishChannel.tests.ps1' + Generator = Get-SingleRatchetValue -Text $generator -Pattern '\$minimumTests\s*=\s*(\d+)' -Location 'scripts/New-GraphKitTestedReleaseProof.ps1' + Verifier = Get-SingleRatchetValue -Text $verifier -Pattern '\$minimumTests\s*=\s*(\d+)' -Location 'scripts/Test-GraphKitReleaseProof.ps1' + ProofFixture = Get-SingleRatchetValue -Text $proofTests -Pattern '(?s)function New-GraphKitReleaseProofFixture.*?\[int\]\s+\$Total\s*=\s*(\d+)' -Location 'tests/QA/ReleaseProof.tests.ps1' + ProofPolicy = Get-SingleRatchetValue -Text $proofTests -Pattern '(?s)function New-GraphKitReleaseProofFixture.*?minimumTests\s*=\s*(\d+)' -Location 'tests/QA/ReleaseProof.tests.ps1 policy' + ProofAssertion = Get-SingleRatchetValue -Text $proofTests -Pattern '\$proof\.testRun\.summary\.total\s*\|\s*Should\s+-Be\s+(\d+)' -Location 'tests/QA/ReleaseProof.tests.ps1 assertion' + PublisherFixture = Get-SingleRatchetValue -Text $publishTests -Pattern '(?s)function New-PassingResult.*?\[int\]\s+\$Total\s*=\s*(\d+)' -Location 'tests/QA/PublishChannel.tests.ps1' + AgentGuidance = Get-SingleRatchetValue -Text $agents -Pattern 'post-release development tree requires\s+(\d+)\s+deterministic tests' -Location 'AGENTS.md' } @($values.Values | Select-Object -Unique).Count | Should -Be 1 -Because ( 'every release gate must use one floor; found ' + (($values.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ') ) + + $discoveryScript = Join-Path $PSScriptRoot 'Get-GraphKitPesterDiscoveryCount.ps1' + $discoveryOutput = @(& pwsh -NoLogo -NoProfile -File $discoveryScript ` + -RepositoryRoot $script:repoRoot 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Independent Pester discovery failed: $($discoveryOutput -join ' ')" + } + $discoveryJson = @($discoveryOutput | + Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') }) | + Select-Object -Last 1 + if (-not $discoveryJson) { + throw "Independent Pester discovery produced no JSON result: $($discoveryOutput -join ' ')" + } + $discovery = $discoveryJson | ConvertFrom-Json + $platformOnlySurplus = switch ([string]$discovery.platform) { + 'MacOS' { 0 } + 'Linux' { 2 } + 'Windows' { 6 } + default { throw "Unsupported discovery platform '$($discovery.platform)'." } + } + $portableFloor = [int]$discovery.total - $platformOnlySurplus + $values.CI | Should -Be $portableFloor -Because ( + "the shared floor must equal independent discovery minus the known $platformOnlySurplus " + + "platform-only case(s); discovered $($discovery.total) across $($discovery.containers) containers" + ) } } diff --git a/tests/QA/PackageDependencies.tests.ps1 b/tests/QA/PackageDependencies.tests.ps1 index 01d346e..e81c336 100644 --- a/tests/QA/PackageDependencies.tests.ps1 +++ b/tests/QA/PackageDependencies.tests.ps1 @@ -3,7 +3,8 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath $script:sourceManifest = Import-PowerShellDataFile -Path (Join-Path $script:repoRoot 'source/GraphKit.psd1') - $script:version = [string] $script:sourceManifest.ModuleVersion + $script:baseVersion = [string] $script:sourceManifest.ModuleVersion + $script:version = (& (Join-Path $script:repoRoot 'scripts/Get-GraphKitTrainVersion.ps1') -RepositoryRoot $script:repoRoot).Trim() $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$script:version.nupkg" $script:graphAuthPath = Join-Path $script:repoRoot 'output/RequiredModules/Microsoft.Graph.Authentication/2.38.1' @@ -33,7 +34,7 @@ BeforeAll { param([Parameter(Mandatory)] [string] $Root) $modulePath = Join-Path $Root 'Modules' - $graphKitDestination = Join-Path $modulePath "GraphKit/$script:version" + $graphKitDestination = Join-Path $modulePath "GraphKit/$script:baseVersion" $graphAuthDestination = Join-Path $modulePath 'Microsoft.Graph.Authentication/2.38.1' $null = New-Item -ItemType Directory -Path $graphKitDestination, $graphAuthDestination -Force [System.IO.Compression.ZipFile]::ExtractToDirectory($script:packagePath, $graphKitDestination) @@ -44,24 +45,28 @@ BeforeAll { function Invoke-IsolatedGraphKitProbe { param([Parameter(Mandatory)] [string] $ModulePath) - $isolatedManifest = Join-Path $ModulePath "GraphKit/$script:version/GraphKit.psd1" + $isolatedManifest = Join-Path $ModulePath "GraphKit/$script:baseVersion/GraphKit.psd1" $probe = @" `$ErrorActionPreference = 'Stop' `$env:PSModulePath = '$($ModulePath.Replace("'", "''"))' Import-Module '$($isolatedManifest.Replace("'", "''"))' -Force -ErrorAction Stop `$operation = Get-GraphOperation -Type ManagedDevice -Operation List +`$graphAuthenticationLoaded = [bool] (Get-Module Microsoft.Graph.Authentication) `$secretManagementLoaded = [bool] (Get-Module Microsoft.PowerShell.SecretManagement) # PowerShell re-adds its default module roots while resolving RequiredModules during import. # Reset the path after import, then refresh discovery so this is an availability proof rather # than a check against either the loaded-module table or stale module-analysis cache state. `$env:PSModulePath = '$($ModulePath.Replace("'", "''"))' +`$graphAuthenticationAvailable = [bool] (Get-Module Microsoft.Graph.Authentication -ListAvailable -Refresh) `$secretManagementAvailable = [bool] (Get-Module Microsoft.PowerShell.SecretManagement -ListAvailable -Refresh) [pscustomobject]@{ - Imported = `$true - ModuleBase = (Get-Module GraphKit).ModuleBase - OperationName = "`$(`$operation.Type).`$(`$operation.Operation)" - SecretManagementLoaded = `$secretManagementLoaded - SecretManagementAvailable = `$secretManagementAvailable + Imported = `$true + ModuleBase = (Get-Module GraphKit).ModuleBase + OperationName = "`$(`$operation.Type).`$(`$operation.Operation)" + GraphAuthenticationLoaded = `$graphAuthenticationLoaded + GraphAuthenticationAvailable = `$graphAuthenticationAvailable + SecretManagementLoaded = `$secretManagementLoaded + SecretManagementAvailable = `$secretManagementAvailable } | ConvertTo-Json -Compress "@ @@ -87,25 +92,29 @@ Import-Module '$($isolatedManifest.Replace("'", "''"))' -Force -ErrorAction Stop } Describe 'Packed GraphKit dependency contract' -Tag 'QA' { - It 'records Microsoft.Graph.Authentication 2.38.1 as its only NuGet dependency' { + It 'records only Graph Authentication as an exact NuGet dependency' { Test-Path -LiteralPath $script:packagePath -PathType Leaf | Should -BeTrue $dependencies = @(Get-PackageDependencies -PackagePath $script:packagePath) $dependencies.Count | Should -Be 1 - [string] $dependencies[0].id | Should -Be 'Microsoft.Graph.Authentication' - [string] $dependencies[0].version | Should -Be '2.38.1' + $dependencyMap = @{} + foreach ($dependency in $dependencies) { $dependencyMap[[string] $dependency.id] = [string] $dependency.version } + $dependencyMap['Microsoft.Graph.Authentication'] | Should -Be '2.38.1' + $dependencyMap.ContainsKey('Microsoft.PowerShell.SecretManagement') | Should -BeFalse -Because 'vault support is optional and resolved on first vault-backed use' } - It 'imports the isolated artifact and inspects the catalog without loading SecretManagement' { + It 'imports the isolated artifact with Graph Authentication alone' { $modulePath = New-IsolatedGraphKitModulePath -Root (Join-Path $TestDrive 'non-vault') $result = Invoke-IsolatedGraphKitProbe -ModulePath $modulePath $result.ExitCode | Should -Be 0 -Because $result.Output $result.Data.Imported | Should -BeTrue - $result.Data.ModuleBase | Should -Be (Join-Path $modulePath "GraphKit/$script:version") + $result.Data.ModuleBase | Should -Be (Join-Path (Join-Path $modulePath 'GraphKit') $script:baseVersion) $result.Data.OperationName | Should -Be 'ManagedDevice.List' - $result.Data.SecretManagementLoaded | Should -BeFalse -Because 'catalog inspection does not use a vault' - $result.Data.SecretManagementAvailable | Should -BeFalse -Because 'the isolated package probe must not be able to discover the lazy vault dependency anywhere' + $result.Data.GraphAuthenticationLoaded | Should -BeTrue -Because 'Graph Authentication remains the R8 transition MSAL delivery vehicle' + $result.Data.GraphAuthenticationAvailable | Should -BeTrue -Because 'Graph Authentication remains a required runtime package dependency until cutover' + $result.Data.SecretManagementLoaded | Should -BeFalse -Because 'non-vault import must not load an optional vault dependency' + $result.Data.SecretManagementAvailable | Should -BeFalse -Because 'the clean package dependency set intentionally omits optional vault support' } } diff --git a/tests/QA/PackageIdentity.tests.ps1 b/tests/QA/PackageIdentity.tests.ps1 index 3aea774..34a1f62 100644 --- a/tests/QA/PackageIdentity.tests.ps1 +++ b/tests/QA/PackageIdentity.tests.ps1 @@ -2,9 +2,23 @@ BeforeAll { Add-Type -AssemblyName System.IO.Compression.FileSystem $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath - $script:expectedVersion = '0.3.0' + $script:baseVersion = '0.4.0' + $script:train = 'r8' $script:sourceManifestPath = Join-Path $script:repoRoot 'source/GraphKit.psd1' - $script:builtManifestPath = Join-Path $script:repoRoot "output/module/GraphKit/$script:expectedVersion/GraphKit.psd1" + + $script:versionScriptPath = Join-Path $script:repoRoot 'scripts/Get-GraphKitTrainVersion.ps1' + $script:expectedVersion = (& $script:versionScriptPath -RepositoryRoot $script:repoRoot).Trim() + if (-not $script:expectedVersion.StartsWith("$script:baseVersion-", [StringComparison]::Ordinal)) { + throw "The derived package version '$script:expectedVersion' does not extend the expected base version '$script:baseVersion'." + } + $script:expectedPrerelease = $script:expectedVersion.Substring($script:baseVersion.Length + 1) + if ([string]::IsNullOrWhiteSpace($script:expectedPrerelease)) { + throw "The derived package version '$script:expectedVersion' has no prerelease identity." + } + if (-not $script:expectedPrerelease.StartsWith("$script:train.", [StringComparison]::Ordinal)) { + throw "The derived package prerelease '$script:expectedPrerelease' is not bound to train '$script:train'." + } + $script:builtManifestPath = Join-Path $script:repoRoot "output/module/GraphKit/$script:baseVersion/GraphKit.psd1" $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$script:expectedVersion.nupkg" function Get-GraphKitPackageMetadata { @@ -31,34 +45,83 @@ BeforeAll { } Describe 'GraphKit release package identity' -Tag 'QA' { - It 'declares released version 0.3.0 in source and release metadata' { + It 'declares the 0.4.0 r8 successor seed in source metadata' { $source = Import-PowerShellDataFile $script:sourceManifestPath - [string] $source.ModuleVersion | Should -Be $script:expectedVersion - [string] $source.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.3\.0(?:\r?\n)' + [string] $source.ModuleVersion | Should -Be $script:baseVersion + [string] $source.PrivateData.PSData.Prerelease | Should -Be $script:train + [string] $source.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.4\.0(?:\r?\n)' + } + + It 'derives the complete package version from the exact repository source state' { + Test-Path -LiteralPath $script:versionScriptPath -PathType Leaf | Should -BeTrue + if (Test-Path -LiteralPath $script:versionScriptPath -PathType Leaf) { + (& $script:versionScriptPath -RepositoryRoot $script:repoRoot) | Should -Be $script:expectedVersion + } + + $buildPath = Join-Path $script:repoRoot 'build.ps1' + $tokens = $null + $parseErrors = $null + $buildAst = [Management.Automation.Language.Parser]::ParseFile( + $buildPath, [ref] $tokens, [ref] $parseErrors) + @($parseErrors).Count | Should -Be 0 + $versionValidators = @($buildAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Get-GraphKitValidatedTrainVersion' + }, $true)) + $versionValidators.Count | Should -Be 1 + . ([scriptblock]::Create($versionValidators[0].Extent.Text)) + + $stubRoot = Join-Path $TestDrive 'train-version-output-contract' + $null = New-Item -ItemType Directory -Path $stubRoot -Force + $validStub = Join-Path $stubRoot 'valid.ps1' + $noneStub = Join-Path $stubRoot 'none.ps1' + $multipleStub = Join-Path $stubRoot 'multiple.ps1' + $objectStub = Join-Path $stubRoot 'object.ps1' + $errorStub = Join-Path $stubRoot 'error.ps1' + Set-Content -LiteralPath $validStub -Encoding utf8NoBOM -Value "[CmdletBinding()] param([string] `$RepositoryRoot)`n' 0.4.0-r8.fixture '" + Set-Content -LiteralPath $noneStub -Encoding utf8NoBOM -Value "[CmdletBinding()] param([string] `$RepositoryRoot)" + Set-Content -LiteralPath $multipleStub -Encoding utf8NoBOM -Value "[CmdletBinding()] param([string] `$RepositoryRoot)`n'one'`n 'two'" + Set-Content -LiteralPath $objectStub -Encoding utf8NoBOM -Value "[CmdletBinding()] param([string] `$RepositoryRoot)`n[pscustomobject] @{ value = 'wrong type' }" + Set-Content -LiteralPath $errorStub -Encoding utf8NoBOM -Value "[CmdletBinding()] param([string] `$RepositoryRoot)`nthrow 'train-version fixture failure'" + + Get-GraphKitValidatedTrainVersion -VersionScript $validStub -RepositoryRoot $stubRoot | + Should -BeExactly '0.4.0-r8.fixture' + foreach ($invalidStub in @($noneStub, $multipleStub, $objectStub)) { + { + Get-GraphKitValidatedTrainVersion -VersionScript $invalidStub -RepositoryRoot $stubRoot + } | Should -Throw -ExpectedMessage '*exactly one non-empty string*' + } + { + Get-GraphKitValidatedTrainVersion -VersionScript $errorStub -RepositoryRoot $stubRoot + } | Should -Throw -ExpectedMessage '*train-version fixture failure*' } - It 'builds and packages the 0.3.0 identity' { + It 'builds the base module directory and packages the full r8 identity' { Test-Path $script:builtManifestPath -PathType Leaf | Should -BeTrue Test-Path $script:packagePath -PathType Leaf | Should -BeTrue + Test-Path (Join-Path $script:repoRoot 'output/GraphKit.0.3.0.nupkg') -PathType Leaf | Should -BeFalse } - It 'preserves 0.3.0 in the built manifest and exact package metadata' { + It 'preserves base and full prerelease identities in the built manifest and package metadata' { Test-Path $script:builtManifestPath -PathType Leaf | Should -BeTrue Test-Path $script:packagePath -PathType Leaf | Should -BeTrue $builtManifest = Import-PowerShellDataFile $script:builtManifestPath $packageMetadata = Get-GraphKitPackageMetadata $script:packagePath - [string] $builtManifest.ModuleVersion | Should -Be $script:expectedVersion + [string] $builtManifest.ModuleVersion | Should -Be $script:baseVersion + [string] $builtManifest.PrivateData.PSData.Prerelease | Should -Be $script:expectedPrerelease [string] $packageMetadata.version | Should -Be $script:expectedVersion } - It 'preserves 0.3.0 in the manifest extracted from the exact nupkg' { + It 'preserves the base module manifest in the exact prerelease nupkg' { Test-Path $script:packagePath -PathType Leaf | Should -BeTrue $extractRoot = Join-Path $TestDrive 'release' [System.IO.Compression.ZipFile]::ExtractToDirectory($script:packagePath, $extractRoot) $packagedManifest = Import-PowerShellDataFile (Join-Path $extractRoot 'GraphKit.psd1') - [string] $packagedManifest.ModuleVersion | Should -Be $script:expectedVersion + [string] $packagedManifest.ModuleVersion | Should -Be $script:baseVersion + [string] $packagedManifest.PrivateData.PSData.Prerelease | Should -Be $script:expectedPrerelease } } diff --git a/tests/QA/PowerShellReleaseArchive.tests.ps1 b/tests/QA/PowerShellReleaseArchive.tests.ps1 new file mode 100644 index 0000000..aaea3f1 --- /dev/null +++ b/tests/QA/PowerShellReleaseArchive.tests.ps1 @@ -0,0 +1,181 @@ +$officialPowerShellArchiveCases = @( + @{ Version = '7.4.19'; Asset = 'PowerShell-7.4.19-win-arm64.zip'; Hash = 'ac3a0249c0cd9f5b55f198f681485099ea73f45838dfd676457571a94d793463' } + @{ Version = '7.4.19'; Asset = 'PowerShell-7.4.19-win-x64.zip'; Hash = 'cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9' } + @{ Version = '7.4.19'; Asset = 'powershell-7.4.19-linux-arm64.tar.gz'; Hash = '2b11aafacf574222abaf691a0b3b2d463e617d17fe337343c2fb93ea871a4691' } + @{ Version = '7.4.19'; Asset = 'powershell-7.4.19-linux-x64.tar.gz'; Hash = '1b023e097b0e0546ad9566f7a2126cbe0eb8455fa7b0c5de558e317b8ddc16c8' } + @{ Version = '7.4.19'; Asset = 'powershell-7.4.19-osx-arm64.tar.gz'; Hash = 'fb9d6656d0c78c6d3f6e8d08ff15e5e0d867f886bf4ebecfde6484d2fa06c042' } + @{ Version = '7.4.19'; Asset = 'powershell-7.4.19-osx-x64.tar.gz'; Hash = 'bb67378d9b9d469d0c3863aa8a5576a38ad8eaa0fd7aae2c4819e7caf06cb79c' } + @{ Version = '7.6.5'; Asset = 'PowerShell-7.6.5-win-arm64.zip'; Hash = '20514a755d16428dc4355c85e0883c859531e71cc3e122670aa1fccdbf96ba7e' } + @{ Version = '7.6.5'; Asset = 'PowerShell-7.6.5-win-x64.zip'; Hash = '32eb8f6cdce08f86e987d625a2733e54ac3e289ae7e1621b14c0b5bcec2434ea' } + @{ Version = '7.6.5'; Asset = 'powershell-7.6.5-linux-arm64.tar.gz'; Hash = 'ed4084f215d8bce2edd23aa7cb1f1e7b0818e41363a635a22065d2701b6141df' } + @{ Version = '7.6.5'; Asset = 'powershell-7.6.5-linux-x64.tar.gz'; Hash = 'b34ab3b19acac1d3d4d0d3cfdb02acf62f457b0b6a962ff008132033f7566844' } + @{ Version = '7.6.5'; Asset = 'powershell-7.6.5-osx-arm64.tar.gz'; Hash = '8196d4b4e7c21b7f6df9d45687bb4e42dc8335f330b580d9eb15f3ef5042a8c3' } + @{ Version = '7.6.5'; Asset = 'powershell-7.6.5-osx-x64.tar.gz'; Hash = '3db1d177ab39511c1b6b73b05a1630a5db4e8dce22857ca76f14c5d98f2733fd' } +) + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $script:hashMapPath = Join-Path $script:repoRoot '.github/powershell-release-sha256.json' + $script:installerPath = Join-Path $script:repoRoot '.github/scripts/Install-VerifiedPowerShellArchive.ps1' + + function New-TestPowerShellHashMap { + param( + [Parameter(Mandatory)][string] $Root, + [Parameter(Mandatory)][string] $Version, + [Parameter(Mandatory)][string] $Asset, + [Parameter(Mandatory)][string] $Hash + ) + + $path = Join-Path $Root ('hash-map-' + [guid]::NewGuid().ToString('N') + '.json') + [ordered]@{ + schemaVersion = 1 + provenance = [ordered]@{ + $Version = [ordered]@{ + releaseUrl = "https://github.com/PowerShell/PowerShell/releases/tag/v$Version" + hashesUrl = "https://github.com/PowerShell/PowerShell/releases/download/v$Version/hashes.sha256" + } + } + sha256 = [ordered]@{ "$Version/$Asset" = $Hash } + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $path -Encoding utf8NoBOM + return $path + } +} + +Describe 'Reviewed PowerShell release archive map' -Tag 'QA' { + It 'binds / to the official release checksum' -ForEach $officialPowerShellArchiveCases { + Test-Path -LiteralPath $script:hashMapPath -PathType Leaf | Should -BeTrue + if (-not (Test-Path -LiteralPath $script:hashMapPath -PathType Leaf)) { return } + + $map = Get-Content -LiteralPath $script:hashMapPath -Raw | ConvertFrom-Json -AsHashtable + $key = "$Version/$Asset" + $map.schemaVersion | Should -Be 1 + $map.sha256[$key] | Should -BeExactly $Hash + $map.provenance[$Version].releaseUrl | + Should -BeExactly "https://github.com/PowerShell/PowerShell/releases/tag/v$Version" + $map.provenance[$Version].hashesUrl | + Should -BeExactly "https://github.com/PowerShell/PowerShell/releases/download/v$Version/hashes.sha256" + } + + It 'contains exactly the twelve archives dynamically selectable by CI' { + Test-Path -LiteralPath $script:hashMapPath -PathType Leaf | Should -BeTrue + if (-not (Test-Path -LiteralPath $script:hashMapPath -PathType Leaf)) { return } + + $map = Get-Content -LiteralPath $script:hashMapPath -Raw | ConvertFrom-Json -AsHashtable + $ci = Get-Content -LiteralPath (Join-Path $script:repoRoot '.github/workflows/ci.yml') -Raw + $matrixMatch = [regex]::Match($ci, '(?m)^\s*pwsh-version:\s*\[([^\]]+)\]') + $matrixMatch.Success | Should -BeTrue + $versions = @([regex]::Matches($matrixMatch.Groups[1].Value, '\d+\.\d+\.\d+') | + ForEach-Object { $_.Value }) + $expectedKeys = @( + foreach ($version in $versions) { + "$version/PowerShell-$version-win-arm64.zip" + "$version/PowerShell-$version-win-x64.zip" + "$version/powershell-$version-linux-arm64.tar.gz" + "$version/powershell-$version-linux-x64.tar.gz" + "$version/powershell-$version-osx-arm64.tar.gz" + "$version/powershell-$version-osx-x64.tar.gz" + } + ) + + @($map.sha256.Keys).Count | Should -Be 12 + @(Compare-Object @($expectedKeys | Sort-Object) @($map.sha256.Keys | Sort-Object)).Count | + Should -Be 0 + $ci | Should -Match '\$asset\s*=\s*"PowerShell-\$version-win-\$arch\.zip"' + $ci | Should -Match '\$asset\s*=\s*"powershell-\$version-linux-\$arch\.tar\.gz"' + $ci | Should -Match '\$asset\s*=\s*"powershell-\$version-osx-\$arch\.tar\.gz"' + } +} + +Describe 'Verified PowerShell release archive installation' -Tag 'QA' { + It 'extracts an archive only when its bytes match the reviewed mapping' { + $version = '9.9.9' + $asset = 'PowerShell-9.9.9-win-x64.zip' + $stage = Join-Path $TestDrive 'valid-stage' + $archive = Join-Path $TestDrive $asset + $install = Join-Path $TestDrive 'valid-install' + $null = New-Item -ItemType Directory -Path $stage + Set-Content -LiteralPath (Join-Path $stage 'pwsh-marker.txt') -Value 'verified archive' -NoNewline + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $archive + $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + $map = New-TestPowerShellHashMap -Root $TestDrive -Version $version -Asset $asset -Hash $hash + + Test-Path -LiteralPath $script:installerPath -PathType Leaf | Should -BeTrue + if (-not (Test-Path -LiteralPath $script:installerPath -PathType Leaf)) { return } + & $script:installerPath -Version $version -AssetName $asset -ArchivePath $archive ` + -InstallDirectory $install -HashMapPath $map + + Get-Content -LiteralPath (Join-Path $install 'pwsh-marker.txt') -Raw | + Should -BeExactly 'verified archive' + } + + It 'rejects an archive with no exact version-and-asset mapping before extraction' { + $version = '9.9.9' + $asset = 'PowerShell-9.9.9-win-x64.zip' + $caseRoot = Join-Path $TestDrive 'missing-case' + $stage = Join-Path $caseRoot 'stage' + $archive = Join-Path $caseRoot $asset + $install = Join-Path $caseRoot 'install' + $null = New-Item -ItemType Directory -Path $stage -Force + Set-Content -LiteralPath (Join-Path $stage 'pwsh-marker.txt') -Value 'must not extract' -NoNewline + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $archive + $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + $map = New-TestPowerShellHashMap -Root $caseRoot -Version $version ` + -Asset 'PowerShell-9.9.9-win-arm64.zip' -Hash $hash + + { & $script:installerPath -Version $version -AssetName $asset -ArchivePath $archive ` + -InstallDirectory $install -HashMapPath $map } | + Should -Throw '*no reviewed SHA-256 mapping*' + Test-Path -LiteralPath (Join-Path $install 'pwsh-marker.txt') | Should -BeFalse + } + + It 'rejects a malformed reviewed digest before extraction' { + $version = '9.9.9' + $asset = 'PowerShell-9.9.9-win-x64.zip' + $caseRoot = Join-Path $TestDrive 'malformed-case' + $stage = Join-Path $caseRoot 'stage' + $archive = Join-Path $caseRoot $asset + $install = Join-Path $caseRoot 'install' + $null = New-Item -ItemType Directory -Path $stage -Force + Set-Content -LiteralPath (Join-Path $stage 'pwsh-marker.txt') -Value 'must not extract' -NoNewline + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $archive + $map = New-TestPowerShellHashMap -Root $caseRoot -Version $version -Asset $asset -Hash 'not-a-digest' + + { & $script:installerPath -Version $version -AssetName $asset -ArchivePath $archive ` + -InstallDirectory $install -HashMapPath $map } | + Should -Throw '*not a lowercase 64-character SHA-256*' + Test-Path -LiteralPath (Join-Path $install 'pwsh-marker.txt') | Should -BeFalse + } + + It 'rejects a digest mismatch before any archive entry is extracted' { + $version = '9.9.9' + $asset = 'PowerShell-9.9.9-win-x64.zip' + $caseRoot = Join-Path $TestDrive 'mismatch-case' + $stage = Join-Path $caseRoot 'stage' + $archive = Join-Path $caseRoot $asset + $install = Join-Path $caseRoot 'install' + $null = New-Item -ItemType Directory -Path $stage -Force + Set-Content -LiteralPath (Join-Path $stage 'pwsh-marker.txt') -Value 'must not extract' -NoNewline + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $archive + $map = New-TestPowerShellHashMap -Root $caseRoot -Version $version -Asset $asset -Hash ('0' * 64) + + { & $script:installerPath -Version $version -AssetName $asset -ArchivePath $archive ` + -InstallDirectory $install -HashMapPath $map } | + Should -Throw '*does not match its reviewed SHA-256*' + Test-Path -LiteralPath (Join-Path $install 'pwsh-marker.txt') | Should -BeFalse + } +} + +Describe 'PowerShell CI archive verification wiring' -Tag 'QA' { + It 'downloads, verifies and extracts as one gate before adding the runtime to PATH' { + $ci = Get-Content -LiteralPath (Join-Path $script:repoRoot '.github/workflows/ci.yml') -Raw + $downloadIndex = $ci.IndexOf('Invoke-WebRequest') + $verifiedInstallIndex = $ci.IndexOf('Install-VerifiedPowerShellArchive.ps1') + $pathIndex = $ci.IndexOf('$env:GITHUB_PATH') + + $downloadIndex | Should -BeGreaterOrEqual 0 + $verifiedInstallIndex | Should -BeGreaterThan $downloadIndex + $pathIndex | Should -BeGreaterThan $verifiedInstallIndex + $ci | Should -Match ([regex]::Escape('.github/powershell-release-sha256.json')) + $ci | Should -Not -Match '(?m)^\s*(Expand-Archive|tar\s+-xzf)' + } +} diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index f488201..fd85d04 100644 --- a/tests/QA/PublishChannel.tests.ps1 +++ b/tests/QA/PublishChannel.tests.ps1 @@ -33,7 +33,7 @@ BeforeAll { } function New-PassingResult { - param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 777) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1482) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" @@ -89,36 +89,20 @@ Describe 'Publish-GraphKitPackage refusals' { $r.Output | Should -BeLike '*-TestResultPath is required*' } - It 'refuses a test result belonging to a different version' { - # A green result from another build proves nothing about these bytes. + It 'refuses a separately supplied result when no canonical proof binds it' { + # A result file is evidence input, not publication authority by itself. The build + # workflow must bind it to package/module bytes in tested-release-proof.json. $pkg = New-FakeNupkg -Root $TestDrive $wrong = New-PassingResult -Root $TestDrive -Version '1.2.3' - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = (Join-Path $TestDrive 'ch2'); TestResultPath = $wrong } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -Match 'does not reference version|not the one the tests ran against|built module at' - } - - It 'refuses when the tested build is gone, so provenance cannot be established' { - # output/module/GraphKit/9.9.9 does not exist, so nothing ties this package to a run. - $pkg = New-FakeNupkg -Root $TestDrive - $result = New-PassingResult -Root $TestDrive -Version '9.9.9' - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = (Join-Path $TestDrive 'ch3'); TestResultPath = $result } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -BeLike '*cannot be tied back to the tested bits*' - } - - It 'refuses a gate-failing test result' { - $pkg = New-FakeNupkg -Root $TestDrive - $failing = Join-Path $TestDrive 'NUnitXml_GraphKit_v9.9.9.Fail.xml' - @' - - - - -'@ | Set-Content -LiteralPath $failing -Encoding utf8 - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = (Join-Path $TestDrive 'ch4'); TestResultPath = $failing } + $r = Invoke-Publish @{ + PackagePath = $pkg + Channel = 'FileSystem' + Destination = (Join-Path $TestDrive 'ch2') + TestResultPath = $wrong + ProofPath = (Join-Path $TestDrive 'missing-tested-release-proof.json') + } $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -BeLike '*did not pass the whole-result gate*' + $r.Output | Should -BeLike '*No tested release proof found*' } Context 'channel immutability' { @@ -129,11 +113,10 @@ Describe 'Publish-GraphKitPackage refusals' { $null = New-Item -ItemType Directory -Path $channel -Force $first = New-FakeNupkg -Root $TestDrive -Psm1Content 'body one' - $r1 = Invoke-Publish @{ PackagePath = $first; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'p1.json') } - $r1.ExitCode | Should -Be 0 + Copy-Item -LiteralPath $first -Destination (Join-Path $channel (Split-Path $first -Leaf)) $second = New-FakeNupkg -Root (Join-Path $TestDrive 'v2') -Psm1Content 'body two DIFFERENT' - $r2 = Invoke-Publish @{ PackagePath = $second; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'p2.json') } + $r2 = Invoke-Publish @{ PackagePath = $second; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; WhatIf = $true; PinPath = (Join-Path $TestDrive 'p2.json') } $r2.ExitCode | Should -Not -Be 0 $r2.Output | Should -BeLike '*DIFFERENT bytes*' } @@ -142,25 +125,43 @@ Describe 'Publish-GraphKitPackage refusals' { $channel = Join-Path $TestDrive 'idempotent' $null = New-Item -ItemType Directory -Path $channel -Force $pkg = New-FakeNupkg -Root (Join-Path $TestDrive 'same') -Psm1Content 'identical body' + Copy-Item -LiteralPath $pkg -Destination (Join-Path $channel (Split-Path $pkg -Leaf)) - $r1 = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'q1.json') } - $r2 = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'q2.json') } - $r1.ExitCode | Should -Be 0 + $r2 = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; WhatIf = $true; PinPath = (Join-Path $TestDrive 'q2.json') } $r2.ExitCode | Should -Be 0 $r2.Output | Should -BeLike '*Already published with identical bytes*' } } - It 'writes a pin record naming the exact bytes' { + It 'refuses -SkipTestProof outside -WhatIf and writes nothing' { $channel = Join-Path $TestDrive 'pinned' $pkg = New-FakeNupkg -Root (Join-Path $TestDrive 'pinsrc') $pinPath = Join-Path $TestDrive 'pin.json' $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = $pinPath } - $r.ExitCode | Should -Be 0 - - $pin = Get-Content -LiteralPath $pinPath -Raw | ConvertFrom-Json - $pin.version | Should -Be '9.9.9' - $pin.sha256 | Should -Be (Get-FileHash -LiteralPath $pkg -Algorithm SHA256).Hash - $pin.testProof | Should -BeLike '*without test proof*' + $r.ExitCode | Should -Not -Be 0 + $r.Output | Should -BeLike '*only allowed with -WhatIf*' + Test-Path -LiteralPath $channel | Should -BeFalse + Test-Path -LiteralPath $pinPath | Should -BeFalse + + $dryRun = Invoke-Publish @{ + PackagePath = $pkg + Channel = 'GitHubRelease' + Destination = 'example/graphkit' + SkipTestProof = $true + WhatIf = $true + PinPath = (Join-Path $TestDrive 'github-dry-run-pin.json') + } + $dryRun.ExitCode | Should -Be 0 -Because $dryRun.Output + $dryRun.Output | Should -BeLike '*NONE - WhatIf-only unverified dry run*' + $dryRun.Output | Should -Not -BeLike '*releases/download/v9.9.9/*' + + $publisherSource = [IO.File]::ReadAllText($script:publish) + $githubBranchIndex = $publisherSource.IndexOf("'GitHubRelease' {", [StringComparison]::Ordinal) + $shouldProcessIndex = $publisherSource.IndexOf('$PSCmdlet.ShouldProcess', $githubBranchIndex, [StringComparison]::Ordinal) + $ghAvailabilityIndex = $publisherSource.IndexOf('Get-Command gh', $githubBranchIndex, [StringComparison]::Ordinal) + $githubBranchIndex | Should -BeGreaterOrEqual 0 + $shouldProcessIndex | Should -BeGreaterThan $githubBranchIndex + $ghAvailabilityIndex | Should -BeGreaterThan $shouldProcessIndex ` + -Because 'WhatIf must not require a publication-only CLI' } } diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 new file mode 100644 index 0000000..d0be128 --- /dev/null +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -0,0 +1,1417 @@ +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $script:verifierPath = Join-Path $script:repoRoot 'scripts/Test-GraphKitReleaseProof.ps1' + $script:generatorPath = Join-Path $script:repoRoot 'scripts/New-GraphKitTestedReleaseProof.ps1' + + function Add-GraphKitFixtureArchiveFile { + param( + [Parameter(Mandatory)] [System.IO.Compression.ZipArchive] $Archive, + [Parameter(Mandatory)] [string] $EntryName, + [Parameter(Mandatory)] [string] $SourcePath + ) + + $entry = $Archive.CreateEntry($EntryName) + $entryStream = $entry.Open() + $sourceStream = [System.IO.File]::OpenRead($SourcePath) + try { + $sourceStream.CopyTo($entryStream) + } + finally { + $sourceStream.Dispose() + $entryStream.Dispose() + } + } + + function Add-GraphKitFixtureArchiveText { + param( + [Parameter(Mandatory)] [System.IO.Compression.ZipArchive] $Archive, + [Parameter(Mandatory)] [string] $EntryName, + [Parameter(Mandatory)] [string] $Content + ) + + $entry = $Archive.CreateEntry($EntryName) + $stream = $entry.Open() + $writer = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false)) + try { + $writer.Write($Content) + $writer.Flush() + } + finally { + $writer.Dispose() + } + } + + function Add-GraphKitFixturePayloadBytes { + param( + [Parameter(Mandatory)] [pscustomobject] $Fixture, + [Parameter(Mandatory)] [string] $EntryName, + [Parameter(Mandatory)] [byte[]] $Bytes + ) + + $payloadPath = Join-Path $Fixture.ModuleDir $EntryName + New-Item -ItemType Directory -Path (Split-Path $payloadPath -Parent) -Force | Out-Null + [System.IO.File]::WriteAllBytes($payloadPath, $Bytes) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($Fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + if (@($archive.Entries | Where-Object FullName -CEQ $EntryName).Count -ne 0) { + throw "Fixture payload '$EntryName' already exists." + } + Add-GraphKitFixtureArchiveFile -Archive $archive -EntryName $EntryName -SourcePath $payloadPath + } + finally { + $archive.Dispose() + } + + $proof = Get-Content -LiteralPath $Fixture.ProofPath -Raw | ConvertFrom-Json + $newRecord = [pscustomobject] [ordered] @{ + path = $EntryName + sha256 = (Get-FileHash -LiteralPath $payloadPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + $proof.module.files = @(@($proof.module.files) + $newRecord | Sort-Object path) + $proof.package.sha256 = (Get-FileHash -LiteralPath $Fixture.PackagePath -Algorithm SHA256).Hash.ToLowerInvariant() + $proof | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $Fixture.ProofPath -NoNewline -Encoding utf8NoBOM + } + + function Add-GraphKitFixturePayloadText { + param( + [Parameter(Mandatory)] [pscustomobject] $Fixture, + [Parameter(Mandatory)] [string] $EntryName, + [Parameter(Mandatory)] [string] $Content + ) + + Add-GraphKitFixturePayloadBytes -Fixture $Fixture -EntryName $EntryName ` + -Bytes ([System.Text.UTF8Encoding]::new($false).GetBytes($Content)) + } + + function Update-GraphKitFixtureProofPackageHash { + param([Parameter(Mandatory)] [pscustomobject] $Fixture) + + $proof = Get-Content -LiteralPath $Fixture.ProofPath -Raw | ConvertFrom-Json + $proof.package.sha256 = (Get-FileHash -LiteralPath $Fixture.PackagePath -Algorithm SHA256).Hash.ToLowerInvariant() + $proof | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $Fixture.ProofPath -NoNewline -Encoding utf8NoBOM + } + + function Set-GraphKitFixtureArchiveEntryText { + param( + [Parameter(Mandatory)] [pscustomobject] $Fixture, + [Parameter(Mandatory)] [string] $EntryName, + [Parameter(Mandatory)] [string] $Content + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($Fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + $existing = @($archive.Entries | Where-Object FullName -CEQ $EntryName) + foreach ($entry in $existing) { $entry.Delete() } + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName $EntryName -Content $Content + } + finally { + $archive.Dispose() + } + Update-GraphKitFixtureProofPackageHash -Fixture $Fixture + } + + function Get-GraphKitFixtureArchiveEntryText { + param( + [Parameter(Mandatory)] [pscustomobject] $Fixture, + [Parameter(Mandatory)] [string] $EntryName + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($Fixture.PackagePath) + try { + $entry = @($archive.Entries | Where-Object FullName -CEQ $EntryName) + if ($entry.Count -ne 1) { throw "Fixture expected one '$EntryName' entry." } + $reader = [System.IO.StreamReader]::new($entry[0].Open()) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } + finally { + $archive.Dispose() + } + } + + function New-GraphKitReleaseProofFixture { + param( + [int] $Failures = 0, + [int] $Errors = 0, + [int] $Skipped = 0, + [int] $NotRun = 0, + [int] $FailedContainers = 0, + [int] $FailedBlocks = 0, + [int] $Inconclusive = 0, + [string] $PesterResult, + [int] $Passed = -1, + [bool] $Executed = $true, + [switch] $ForGenerator, + [switch] $IncludeGraphKitAuth, + [switch] $NoRequiredModules, + [switch] $OmitRequiredModules, + [switch] $NullRequiredModules, + [string] $BaseVersion = '0.4.0', + [switch] $DirtySource, + [int] $Total = 1482 + ) + + $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) + $baseVersion = $BaseVersion + $revision = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + $version = "$baseVersion-r8.g$($revision.Substring(0, 12))" + $sourceStateHash = if ($DirtySource) { ('b' * 64) -join '' } else { $null } + if ($DirtySource) { $version += ".d$($sourceStateHash.Substring(0, 12))" } + $moduleDir = Join-Path $fixtureRoot "output/module/GraphKit/$baseVersion" + $resultsDir = Join-Path $fixtureRoot 'output/testResults' + $gateDir = Join-Path $fixtureRoot 'tests/QA' + $scriptsDir = Join-Path $fixtureRoot 'scripts' + $privateScriptsDir = Join-Path $scriptsDir 'private' + $authSourceDir = Join-Path $fixtureRoot 'src/GraphKit.Auth/GraphKit.Auth' + $buildDir = Join-Path $fixtureRoot '.build' + New-Item -ItemType Directory -Path $moduleDir, $resultsDir, $gateDir, $scriptsDir, $privateScriptsDir, $authSourceDir, $buildDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $authSourceDir 'Fixture.cs') -NoNewline -Encoding utf8NoBOM -Value @' +namespace GraphKit.Auth; +internal static class Fixture { internal const string Value = "public fixture"; } +'@ + + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'tests/QA/Assert-GateResult.ps1') ` + -Destination (Join-Path $gateDir 'Assert-GateResult.ps1') + Copy-Item -LiteralPath $script:verifierPath ` + -Destination (Join-Path $scriptsDir 'Test-GraphKitReleaseProof.ps1') + Copy-Item -LiteralPath $script:generatorPath ` + -Destination (Join-Path $scriptsDir 'New-GraphKitTestedReleaseProof.ps1') + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/Publish-GraphKitPackage.ps1') ` + -Destination (Join-Path $scriptsDir 'Publish-GraphKitPackage.ps1') + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/Publish-GraphKitToGallery.ps1') ` + -Destination (Join-Path $scriptsDir 'Publish-GraphKitToGallery.ps1') + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/private/Test-GraphKitPackagePrivacy.ps1') ` + -Destination (Join-Path $privateScriptsDir 'Test-GraphKitPackagePrivacy.ps1') + if ($IncludeGraphKitAuth) { + Copy-Item -LiteralPath (Join-Path $script:repoRoot '.build/GraphKitAuth.tasks.ps1') ` + -Destination (Join-Path $buildDir 'GraphKitAuth.tasks.ps1') + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs') ` + -Destination (Join-Path $privateScriptsDir 'GraphKit.AuthStageCapture.cs') + } + + if ($ForGenerator) { + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/Get-GraphKitTrainVersion.ps1') ` + -Destination (Join-Path $scriptsDir 'Get-GraphKitTrainVersion.ps1') + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/private/GraphKit.SourceCapture.cs') ` + -Destination (Join-Path $privateScriptsDir 'GraphKit.SourceCapture.cs') + Set-Content -LiteralPath (Join-Path $fixtureRoot '.gitignore') -Value "output/`nLICENSE`n" -NoNewline -Encoding utf8NoBOM + & git -C $fixtureRoot init --quiet + & git -C $fixtureRoot config core.autocrlf false + & git -C $fixtureRoot add .gitignore scripts src tests + & git -C $fixtureRoot -c user.name='GraphKit Fixture' -c user.email='fixture@example.invalid' commit --quiet -m 'fixture source' + $revision = (& git -C $fixtureRoot rev-parse HEAD).Trim().ToLowerInvariant() + $version = (& (Join-Path $scriptsDir 'Get-GraphKitTrainVersion.ps1') -RepositoryRoot $fixtureRoot).Trim() + } + $prerelease = $version.Substring($baseVersion.Length + 1) + $requiredAssembliesLine = if ($IncludeGraphKitAuth) { + " RequiredAssemblies = @('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')`n" + } + else { '' } + $requiredModulesLine = if ($OmitRequiredModules) { + '' + } + elseif ($NullRequiredModules) { + ' RequiredModules = $null' + } + elseif ($NoRequiredModules) { + ' RequiredModules = @()' + } + else { + " RequiredModules = @(@{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' })" + } + $dependenciesMarkup = if ($NoRequiredModules -or $OmitRequiredModules -or $NullRequiredModules) { + '' + } + else { + '' + } + + $payloads = [ordered] @{ + 'Data/Operations/Probe.List.psd1' = "@{ SchemaVersion = 1; Type = 'Probe'; Operation = 'List' }`n" + 'Formats/GraphKit.Format.ps1xml' = "`n" + 'GraphKit.psd1' = @" +@{ + RootModule = 'GraphKit.psm1' + ModuleVersion = '$baseVersion' + GUID = '12345678-1234-1234-9234-123456789abc' + Author = 'Fixture Author' + CompanyName = 'Fixture Company' + Copyright = '(c) Fixture Author' + Description = 'Fixture GraphKit release-proof module package.' + FunctionsToExport = @('Get-GraphProbe') +$requiredAssembliesLine$requiredModulesLine + PrivateData = @{ PSData = @{ + Tags = @('Fixture', 'Graph') + LicenseUri = 'https://opensource.org/licenses/MIT' + ReleaseNotes = 'Fixture release notes.' + Prerelease = '$prerelease' + } } +} +"@ + 'GraphKit.psm1' = "function Get-GraphProbe { 'fixture' }`n" + 'en-US/about_GraphKit.help.txt' = "TOPIC`n about_GraphKit`n" + } + if ($IncludeGraphKitAuth) { + $payloads['Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll'] = 'fixture contracts bytes' + $payloads['Assemblies/GraphKit.Auth/GraphKit.Auth.dll'] = 'fixture provider bytes' + $payloads['Assemblies/GraphKit.Auth/GraphKit.Auth.deps.json'] = '{"runtimeTarget":{"name":"fixture"}}' + $payloads['Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll'] = 'fixture msal bytes' + $payloads['Assemblies/GraphKit.Auth/Microsoft.IdentityModel.Abstractions.dll'] = 'fixture abstractions bytes' + } + + foreach ($relativePath in $payloads.Keys) { + $path = Join-Path $moduleDir $relativePath + New-Item -ItemType Directory -Path (Split-Path $path -Parent) -Force | Out-Null + Set-Content -LiteralPath $path -Value $payloads[$relativePath] -NoNewline -Encoding utf8NoBOM + } + Set-Content -LiteralPath (Join-Path $fixtureRoot 'LICENSE') -Value 'Fixture license.' -NoNewline -Encoding utf8NoBOM + if ($IncludeGraphKitAuth) { + . (Join-Path $buildDir 'GraphKitAuth.tasks.ps1') -SkipTaskRegistration + $null = New-GraphKitAuthSealedStage -OutputRoot (Join-Path $fixtureRoot 'output') ` + -FullVersion $version ` + -PayloadSourceRoot (Join-Path $moduleDir 'Assemblies/GraphKit.Auth') + } + + $packagePath = Join-Path $fixtureRoot "output/GraphKit.$version.nupkg" + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($packagePath, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($relativePath in $payloads.Keys) { + Add-GraphKitFixtureArchiveFile -Archive $archive -EntryName $relativePath -SourcePath (Join-Path $moduleDir $relativePath) + } + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName 'GraphKit.nuspec' -Content @" + +GraphKit$versionFixture AuthorFixture Authorfalsehttps://opensource.org/licenses/MITFixture GraphKit release-proof module package.Fixture release notes.(c) Fixture AuthorFixture Graph PSModule PSIncludes_Function PSFunction_Get-GraphProbe PSCommand_Get-GraphProbe$dependenciesMarkup +"@ + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName '_rels/.rels' -Content '' + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName '[Content_Types].xml' -Content '' + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName 'package/services/metadata/core-properties/nuget.psmdcp' -Content '' + } + finally { + $archive.Dispose() + } + + $suiteResult = if ($Errors -gt 0) { + 'Error' + } + elseif ($Failures -gt 0) { + 'Failure' + } + elseif ($Skipped -gt 0) { + 'Ignored' + } + else { + 'Success' + } + $pesterOutcome = if (-not [string]::IsNullOrWhiteSpace($PesterResult)) { + $PesterResult + } + elseif ($suiteResult -eq 'Success') { + 'Passed' + } + else { + $suiteResult + } + $resultSuffix = "GraphKit_v$version.Fixture.xml" + $nunitPath = Join-Path $resultsDir "NUnitXml_$resultSuffix" + $containerName = if ($FailedContainers -gt 0) { 'Discovery failure fixture' } else { 'GraphKit' } + Set-Content -LiteralPath $nunitPath -Encoding utf8NoBOM -Value @" + + + + +"@ + + $pesterObjectPath = Join-Path $resultsDir "PesterObject_$resultSuffix" + $resolvedPassed = if ($Passed -ge 0) { + $Passed + } + else { + $Total - $Failures - $Skipped - $NotRun - $Inconclusive + } + [pscustomobject] [ordered] @{ + Result = $pesterOutcome + TotalCount = $Total + PassedCount = $resolvedPassed + FailedCount = $Failures + SkippedCount = $Skipped + NotRunCount = $NotRun + FailedBlocksCount = $FailedBlocks + FailedContainersCount = $FailedContainers + InconclusiveCount = $Inconclusive + Executed = $Executed + Containers = @() + } | Export-Clixml -LiteralPath $pesterObjectPath + + $moduleFiles = @( + $payloads.Keys | + Sort-Object | + ForEach-Object { + [pscustomobject] [ordered] @{ + path = $_ + sha256 = (Get-FileHash -LiteralPath (Join-Path $moduleDir $_) -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + ) + $proofPath = Join-Path $resultsDir 'tested-release-proof.json' + [pscustomobject] [ordered] @{ + schemaVersion = 3 + runId = [guid]::NewGuid().ToString('D') + source = [pscustomobject] [ordered] @{ + revision = $revision + clean = -not $DirtySource + stateSha256 = if ($DirtySource) { $sourceStateHash } else { ('c' * 64) -join '' } + } + module = [pscustomobject] [ordered] @{ + name = 'GraphKit' + version = $version + baseVersion = $baseVersion + files = $moduleFiles + } + package = [pscustomobject] [ordered] @{ + name = Split-Path -Leaf $packagePath + sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + } + testRun = [pscustomobject] [ordered] @{ + nunit = [pscustomobject] [ordered] @{ + name = Split-Path -Leaf $nunitPath + sha256 = (Get-FileHash -LiteralPath $nunitPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + pesterObject = [pscustomobject] [ordered] @{ + name = Split-Path -Leaf $pesterObjectPath + sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + policy = [pscustomobject] [ordered] @{ + minimumTests = 1482 + allowedSkips = 0 + allowedNotRun = 0 + } + summary = [pscustomobject] [ordered] @{ + overallResult = $suiteResult + pesterResult = $pesterOutcome + executed = $Executed + total = $Total + passed = $resolvedPassed + failures = $Failures + errors = $Errors + skipped = $Skipped + notRun = $NotRun + inconclusive = $Inconclusive + failedBlocks = $FailedBlocks + failedContainers = $FailedContainers + } + } + } | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $proofPath -NoNewline -Encoding utf8NoBOM + + [pscustomobject] @{ + Root = $fixtureRoot + Version = $version + BaseVersion = $baseVersion + ModuleDir = $moduleDir + AuthSourceDir = $authSourceDir + PackagePath = $packagePath + ProofPath = $proofPath + NUnitPath = $nunitPath + PesterObjectPath = $pesterObjectPath + } + } + + function Invoke-GraphKitReleaseProofVerifier { + param( + [Parameter(Mandatory)] [pscustomobject] $Fixture, + [switch] $RequestSnapshots + ) + + $snapshotPackagePath = Join-Path $Fixture.Root 'verified/GraphKit.nupkg' + $snapshotProofPath = Join-Path $Fixture.Root 'verified/tested-release-proof.json' + $snapshotArguments = if ($RequestSnapshots) { + @('-VerifiedPackageCopyPath', $snapshotPackagePath, '-VerifiedProofCopyPath', $snapshotProofPath) + } + else { @() } + + $output = & pwsh -NoLogo -NoProfile -File $script:verifierPath ` + -PackagePath $Fixture.PackagePath ` + -ProofPath $Fixture.ProofPath ` + -RepositoryRoot $Fixture.Root @snapshotArguments 2>&1 | Out-String + $output = $output -replace '\r?\n\s*\|\s*', ' ' + $output = ($output -replace '\s+', ' ').Trim() + [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output + SnapshotPackagePath = $snapshotPackagePath + SnapshotProofPath = $snapshotProofPath + } + } + + function Invoke-GraphKitReleaseProofGenerator { + param( + [Parameter(Mandatory)] [pscustomobject] $Fixture, + [Parameter(Mandatory)] [ValidateSet('Capture', 'Finalize')] [string] $Stage + ) + + $output = & pwsh -NoLogo -NoProfile -File $script:generatorPath ` + -Stage $Stage ` + -RepositoryRoot $Fixture.Root 2>&1 | Out-String + $output = $output -replace '\r?\n\s*\|\s*', ' ' + $output = ($output -replace '\s+', ' ').Trim() + [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output + } + } + + function Invoke-GraphKitFixturePublisher { + param( + [Parameter(Mandatory)] [pscustomobject] $Fixture, + [Parameter(Mandatory)] [ValidateSet('PrivateChannel', 'PSGallery')] [string] $Publisher + ) + + $scriptPath = if ($Publisher -eq 'PrivateChannel') { + Join-Path $Fixture.Root 'scripts/Publish-GraphKitPackage.ps1' + } + else { + Join-Path $Fixture.Root 'scripts/Publish-GraphKitToGallery.ps1' + } + $arguments = if ($Publisher -eq 'PrivateChannel') { + @( + '-PackagePath', $Fixture.PackagePath, + '-Channel', 'FileSystem', + '-Destination', (Join-Path $Fixture.Root 'channel'), + '-TestResultPath', $Fixture.NUnitPath, + '-PinPath', (Join-Path $Fixture.Root 'graphkit.pin.json') + ) + } + else { + @('-PackagePath', $Fixture.PackagePath, '-WhatIfOnly') + } + + $output = & pwsh -NoLogo -NoProfile -File $scriptPath @arguments 2>&1 | Out-String + $output = $output -replace '\r?\n\s*\|\s*', ' ' + $output = ($output -replace '\s+', ' ').Trim() + [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output + } + } + + function Install-GraphKitFixtureMutatingVerifier { + param([Parameter(Mandatory)] [pscustomobject] $Fixture) + + $coreVerifier = Join-Path $Fixture.Root 'scripts/Test-GraphKitReleaseProof.Core.ps1' + Move-Item -LiteralPath (Join-Path $Fixture.Root 'scripts/Test-GraphKitReleaseProof.ps1') -Destination $coreVerifier + Set-Content -LiteralPath (Join-Path $Fixture.Root 'scripts/Test-GraphKitReleaseProof.ps1') -Encoding utf8NoBOM -Value @' +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $PackagePath, + [string] $ProofPath, + [string] $TestResultPath, + [string] $RepositoryRoot, + [string] $VerifiedPackageCopyPath, + [string] $VerifiedProofCopyPath +) +$parameters = @{ + PackagePath = $PackagePath + ProofPath = $ProofPath + TestResultPath = $TestResultPath + RepositoryRoot = $RepositoryRoot +} +if (-not [string]::IsNullOrWhiteSpace($VerifiedPackageCopyPath)) { + $parameters.VerifiedPackageCopyPath = $VerifiedPackageCopyPath +} +if (-not [string]::IsNullOrWhiteSpace($VerifiedProofCopyPath)) { + $parameters.VerifiedProofCopyPath = $VerifiedProofCopyPath +} +$verified = & (Join-Path $PSScriptRoot 'Test-GraphKitReleaseProof.Core.ps1') @parameters +$effectiveProofPath = if ([string]::IsNullOrWhiteSpace($ProofPath)) { + Join-Path $RepositoryRoot 'output/testResults/tested-release-proof.json' +} +else { + $ProofPath +} +[System.IO.File]::WriteAllText($PackagePath, 'replacement package after verifier return') +[System.IO.File]::WriteAllText($effectiveProofPath, '{"replacementProof":true}') +$mutableManifestPath = Join-Path $RepositoryRoot "output/module/GraphKit/$($verified.BaseVersion)/GraphKit.psd1" +$mutableManifest = [System.IO.File]::ReadAllText($mutableManifestPath) +$mutableManifest = $mutableManifest.Replace( + "GUID = '12345678-1234-1234-9234-123456789abc'", + "GUID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'" +) +[System.IO.File]::WriteAllText($mutableManifestPath, $mutableManifest) +$verified +'@ + } + + function Invoke-GraphKitFixtureGalleryPreflight { + param([Parameter(Mandatory)] [pscustomobject] $Fixture) + + $bootstrapPath = Join-Path $Fixture.Root 'Invoke-FixtureGalleryPreflight.ps1' + Set-Content -LiteralPath $bootstrapPath -Encoding utf8NoBOM -Value @' +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $PublisherPath, + [Parameter(Mandatory)] [string] $PackagePath, + [Parameter(Mandatory)] [string] $ProofPath, + [Parameter(Mandatory)] [string] $TestResultPath, + [Parameter(Mandatory)] [string] $MutableManifestPath +) +function Find-PSResource { + [CmdletBinding()] + param([string] $Name, [string] $Repository) + return $null +} +function Test-ModuleManifest { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Path) + $resolved = (Resolve-Path -LiteralPath $Path).ProviderPath + $mutable = (Resolve-Path -LiteralPath $MutableManifestPath).ProviderPath + if ([string]::Equals($resolved, $mutable, [System.StringComparison]::Ordinal)) { + throw 'Gallery reopened the mutable built manifest after proof verification.' + } + return Import-PowerShellDataFile -LiteralPath $resolved +} +& $PublisherPath ` + -PackagePath $PackagePath ` + -ProofPath $ProofPath ` + -TestResultPath $TestResultPath ` + -WhatIfOnly +'@ + + $output = & pwsh -NoLogo -NoProfile -File $bootstrapPath ` + -PublisherPath (Join-Path $Fixture.Root 'scripts/Publish-GraphKitToGallery.ps1') ` + -PackagePath $Fixture.PackagePath ` + -ProofPath $Fixture.ProofPath ` + -TestResultPath $Fixture.NUnitPath ` + -MutableManifestPath (Join-Path $Fixture.ModuleDir 'GraphKit.psd1') 2>&1 | Out-String + $output = $output -replace '\r?\n\s*\|\s*', ' ' + $output = ($output -replace '\s+', ' ').Trim() + [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output + } + } +} + +Describe 'Canonical tested release proof' { + AfterEach { + if ($script:fixture) { + $fixtureStageRoot = Join-Path $script:fixture.Root 'output/GraphKit.Auth/stage' + if (Test-Path -LiteralPath $fixtureStageRoot -PathType Container) { + . (Join-Path $script:fixture.Root '.build/GraphKitAuth.tasks.ps1') -SkipTaskRegistration + Invoke-GraphKitAuthPrepareClean -OutputRoot (Join-Path $script:fixture.Root 'output') | Out-Null + } + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force -ErrorAction SilentlyContinue + $script:fixture = $null + } + } + + It 'accepts one proof binding every shipped file and exact optional dependency metadata' { + $script:fixture = New-GraphKitReleaseProofFixture + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match 'VERIFIED TESTED RELEASE' + $result.Output | Should -Match '5 shipped file' + + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force + $script:fixture = New-GraphKitReleaseProofFixture -NoRequiredModules + $withoutDependencies = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + $withoutDependencies.ExitCode | Should -Be 0 -Because $withoutDependencies.Output + + $nuspec = Get-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' + $withEmptyDependencies = $nuspec.Replace('', '') + Set-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' ` + -Content $withEmptyDependencies + $emptyDependencies = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + $emptyDependencies.ExitCode | Should -Be 0 -Because $emptyDependencies.Output + + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force + $script:fixture = New-GraphKitReleaseProofFixture -OmitRequiredModules + $withoutRequiredModulesKey = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + $withoutRequiredModulesKey.ExitCode | Should -Be 0 -Because $withoutRequiredModulesKey.Output + + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force + $script:fixture = New-GraphKitReleaseProofFixture -NullRequiredModules + $withNullRequiredModules = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + $withNullRequiredModules.ExitCode | Should -Be 0 -Because $withNullRequiredModules.Output + } + + It 'accepts GraphKit.Auth runtime bytes when the data-file Hashtable declares the exact contracts prerequisite' { + $script:fixture = New-GraphKitReleaseProofFixture -IncludeGraphKitAuth + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match 'VERIFIED TESTED RELEASE' + $result.Output | Should -Match '10 shipped file' + } + + It 'accepts a prerelease package from its base-version module directory and records source provenance' { + $script:fixture = New-GraphKitReleaseProofFixture + $proof = Get-Content -LiteralPath $script:fixture.ProofPath -Raw | ConvertFrom-Json + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Be 0 -Because $result.Output + $proof.source.revision | Should -Match '^[0-9a-f]{40}$' + $proof.module.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}$' + } + + It 'rejects a proof whose base version is not the R8 0.4.0 successor base' { + $script:fixture = New-GraphKitReleaseProofFixture -BaseVersion '0.4.1' + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match '0\.4\.0.*r8' + } + + It 'rejects dirty provenance before it can emit authority or snapshots' { + $script:fixture = New-GraphKitReleaseProofFixture -DirtySource + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture -RequestSnapshots + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'non-authoritative' + $result.Output | Should -Not -Match '^VERIFIED TESTED RELEASE:' + Test-Path -LiteralPath $result.SnapshotPackagePath | Should -BeFalse + Test-Path -LiteralPath $result.SnapshotProofPath | Should -BeFalse + } + + It 'accepts package-serializer trimming of terminal release-note line endings' { + $script:fixture = New-GraphKitReleaseProofFixture + $manifestPath = Join-Path $script:fixture.ModuleDir 'GraphKit.psd1' + $manifestContent = Get-Content -LiteralPath $manifestPath -Raw + $manifestContent = $manifestContent.Replace( + "ReleaseNotes = 'Fixture release notes.'", + 'ReleaseNotes = "Fixture release notes.`n`n"' + ) + Set-Content -LiteralPath $manifestPath -Value $manifestContent -NoNewline -Encoding utf8NoBOM + Set-GraphKitFixtureArchiveEntryText ` + -Fixture $script:fixture ` + -EntryName 'GraphKit.psd1' ` + -Content $manifestContent + + $proof = Get-Content -LiteralPath $script:fixture.ProofPath -Raw | ConvertFrom-Json + ($proof.module.files | Where-Object path -CEQ 'GraphKit.psd1').sha256 = + (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + $proof | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $script:fixture.ProofPath -NoNewline -Encoding utf8NoBOM + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match 'VERIFIED TESTED RELEASE' + } + + It 'rejects changed bytes by their shipped relative path' -ForEach @( + @{ Kind = 'descriptor'; RelativePath = 'Data/Operations/Probe.List.psd1' } + @{ Kind = 'manifest'; RelativePath = 'GraphKit.psd1' } + @{ Kind = 'format'; RelativePath = 'Formats/GraphKit.Format.ps1xml' } + @{ Kind = 'help'; RelativePath = 'en-US/about_GraphKit.help.txt' } + ) { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Content -LiteralPath (Join-Path $script:fixture.ModuleDir $RelativePath) -Value 'changed after test' + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match ([regex]::Escape($RelativePath)) + $result.Output | Should -Match 'does not match the tested release proof' + } + + It 'rejects a shipped file missing after the test run' { + $script:fixture = New-GraphKitReleaseProofFixture + Remove-Item -LiteralPath (Join-Path $script:fixture.ModuleDir 'GraphKit.psm1') -Force + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'GraphKit\.psm1' + $result.Output | Should -Match 'file set differs' + } + + It 'rejects an extra untested shipped file' { + $script:fixture = New-GraphKitReleaseProofFixture + Set-Content -LiteralPath (Join-Path $script:fixture.ModuleDir 'untested.txt') -Value 'extra' -NoNewline + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'untested\.txt' + $result.Output | Should -Match 'file set differs' + } + + It 'rejects bytes changed only inside the package' -ForEach @( + @{ Kind = 'descriptor'; RelativePath = 'Data/Operations/Probe.List.psd1' } + @{ Kind = 'manifest'; RelativePath = 'GraphKit.psd1' } + @{ Kind = 'format'; RelativePath = 'Formats/GraphKit.Format.ps1xml' } + @{ Kind = 'help'; RelativePath = 'en-US/about_GraphKit.help.txt' } + ) { + $script:fixture = New-GraphKitReleaseProofFixture + $changedContent = (Get-Content -LiteralPath (Join-Path $script:fixture.ModuleDir $RelativePath) -Raw) + "`nchanged only in package" + Set-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName $RelativePath -Content $changedContent + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match ([regex]::Escape($RelativePath)) + $result.Output | Should -Match 'does not match the tested release proof' + } + + It 'rejects a shipped file missing only from the package' { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($script:fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + ($archive.GetEntry('GraphKit.psm1')).Delete() + } + finally { + $archive.Dispose() + } + Update-GraphKitFixtureProofPackageHash -Fixture $script:fixture + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'missing:GraphKit\.psm1' + } + + It 'rejects an extra file present only in the package' { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($script:fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName 'extra.ps1' -Content 'untested package code' + } + finally { + $archive.Dispose() + } + Update-GraphKitFixtureProofPackageHash -Fixture $script:fixture + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'extra:extra\.ps1' + } + + It 'rejects a duplicate package entry path' { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($script:fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName 'GraphKit.psm1' -Content 'duplicate payload' + } + finally { + $archive.Dispose() + } + Update-GraphKitFixtureProofPackageHash -Fixture $script:fixture + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'duplicate entry path' + } + + It 'rejects NFC-equivalent package entry paths before file-set comparison' { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open( + $script:fixture.PackagePath, + [System.IO.Compression.ZipArchiveMode]::Update) + try { + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName "Data/prob$([char]0x00E9).ps1" -Content 'composed' + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName "Data/probe$([char]0x0301).ps1" -Content 'decomposed' + } + finally { + $archive.Dispose() + } + Update-GraphKitFixtureProofPackageHash -Fixture $script:fixture + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'Unicode|normalization|NFC' + } + + It 'rejects a ZIP entry encoded as ' -ForEach @( + @{ Kind = 'a Unix symbolic link'; ExternalAttributes = ((0xA000 -bor 0x1A4) -shl 16) } + @{ Kind = 'a Unix non-regular device'; ExternalAttributes = ((0x2000 -bor 0x180) -shl 16) } + @{ Kind = 'a Windows reparse point'; ExternalAttributes = 0x0400 } + @{ Kind = 'a Windows DOS directory'; ExternalAttributes = 0x0010 } + ) { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open( + $script:fixture.PackagePath, + [System.IO.Compression.ZipArchiveMode]::Update) + try { + $entry = $archive.GetEntry('GraphKit.psm1') + $entry.ExternalAttributes = $ExternalAttributes + } + finally { + $archive.Dispose() + } + Update-GraphKitFixtureProofPackageHash -Fixture $script:fixture + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'non-regular|link|reparse' + } + + It 'rejects unsafe package path ' -ForEach @( + @{ EntryName = '../outside/' } + @{ EntryName = '/absolute.ps1' } + @{ EntryName = 'C:/absolute.ps1' } + @{ EntryName = 'Data\\evil.ps1' } + @{ EntryName = 'Data//evil.ps1' } + @{ EntryName = 'Data/./evil.ps1' } + @{ EntryName = 'Data/../evil.ps1' } + @{ EntryName = 'package/services/metadata/core-properties/../../../../evil.ps1' } + ) { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($script:fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName $EntryName -Content 'unsafe' + } + finally { + $archive.Dispose() + } + Update-GraphKitFixtureProofPackageHash -Fixture $script:fixture + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'unsafe package entry path' + } + + It 'rejects a case-colliding proof file path' { + $script:fixture = New-GraphKitReleaseProofFixture + $proof = Get-Content -LiteralPath $script:fixture.ProofPath -Raw | ConvertFrom-Json + $proof.module.files += [pscustomobject] @{ + path = 'graphkit.psm1' + sha256 = ($proof.module.files | Where-Object path -CEQ 'GraphKit.psm1').sha256 + } + $proof | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $script:fixture.ProofPath -NoNewline -Encoding utf8NoBOM + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'case-colliding|duplicate module-file' + } + + It 'rejects NFC-equivalent proof file paths' { + $script:fixture = New-GraphKitReleaseProofFixture + $proof = Get-Content -LiteralPath $script:fixture.ProofPath -Raw | ConvertFrom-Json + $hash = ('d' * 64) -join '' + $proof.module.files = @($proof.module.files) + @( + [pscustomobject] @{ path = "Data/prob$([char]0x00E9).ps1"; sha256 = $hash } + [pscustomobject] @{ path = "Data/probe$([char]0x0301).ps1"; sha256 = $hash } + ) + $proof | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $script:fixture.ProofPath -NoNewline -Encoding utf8NoBOM + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'Unicode|normalization|NFC' + } + + It 'rejects nuspec drift' -ForEach @( + @{ Field = 'id'; Find = 'GraphKit'; Replace = 'OtherModule' } + @{ Field = 'version'; Find = $null; Replace = '9.9.8' } + @{ Field = 'authors'; Find = 'Fixture Author'; Replace = 'Other Author' } + @{ Field = 'description'; Find = 'Fixture GraphKit release-proof module package.'; Replace = 'Different description.' } + @{ Field = 'license'; Find = 'https://opensource.org/licenses/MIT'; Replace = 'https://example.invalid/license' } + @{ Field = 'tags'; Find = 'Fixture Graph PSModule PSIncludes_Function PSFunction_Get-GraphProbe PSCommand_Get-GraphProbe'; Replace = 'Different' } + @{ Field = 'release notes'; Find = 'Fixture release notes.'; Replace = 'Different notes.' } + ) { + $script:fixture = New-GraphKitReleaseProofFixture + $nuspec = Get-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' + if ($Field -eq 'version') { + $Find = "$($script:fixture.Version)" + } + Set-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' -Content ($nuspec.Replace($Find, $Replace)) + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'package metadata|nuspec' + } + + It 'rejects an injected nuspec dependency' { + $script:fixture = New-GraphKitReleaseProofFixture + $nuspec = Get-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' + $changed = $nuspec.Replace('', '') + Set-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' -Content $changed + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'dependencies.*built manifest' + } + + It 'rejects an injected nuspec metadata field' { + $script:fixture = New-GraphKitReleaseProofFixture + $nuspec = Get-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' + $changed = $nuspec.Replace('', 'https://example.invalid/project') + Set-GraphKitFixtureArchiveEntryText -Fixture $script:fixture -EntryName 'GraphKit.nuspec' -Content $changed + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'unsupported nuspec metadata' + } + + It 'rejects a proof that binds a failing result' { + $script:fixture = New-GraphKitReleaseProofFixture -Failures 1 + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match '1 test\(s\) failed' + } + + It 'rejects a proof that binds a skipped result' { + $script:fixture = New-GraphKitReleaseProofFixture -Skipped 1 + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match '1 test\(s\) skipped' + } + + It 'rejects a proof that binds a NotRun block' { + $script:fixture = New-GraphKitReleaseProofFixture -NotRun 1 + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match '1 NotRun' + } + + It 'rejects a proof that binds a discovery failure' { + $script:fixture = New-GraphKitReleaseProofFixture -FailedContainers 1 + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'failed container\(s\) / discovery error\(s\)' + } + + It 'rejects Pester-only while NUnit remains successful' -ForEach @( + @{ Case = 'non-passing result'; Parameters = @{ PesterResult = 'Failed' }; Expected = 'Pester result.*Passed' } + @{ Case = 'failed block'; Parameters = @{ FailedBlocks = 1 }; Expected = '1 failed block' } + @{ Case = 'inconclusive count'; Parameters = @{ Inconclusive = 1 }; Expected = '1 inconclusive' } + ) { + $script:fixture = New-GraphKitReleaseProofFixture @Parameters + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match $Expected + } + + It 'rejects a Pester result whose passed count cannot account for its total' { + $script:fixture = New-GraphKitReleaseProofFixture -Passed 0 + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'Pester count arithmetic' + } + + It 'rejects a Pester result that was not executed' { + $script:fixture = New-GraphKitReleaseProofFixture -Executed:$false + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'not executed' + } + + It 'rejects same-version package payload drift after the proof was recorded' { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::Open($script:fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + Add-GraphKitFixtureArchiveText -Archive $archive -EntryName 'Data/Operations/Drift.List.psd1' -Content '@{ drift = $true }' + } + finally { + $archive.Dispose() + } + + $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'package archive changed after the passing test run' + } +} + +Describe 'Test workflow release-proof generation' { + AfterEach { + if ($script:fixture) { + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force -ErrorAction SilentlyContinue + $script:fixture = $null + } + } + + It 'capture invalidates old proof and result files before recording the candidate' { + $script:fixture = New-GraphKitReleaseProofFixture -ForGenerator + + $result = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match 'CAPTURED RELEASE CANDIDATE' + Test-Path -LiteralPath $script:fixture.ProofPath | Should -BeFalse + Test-Path -LiteralPath $script:fixture.NUnitPath | Should -BeFalse + Test-Path -LiteralPath $script:fixture.PesterObjectPath | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeTrue + } + + It 'finalize atomically emits one proof and preserves the candidate when replacement fails' { + $script:fixture = New-GraphKitReleaseProofFixture -ForGenerator + $nunitBytes = [System.IO.File]::ReadAllBytes($script:fixture.NUnitPath) + $pesterObjectBytes = [System.IO.File]::ReadAllBytes($script:fixture.PesterObjectPath) + (Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture).ExitCode | Should -Be 0 + [System.IO.File]::WriteAllBytes($script:fixture.NUnitPath, $nunitBytes) + [System.IO.File]::WriteAllBytes($script:fixture.PesterObjectPath, $pesterObjectBytes) + @(& git -C $script:fixture.Root status --porcelain=v1 --untracked-files=all) | Should -BeNullOrEmpty + + $result = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Finalize + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match 'RECORDED TESTED RELEASE PROOF' + $proof = Get-Content -LiteralPath $script:fixture.ProofPath -Raw | ConvertFrom-Json + $proof.module.name | Should -Be 'GraphKit' + $proof.module.version | Should -Be $script:fixture.Version + $proof.module.baseVersion | Should -Be $script:fixture.BaseVersion + $proof.source.revision | Should -Match '^[0-9a-f]{40}$' + @($proof.module.files).Count | Should -Be 5 + $proof.testRun.summary.total | Should -Be 1482 + $proof.testRun.summary.notRun | Should -Be 0 + Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse + + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force + $script:fixture = New-GraphKitReleaseProofFixture -ForGenerator + $nunitBytes = [System.IO.File]::ReadAllBytes($script:fixture.NUnitPath) + $pesterObjectBytes = [System.IO.File]::ReadAllBytes($script:fixture.PesterObjectPath) + (Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture).ExitCode | Should -Be 0 + [System.IO.File]::WriteAllBytes($script:fixture.NUnitPath, $nunitBytes) + [System.IO.File]::WriteAllBytes($script:fixture.PesterObjectPath, $pesterObjectBytes) + New-Item -ItemType Directory -Path $script:fixture.ProofPath | Out-Null + + $failedReplacement = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Finalize + + $failedReplacement.ExitCode | Should -Not -Be 0 + Test-Path -LiteralPath ( + Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') ` + -PathType Leaf | Should -BeTrue + } + + It 'finalize refuses module drift after capture and leaves no tested proof' { + $script:fixture = New-GraphKitReleaseProofFixture -ForGenerator + $nunitBytes = [System.IO.File]::ReadAllBytes($script:fixture.NUnitPath) + $pesterObjectBytes = [System.IO.File]::ReadAllBytes($script:fixture.PesterObjectPath) + (Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture).ExitCode | Should -Be 0 + [System.IO.File]::WriteAllBytes($script:fixture.NUnitPath, $nunitBytes) + [System.IO.File]::WriteAllBytes($script:fixture.PesterObjectPath, $pesterObjectBytes) + Add-Content -LiteralPath (Join-Path $script:fixture.ModuleDir 'GraphKit.psm1') -Value '# drift' + + $result = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Finalize + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'module candidate changed after capture' + Test-Path -LiteralPath $script:fixture.ProofPath | Should -BeFalse + } + + It 'finalize refuses a NotRun result and leaves no tested proof' { + $script:fixture = New-GraphKitReleaseProofFixture -ForGenerator -NotRun 1 + $nunitBytes = [System.IO.File]::ReadAllBytes($script:fixture.NUnitPath) + $pesterObjectBytes = [System.IO.File]::ReadAllBytes($script:fixture.PesterObjectPath) + (Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture).ExitCode | Should -Be 0 + [System.IO.File]::WriteAllBytes($script:fixture.NUnitPath, $nunitBytes) + [System.IO.File]::WriteAllBytes($script:fixture.PesterObjectPath, $pesterObjectBytes) + + $result = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Finalize + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match '1 NotRun' + Test-Path -LiteralPath $script:fixture.ProofPath | Should -BeFalse + } + + It 'wires pack before test, Capture first, Record last, and canonical CI verification' { + $buildYaml = Get-Content -LiteralPath (Join-Path $script:repoRoot 'build.yaml') -Raw + $defaultWorkflow = [regex]::Match($buildYaml, '(?ms)^ ''\.'':.*?(?=^ build:)').Value + $testWorkflow = [regex]::Match($buildYaml, '(?ms)^ test:.*?(?=^ [A-Za-z][A-Za-z0-9_-]*:)').Value + + $defaultWorkflow | Should -Match '(?s)-\s+pack.*-\s+test' + @([regex]::Matches($testWorkflow, '(?m)^\s*-\s+Capture_Tested_Release_Proof_Candidate\s*$')).Count | Should -Be 1 + @([regex]::Matches($testWorkflow, '(?m)^\s*-\s+Pester_Tests_With_GraphKitAuth_ABI_Fixture\s*$')).Count | Should -Be 1 + @([regex]::Matches($testWorkflow, '(?m)^\s*-\s+Record_Tested_Release_Proof\s*$')).Count | Should -Be 1 + $testWorkflow.IndexOf('Capture_Tested_Release_Proof_Candidate') | Should -BeLessThan $testWorkflow.IndexOf('Pester_Tests_With_GraphKitAuth_ABI_Fixture') + $testWorkflow.IndexOf('Pester_Tests_With_GraphKitAuth_ABI_Fixture') | Should -BeLessThan $testWorkflow.IndexOf('Record_Tested_Release_Proof') + $testTaskLines = @( + $testWorkflow -split '\r?\n' | + Where-Object { $_ -match '^\s*-\s+[A-Za-z]' } + ) + $testTaskLines[-1] | Should -Match 'Record_Tested_Release_Proof\s*$' + + $authTasks = Get-Content -LiteralPath (Join-Path $script:repoRoot '.build/GraphKitAuth.tasks.ps1') -Raw + $guardedTask = [regex]::Match($authTasks, + '(?ms)^\s*task Pester_Tests_With_GraphKitAuth_ABI_Fixture \{.*?^\s*\}\s*^\}').Value + $guardedTask | Should -Match '(?s)try\s*\{.*Pester_Tests_Stop_On_Fail.*\}\s*finally\s*\{.*Remove-GraphKitAuthAbiTestFixture' + + $ci = Get-Content -LiteralPath (Join-Path $script:repoRoot '.github/workflows/ci.yml') -Raw + $ci | Should -Match 'tested-release-proof\.json' + $ci | Should -Match 'Test-GraphKitReleaseProof\.ps1' + } +} + +Describe 'Both publisher paths consume the canonical proof verifier' { + AfterEach { + if ($script:fixture) { + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force -ErrorAction SilentlyContinue + $script:fixture = $null + } + } + + It ' refuses the canonical descriptor-drift verdict before publication' -ForEach @( + @{ Publisher = 'PrivateChannel' } + @{ Publisher = 'PSGallery' } + ) { + $script:fixture = New-GraphKitReleaseProofFixture + Add-Content -LiteralPath (Join-Path $script:fixture.ModuleDir 'Data/Operations/Probe.List.psd1') -Value 'changed after test' + + $result = Invoke-GraphKitFixturePublisher -Fixture $script:fixture -Publisher $Publisher + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'Data/Operations/Probe\.List\.psd1' + $result.Output | Should -Match 'does not match the tested release proof' + } + + It ' rejects a dirty proof before publication authority is established' -ForEach @( + @{ Publisher = 'PrivateChannel' } + @{ Publisher = 'PSGallery' } + ) { + $script:fixture = New-GraphKitReleaseProofFixture -DirtySource + + $result = Invoke-GraphKitFixturePublisher -Fixture $script:fixture -Publisher $Publisher + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'non-authoritative' + } + + It 'private publication uses verifier-owned snapshots and preserves durable proof evidence' { + $script:fixture = New-GraphKitReleaseProofFixture + Install-GraphKitFixtureMutatingVerifier -Fixture $script:fixture + + $proofHashBefore = (Get-FileHash -LiteralPath $script:fixture.ProofPath -Algorithm SHA256).Hash.ToLowerInvariant() + $proofBefore = Get-Content -LiteralPath $script:fixture.ProofPath -Raw | ConvertFrom-Json + $result = Invoke-GraphKitFixturePublisher -Fixture $script:fixture -Publisher PrivateChannel + + $result.ExitCode | Should -Be 0 -Because $result.Output + $publishedPackage = Join-Path $script:fixture.Root "channel/GraphKit.$($script:fixture.Version).nupkg" + (Get-FileHash -LiteralPath $publishedPackage -Algorithm SHA256).Hash.ToLowerInvariant() | + Should -Be $proofBefore.package.sha256 + + $pin = Get-Content -LiteralPath (Join-Path $script:fixture.Root 'graphkit.pin.json') -Raw | ConvertFrom-Json + $pin.sha256.ToLowerInvariant() | Should -Be $proofBefore.package.sha256 + $pin.testProofRunId | Should -Be $proofBefore.runId + Test-Path -LiteralPath $pin.testProof -PathType Leaf | Should -BeTrue + $pin.testProof | Should -Not -Be $script:fixture.ProofPath + (Get-FileHash -LiteralPath $pin.testProof -Algorithm SHA256).Hash.ToLowerInvariant() | + Should -Be $pin.testProofSha256.ToLowerInvariant() + $pin.testProofSha256.ToLowerInvariant() | Should -Be $proofHashBefore + (Get-Content -LiteralPath $pin.testProof -Raw | ConvertFrom-Json).runId | Should -Be $proofBefore.runId + (Split-Path $pin.testProof -Leaf) | Should -Match ([regex]::Escape($pin.testProofSha256.ToLowerInvariant())) + } + + It 'gallery preflight uses verifier-owned package and manifest snapshots after original bytes mutate' { + $script:fixture = New-GraphKitReleaseProofFixture + Install-GraphKitFixtureMutatingVerifier -Fixture $script:fixture + + $result = Invoke-GraphKitFixtureGalleryPreflight -Fixture $script:fixture + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match 'PRE-FLIGHT PASSED' + (Get-Content -LiteralPath $script:fixture.PackagePath -Raw) | Should -Be 'replacement package after verifier return' + (Get-Content -LiteralPath $script:fixture.ProofPath -Raw) | Should -Be '{"replacementProof":true}' + } + + It 'gallery preflight rejects a local path in strict UTF-8 deps JSON from the verifier-owned package without disclosing it' { + $script:fixture = New-GraphKitReleaseProofFixture + $sentinel = '/Users/GraphKitPrivacyJson/private-build' + Add-GraphKitFixturePayloadText -Fixture $script:fixture ` + -EntryName 'Diagnostics/Fixture.deps.json' ` + -Content ('{"runtimeTarget":{"path":"' + $sentinel + '"}}') + Install-GraphKitFixtureMutatingVerifier -Fixture $script:fixture + + $result = Invoke-GraphKitFixtureGalleryPreflight -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 -Because $result.Output + $result.Output | Should -Match 'package carries no identifiers that must stay private' + $result.Output | Should -Match 'local user path' + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) + (Get-Content -LiteralPath $script:fixture.PackagePath -Raw) | Should -Be 'replacement package after verifier return' + } + + It 'gallery preflight fails closed when a deps JSON entry is not strict UTF-8' { + $script:fixture = New-GraphKitReleaseProofFixture + $invalidUtf8 = [byte[]] @(0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d) + Add-GraphKitFixturePayloadBytes -Fixture $script:fixture ` + -EntryName 'Diagnostics/Invalid.deps.json' ` + -Bytes $invalidUtf8 + + $result = Invoke-GraphKitFixtureGalleryPreflight -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 -Because $result.Output + $result.Output | Should -Match 'strict UTF-8' + $result.Output | Should -Not -Match ([char] 0xfffd) + + . (Join-Path $script:repoRoot 'scripts/private/Test-GraphKitPackagePrivacy.ps1') + $understatedStream = [IO.MemoryStream]::new([byte[]](1..64), $false) + try { + { + Read-GraphKitPackagePrivacyEntryBytesBounded ` + -EntryStream $understatedStream -DeclaredLength 1 + } | Should -Throw -ExpectedMessage '*declared byte count*' + $understatedStream.Position | Should -BeLessOrEqual 2 ` + -Because 'an understated ZIP entry must be rejected after at most one excess byte' + } + finally { + $understatedStream.Dispose() + } + } + + It 'gallery preflight applies every privacy category to authored CSharp without disclosing matched values' { + $script:fixture = New-GraphKitReleaseProofFixture + $privateGuid = '87f7ad68-c47e-48b4-a248-49602bc19e84' + $thumbprint = '0123456789abcdef0123456789abcdef01234567' + $localPath = 'C:\Users\GraphKitPrivacyCSharp\source.cs' + $linuxLocalPath = '/home/GraphKitPrivacyCSharp/source.cs' + $internalProject = 'IntuneHealthAutomation' + Add-GraphKitFixturePayloadText -Fixture $script:fixture ` + -EntryName 'Diagnostics/Fixture.cs' ` + -Content @" +internal static class Fixture { + private const string TenantId = "$privateGuid"; + private const string CertificateThumbprint = "$thumbprint"; + private const string SourcePath = @"$localPath"; + private const string LinuxSourcePath = "$linuxLocalPath"; + private const string Project = "$internalProject"; +} +"@ + + $result = Invoke-GraphKitFixtureGalleryPreflight -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 -Because $result.Output + $result.Output | Should -Match 'GUID that is not a well-known or package id' + $result.Output | Should -Match 'certificate thumbprint' + $result.Output | Should -Match 'local user path' + $result.Output | Should -Match 'internal project name' + foreach ($sentinel in @($privateGuid, $thumbprint, $localPath, $linuxLocalPath, $internalProject)) { + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) + } + } + + It 'gallery preflight rejects private authored GraphKit.Auth source that compilation omitted without disclosing it' { + $script:fixture = New-GraphKitReleaseProofFixture + $privateGuid = '0b7fc557-6600-4ca6-bd64-de8e4f0eb285' + $thumbprint = 'fedcba9876543210fedcba9876543210fedcba98' + $localPath = '/Users/GraphKitPrivacySource/private-build' + $internalProject = 'IntuneHealthAutomation' + Set-Content -LiteralPath (Join-Path $script:fixture.AuthSourceDir 'PrivateFixture.cs') ` + -NoNewline -Encoding utf8NoBOM -Value @" +namespace GraphKit.Auth; +internal static class PrivateFixture { + private const string TenantId = "$privateGuid"; + private const string CertificateThumbprint = "$thumbprint"; + private const string SourcePath = "$localPath"; + private const string Project = "$internalProject"; +} +"@ + + $result = Invoke-GraphKitFixtureGalleryPreflight -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 -Because $result.Output + $result.Output | Should -Match 'authored GraphKit.Auth source carries no identifiers that must stay private' + $result.Output | Should -Match 'GUID that is not a well-known or package id' + $result.Output | Should -Match 'certificate thumbprint' + $result.Output | Should -Match 'local user path' + $result.Output | Should -Match 'internal project name' + foreach ($sentinel in @($privateGuid, $thumbprint, $localPath, $internalProject)) { + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) + } + } + + It 'gallery preflight scans first-party and dependency DLL strings in ASCII and UTF-16LE without disclosing matched values' { + $script:fixture = New-GraphKitReleaseProofFixture + $asciiSentinel = '/Users/GraphKitPrivacyBinary/private-build' + $wideSentinel = 'IntuneHealthAutomation' + Add-GraphKitFixturePayloadBytes -Fixture $script:fixture ` + -EntryName 'Binary/GraphKit.Auth.dll' ` + -Bytes ([System.Text.Encoding]::ASCII.GetBytes("prefix::$asciiSentinel::suffix")) + Add-GraphKitFixturePayloadBytes -Fixture $script:fixture ` + -EntryName 'Binary/Microsoft.Identity.Client.DLL' ` + -Bytes ([System.Text.Encoding]::Unicode.GetBytes("prefix::$wideSentinel::suffix")) + + $result = Invoke-GraphKitFixtureGalleryPreflight -Fixture $script:fixture + + $result.ExitCode | Should -Not -Be 0 -Because $result.Output + $result.Output | Should -Match 'binary-ascii: local user path' + $result.Output | Should -Match 'binary-utf16le: internal project name' + $result.Output | Should -Not -Match ([regex]::Escape($asciiSentinel)) + $result.Output | Should -Not -Match ([regex]::Escape($wideSentinel)) + } + + It 'gallery preflight accepts legitimate 40-hex source and vendor revisions' { + $script:fixture = New-GraphKitReleaseProofFixture + $sourceRevision = '6aee19bc50d2cdfbdba55d6694465855c5c6fb51' + $vendorRevision = '013d71559a017f50aa4861487226c523959d1579' + Add-GraphKitFixturePayloadText -Fixture $script:fixture ` + -EntryName 'Diagnostics/Revisions.deps.json' ` + -Content ('{"sourceRevision":"' + $sourceRevision + '"}') + Add-GraphKitFixturePayloadBytes -Fixture $script:fixture ` + -EntryName 'Binary/Vendor.Dependency.dll' ` + -Bytes ([System.Text.Encoding]::ASCII.GetBytes("RepositoryCommit=$vendorRevision")) + + $result = Invoke-GraphKitFixtureGalleryPreflight -Fixture $script:fixture + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match 'PRE-FLIGHT PASSED' + } + + It 'both publisher scripts switch to verifier-owned package snapshots' { + foreach ($relativePath in @('scripts/Publish-GraphKitPackage.ps1', 'scripts/Publish-GraphKitToGallery.ps1')) { + $publisher = Get-Content -LiteralPath (Join-Path $script:repoRoot $relativePath) -Raw + $publisher | Should -Match 'VerifiedPackageCopyPath' -Because $relativePath + $publisher | Should -Match 'VerifiedProofCopyPath' -Because $relativePath + $publisher | Should -Match 'VerifiedPackagePath' -Because $relativePath + } + $privatePublisher = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/Publish-GraphKitPackage.ps1') -Raw + $galleryPublisher = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/Publish-GraphKitToGallery.ps1') -Raw + $privatePublisher | Should -Not -Match '--clobber:' + $privatePublisher | Should -Match '\$proofUploadArguments \+= ''--clobber''' + $privatePublisher | Should -Match '\$packageUploadArguments \+= ''--clobber''' + $galleryPublisher | Should -Match 'Get-Command\s+-Name\s+Test-GraphKitAuthSourcePrivacy' ` + -Because 'gallery publication must fail closed if its dot-sourced source scanner is unavailable' + $proofCopy = $privatePublisher.IndexOf( + 'Copy-Item -LiteralPath $verifiedProofSnapshot.FullName -Destination $proofTarget', + [StringComparison]::Ordinal) + $packageCopy = $privatePublisher.IndexOf( + 'Copy-Item -LiteralPath $package.FullName -Destination $target -Force', + [StringComparison]::Ordinal) + $proofCopy | Should -BeGreaterOrEqual 0 + $packageCopy | Should -BeGreaterThan $proofCopy -Because 'proof publication must finish before package discoverability' + } +} diff --git a/tests/QA/ReleaseTruth.tests.ps1 b/tests/QA/ReleaseTruth.tests.ps1 index 00de307..b8e5d96 100644 --- a/tests/QA/ReleaseTruth.tests.ps1 +++ b/tests/QA/ReleaseTruth.tests.ps1 @@ -61,10 +61,11 @@ Describe 'GraphKit current release truth' -Tag 'QA' { Assert-CurrentReleaseEvidence -Text $changelogCurrentRelease -Location 'CHANGELOG 0.3.0 release section' } - It 'preserves the immutable released manifest identity' { + It 'retains immutable release evidence while source declares the successor train seed' { $manifest = Import-PowerShellDataFile $manifestPath - [string] $manifest.ModuleVersion | Should -Be '0.3.0' - [string] $manifest.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.3\.0(?:\r?\n)' + [string] $manifest.ModuleVersion | Should -Be '0.4.0' + [string] $manifest.PrivateData.PSData.Prerelease | Should -Be 'r8' + [string] $manifest.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.4\.0(?:\r?\n)' } It 'marks the dated integration plan as executed and superseded by publication evidence' { diff --git a/tests/QA/RepositoryHygiene.Tests.ps1 b/tests/QA/RepositoryHygiene.Tests.ps1 index a83e7a2..81e7efb 100644 --- a/tests/QA/RepositoryHygiene.Tests.ps1 +++ b/tests/QA/RepositoryHygiene.Tests.ps1 @@ -29,6 +29,16 @@ Describe 'Repository hygiene' -Tag 'QA' { Get-GitIgnoreExitCode -Path 'docs/r0-marker.md' | Should -Be 1 } + It 'pins the raw-byte auth parity fixture to LF on every platform' { + $fixture = 'tests/Fixtures/GraphKitAuthParityCases.json' + Test-Path -LiteralPath (Join-Path $script:repoRoot $fixture) -PathType Leaf | Should -BeTrue + $attributes = @(& git -C $script:repoRoot check-attr text eol -- $fixture) + + $LASTEXITCODE | Should -Be 0 + $attributes | Should -Contain "$fixture`: text: set" + $attributes | Should -Contain "$fixture`: eol: lf" + } + It 'pins every declared dependency to an exact version' { $dependencies = Import-PowerShellDataFile -Path (Join-Path $script:repoRoot 'RequiredModules.psd1') $unversioned = @( diff --git a/tests/QA/SourceHygiene.tests.ps1 b/tests/QA/SourceHygiene.tests.ps1 index 96f44ea..3a44702 100644 --- a/tests/QA/SourceHygiene.tests.ps1 +++ b/tests/QA/SourceHygiene.tests.ps1 @@ -10,6 +10,238 @@ BeforeAll { $script:sourceFiles = @( Get-ChildItem -Path (Join-Path $script:repoRoot 'source') -Recurse -File -Include '*.ps1', '*.psd1', '*.psm1', '*.ps1xml' ) + . (Join-Path $script:repoRoot 'scripts/private/Test-GraphKitPackagePrivacy.ps1') +} + +Describe 'GraphKit.Auth authored project-source privacy' { + + It 'passes the reusable strict source privacy scan for every authored project file' { + $authSourceRoot = Join-Path $script:repoRoot 'src/GraphKit.Auth' + $authoredExtensions = @('.cs', '.csproj', '.props', '.sln', '.json') + $expectedSourceFiles = @( + Get-ChildItem -LiteralPath $authSourceRoot -Recurse -File -Force | + Where-Object { + $_.Extension -iin $authoredExtensions -and + $_.FullName -notmatch '[\\/](?:bin|obj)[\\/]' + } + ) + $result = Test-GraphKitAuthSourcePrivacy ` + -SourceRoot $authSourceRoot ` + -ModuleGuid ([guid] (Import-PowerShellDataFile (Join-Path $script:repoRoot 'source/GraphKit.psd1')).GUID) + + $result.Passed | Should -BeTrue + $expectedSourceFiles.Count | Should -BeGreaterThan 0 + $result.SourceFilesScanned | Should -Be $expectedSourceFiles.Count + @($result.Findings).Count | Should -Be 0 + } + + It 'fails closed for invalid project-metadata encoding or an unapproved identifier' { + $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-auth-source-privacy-' + [guid]::NewGuid().ToString('N')) + try { + New-Item -ItemType Directory -Path $fixtureRoot -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $fixtureRoot 'Valid.cs'), 'internal class Valid {}') + [System.IO.File]::WriteAllBytes( + (Join-Path $fixtureRoot 'Invalid.props'), + [byte[]] @(0x3c, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0xc3, 0x28) + ) + + { + Test-GraphKitAuthSourcePrivacy -SourceRoot $fixtureRoot -ModuleGuid ([guid]::Empty) + } | Should -Throw '*strict UTF-8*' + + [System.IO.File]::WriteAllText( + (Join-Path $fixtureRoot 'Invalid.props'), + '01234567-89ab-4cde-8f01-23456789abcd' + ) + $result = Test-GraphKitAuthSourcePrivacy -SourceRoot $fixtureRoot -ModuleGuid ([guid]::Empty) + $result.Passed | Should -BeFalse + @($result.Findings).Category | Should -Contain 'GUID that is not a well-known or package id' + } + finally { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'never treats an RFC-versioned repeated-segment GUID as a placeholder' { + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet -ModuleGuid ([guid]::Empty) + + foreach ($version in @('1', '2', '3', '4', '5', '6', '7', '8')) { + foreach ($variant in @('8', '9', 'a', 'b')) { + $candidate = "11111111-2222-$version$version$version$version-$variant$variant$variant$variant-555555555555" + Test-GraphKitPackagePrivacyPlaceholderGuid -Value $candidate | Should -BeFalse + $findings = [System.Collections.Generic.List[object]]::new() + $findingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + Test-GraphKitPackagePrivacyText ` + -Text $candidate ` + -EntryName 'fixture.txt' ` + -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids ` + -Findings $findings ` + -FindingKeys $findingKeys + @($findings).Count | Should -Be 1 + $findings[0].Category | + Should -BeExactly 'GUID that is not a well-known or package id' + } + } + + $placeholder = '11111111-2222-4444-4444-555555555555' + Test-GraphKitPackagePrivacyPlaceholderGuid ` + -Value $placeholder | + Should -BeTrue + $placeholderFindings = [System.Collections.Generic.List[object]]::new() + $placeholderFindingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + Test-GraphKitPackagePrivacyText ` + -Text $placeholder ` + -EntryName 'fixture.txt' ` + -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids ` + -Findings $placeholderFindings ` + -FindingKeys $placeholderFindingKeys + @($placeholderFindings).Count | Should -Be 0 + } + + It 'detects a hashed protected token embedded in a longer hyphenated identifier' { + $realDigest = (Get-Command -Name Get-GraphKitPackagePrivacyDigest).ScriptBlock + Mock Get-GraphKitPackagePrivacyDigest { + if ($Value -ceq 'synthetic-protected-token') { + return '5cad5cdbf022740cbfc976f9836ac89d00000000000000000000000000000000' + } + return (& $realDigest -Value $Value) + } + + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet -ModuleGuid ([guid]::Empty) + $findings = [System.Collections.Generic.List[object]]::new() + $findingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + + Test-GraphKitPackagePrivacyText ` + -Text 'prefix-synthetic-protected-token-suffix' ` + -EntryName 'fixture.txt' ` + -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids ` + -Findings $findings ` + -FindingKeys $findingKeys + + @($findings).Count | Should -Be 1 + $findings[0].Category | Should -BeExactly 'internal identifier - customer name (A)' + + $overBoundFindings = [System.Collections.Generic.List[object]]::new() + $overBoundFindingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + Test-GraphKitPackagePrivacyText ` + -Text ((1..33 | ForEach-Object { "segment$_" }) -join '-') ` + -EntryName 'fixture.txt' ` + -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids ` + -Findings $overBoundFindings ` + -FindingKeys $overBoundFindingKeys + + @($overBoundFindings).Count | Should -Be 1 + $overBoundFindings[0].Category | + Should -BeExactly 'hyphenated identifier exceeds bounded privacy scan' + } + + It 'detects an unapproved wrapped GUID and permits only digest-approved vendor metadata' { + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet -ModuleGuid ([guid]::Empty) + $findings = [System.Collections.Generic.List[object]]::new() + $findingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + + Test-GraphKitPackagePrivacyText ` + -Text 'prefixx87f7ad68-c47e-48b4-a248-49602bc19e84ysuffix' ` + -EntryName 'fixture.txt' ` + -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids ` + -Findings $findings ` + -FindingKeys $findingKeys + + @($findings).Count | Should -Be 1 + $findings[0].Category | Should -BeExactly 'GUID that is not a well-known or package id' + + $realDigest = (Get-Command -Name Get-GraphKitPackagePrivacyDigest).ScriptBlock + Mock Get-GraphKitPackagePrivacyDigest { + if ($Value -ceq '87f7ad68-c47e-48b4-a248-49602bc19e84') { + return '391ab33fdbbec5d86574ef81ce268caffeccdc6ea36e7940358e4ded01294842' + } + return (& $realDigest -Value $Value) + } + $vendorFindings = [System.Collections.Generic.List[object]]::new() + $vendorFindingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + + Test-GraphKitPackagePrivacyText ` + -Text 'prefixx87f7ad68-c47e-48b4-a248-49602bc19e84ysuffix' ` + -EntryName 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' ` + -Encoding 'binary-utf16le' ` + -AllowedGuids $allowedGuids ` + -Findings $vendorFindings ` + -FindingKeys $vendorFindingKeys + + @($vendorFindings).Count | Should -Be 0 + + $sourceFindings = [System.Collections.Generic.List[object]]::new() + $sourceFindingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + Test-GraphKitPackagePrivacyText ` + -Text 'prefixx87f7ad68-c47e-48b4-a248-49602bc19e84ysuffix' ` + -EntryName 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' ` + -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids ` + -Findings $sourceFindings ` + -FindingKeys $sourceFindingKeys + + @($sourceFindings).Count | Should -Be 1 + $sourceFindings[0].Category | + Should -BeExactly 'GUID that is not a well-known or package id' + + $wrongEntryFindings = [System.Collections.Generic.List[object]]::new() + $wrongEntryFindingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + Test-GraphKitPackagePrivacyText ` + -Text 'prefixx87f7ad68-c47e-48b4-a248-49602bc19e84ysuffix' ` + -EntryName 'Assemblies/GraphKit.Auth/Unexpected.dll' ` + -Encoding 'binary-utf16le' ` + -AllowedGuids $allowedGuids ` + -Findings $wrongEntryFindings ` + -FindingKeys $wrongEntryFindingKeys + + @($wrongEntryFindings).Count | Should -Be 1 + $wrongEntryFindings[0].Category | + Should -BeExactly 'GUID that is not a well-known or package id' + + $wrongGuidFindings = [System.Collections.Generic.List[object]]::new() + $wrongGuidFindingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + Test-GraphKitPackagePrivacyText ` + -Text 'prefixx89abcdef-0123-4abc-8def-0123456789abysuffix' ` + -EntryName 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' ` + -Encoding 'binary-utf16le' ` + -AllowedGuids $allowedGuids ` + -Findings $wrongGuidFindings ` + -FindingKeys $wrongGuidFindingKeys + + @($wrongGuidFindings).Count | Should -Be 1 + $wrongGuidFindings[0].Category | + Should -BeExactly 'GUID that is not a well-known or package id' + } + + It 'fails closed when protected-token candidate generation reaches its fixed bound' { + $runs = @( + foreach ($runIndex in 0..15) { + (@(0..31 | ForEach-Object { "r${runIndex}s$_" }) -join '-') + } + ) + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet -ModuleGuid ([guid]::Empty) + $findings = [System.Collections.Generic.List[object]]::new() + $findingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + + Test-GraphKitPackagePrivacyText ` + -Text ($runs -join ' ') ` + -EntryName 'fixture.txt' ` + -Encoding 'strict-utf8' ` + -AllowedGuids $allowedGuids ` + -Findings $findings ` + -FindingKeys $findingKeys + + @($findings).Count | Should -Be 1 + $findings[0].Category | + Should -BeExactly 'protected-token candidate limit exceeded' + $findings[0].EvidenceSha256 | + Should -BeExactly (Get-GraphKitPackagePrivacyDigest -Value '8192') + } } Describe 'Source hygiene' { @@ -76,7 +308,7 @@ Describe 'Source hygiene' { # repositories, and hiding them would cost readability for no privacy gain. $patterns = @{ 'internal project name' = '(?i)\bivy24\b|\bIntuneHealthAutomation\b' - 'local user path' = '/Users/[A-Za-z0-9._-]+|C:\\Users\\[A-Za-z0-9._-]+' + 'local user path' = '/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|C:\\Users\\[A-Za-z0-9._-]+' } function Get-TokenDigest { diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 new file mode 100644 index 0000000..89295d6 --- /dev/null +++ b/tests/QA/TrainVersion.tests.ps1 @@ -0,0 +1,1335 @@ +BeforeAll { + $script:versionScript = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath 'scripts/Get-GraphKitTrainVersion.ps1' + $script:sourceCaptureHelper = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath 'scripts/private/GraphKit.SourceCapture.cs' + + function New-R8TrainVersionFixture { + $root = Join-Path $TestDrive ('source-state-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path (Join-Path $root 'source/Private') -Force + Set-Content -LiteralPath (Join-Path $root '.gitignore') -Value "output/`n.git-order`n" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked-One.ps1') -Value "'one'`n" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked-Two.ps1') -Value "'two'`n" -NoNewline -Encoding utf8NoBOM + & git -C $root init --quiet + & git -C $root config core.autocrlf false + & git -C $root add .gitignore source + & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'fixture' + return $root + } + + function Get-R8TrainVersion { + param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [string] $VersionScript = $script:versionScript + ) + + $output = & pwsh -NoLogo -NoProfile -File $VersionScript -RepositoryRoot $RepositoryRoot 2>&1 | Out-String + [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output.Trim() + } + } + + function Invoke-R8Bootstrap { + param([Parameter(Mandatory)] [string] $Content) + + $bootstrap = Join-Path $TestDrive ('r8-bootstrap-' + [guid]::NewGuid().ToString('N') + '.ps1') + Set-Content -LiteralPath $bootstrap -Value $Content -NoNewline -Encoding utf8NoBOM + $output = & pwsh -NoLogo -NoProfile -File $bootstrap 2>&1 | Out-String + [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output.Trim() + } + } + + function Get-R8TrainVersionWithTimeout { + param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [int] $TimeoutMilliseconds, + [string] $VersionScript = $script:versionScript + ) + + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = 'pwsh' + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $null = $start.ArgumentList.Add('-NoLogo') + $null = $start.ArgumentList.Add('-NoProfile') + $null = $start.ArgumentList.Add('-File') + $null = $start.ArgumentList.Add($VersionScript) + $null = $start.ArgumentList.Add('-RepositoryRoot') + $null = $start.ArgumentList.Add($RepositoryRoot) + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + try { + $null = $process.Start() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $timedOut = -not $process.WaitForExit($TimeoutMilliseconds) + if ($timedOut) { + try { + $process.Kill($true) + } + catch [System.InvalidOperationException] { + # The process exited between the bounded wait and kill. + } + $process.WaitForExit() + } + $output = ($stdoutTask.GetAwaiter().GetResult() + + $stderrTask.GetAwaiter().GetResult()).Trim() + if ($timedOut) { + return [pscustomobject] @{ Running = $true; ExitCode = $null; Output = $output } + } + return [pscustomobject] @{ + Running = $false + ExitCode = $process.ExitCode + Output = $output + } + } + finally { + $process.Dispose() + } + } + + function New-R8PortableGitShim { + param( + [Parameter(Mandatory)] [ValidateSet( + 'duplicate-index', + 'appearance', + 'mutation', + 'head-move', + 'invalid-tree-oid', + 'sha256-format', + 'missing', + 'invalid-path', + 'unmerged-stage', + 'helper-case-alias', + 'case-collision', + 'normalization-collision', + 'stderr-flood', + 'stdin-stdout-flood', + 'reverse-untracked' + )] [string] $Mode, + [hashtable] $Configuration = @{} + ) + + $shimDirectory = Join-Path $TestDrive ("portable-git-shim-$Mode-" + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $shimDirectory -Force + $payload = @{ + Mode = $Mode + RealGit = @((Get-Command git -CommandType Application))[0].Source + InvocationLog = Join-Path $shimDirectory 'invocations.log' + Configuration = $Configuration + } | ConvertTo-Json -Compress -Depth 5 + $encodedPayload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload)) + $shimScript = Join-Path $shimDirectory 'git-shim.ps1' + Set-Content -LiteralPath $shimScript -NoNewline -Encoding utf8NoBOM -Value (@' +$ErrorActionPreference = 'Stop' +$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PAYLOAD__')) | ConvertFrom-Json +$gitArguments = @($args) +[IO.File]::AppendAllText( + [string] $payload.InvocationLog, + (($gitArguments | ConvertTo-Json -Compress) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false) +) + +function Invoke-RealGit([string[]] $Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $payload.RealGit + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $Arguments) { $null = $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::new(); $process.StartInfo = $start; $null = $process.Start() + $output = [IO.MemoryStream]::new(); $process.StandardOutput.BaseStream.CopyTo($output) + $errorText = $process.StandardError.ReadToEnd(); $process.WaitForExit() + [pscustomobject] @{ ExitCode=$process.ExitCode; Output=$output.ToArray(); Error=$errorText } +} + +function Write-Result($Result) { + $stdout = [Console]::OpenStandardOutput(); $stdout.Write($Result.Output, 0, $Result.Output.Length) + if ($Result.Error) { [Console]::Error.Write($Result.Error) } + exit $Result.ExitCode +} + +$isStage = $gitArguments.Count -ge 2 -and $gitArguments[0] -eq 'ls-files' -and $gitArguments[1] -eq '--stage' +$isOthers = $gitArguments.Count -ge 1 -and $gitArguments[0] -eq 'ls-files' -and $gitArguments -contains '--others' +$isTree = $gitArguments.Count -ge 1 -and $gitArguments[0] -eq 'ls-tree' +$isObjectFormat = $gitArguments.Count -ge 2 -and $gitArguments[0] -eq 'rev-parse' -and $gitArguments[1] -eq '--show-object-format' +$isCheckIgnore = $gitArguments.Count -ge 1 -and $gitArguments[0] -eq 'check-ignore' +switch ($payload.Mode) { + 'duplicate-index' { + $result = Invoke-RealGit $gitArguments + if ($isStage -and $result.ExitCode -eq 0) { + $joined = [byte[]]::new($result.Output.Length * 2) + [Array]::Copy($result.Output, 0, $joined, 0, $result.Output.Length) + [Array]::Copy($result.Output, 0, $joined, $result.Output.Length, $result.Output.Length) + $result.Output = $joined + } + Write-Result $result + } + 'appearance' { + if ($isOthers) { + if (-not [IO.File]::Exists($payload.Configuration.Counter)) { + [IO.File]::WriteAllBytes($payload.Configuration.Counter, [byte[]] @()) + Write-Result ([pscustomobject] @{ ExitCode=0; Output=[byte[]] @(); Error='' }) + } + [IO.File]::WriteAllText($payload.Configuration.Path, "'appeared'`n", [Text.UTF8Encoding]::new($false)) + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'mutation' { + if ($isOthers) { + if ([IO.File]::Exists($payload.Configuration.Counter)) { + [IO.File]::WriteAllText($payload.Configuration.Path, "'mutated after read'`n", [Text.UTF8Encoding]::new($false)) + } else { + [IO.File]::WriteAllBytes($payload.Configuration.Counter, [byte[]] @()) + } + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'head-move' { + if ($isOthers -and -not [IO.File]::Exists($payload.Configuration.Counter)) { + [IO.File]::WriteAllBytes($payload.Configuration.Counter, [byte[]] @()) + $move = Invoke-RealGit @('-C', $payload.Configuration.Root, 'update-ref', 'HEAD', $payload.Configuration.Revision) + if ($move.ExitCode -ne 0) { Write-Result $move } + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'invalid-tree-oid' { + $result = Invoke-RealGit $gitArguments + if ($isTree -and $result.ExitCode -eq 0) { + $text = [Text.Encoding]::Latin1.GetString($result.Output) + $text = [Text.RegularExpressions.Regex]::Replace($text, '(?<=blob )[0-9a-f]{40}', { param($match) $match.Value + '0' }, 1) + $result.Output = [Text.Encoding]::Latin1.GetBytes($text) + } + Write-Result $result + } + 'sha256-format' { + if ($isObjectFormat) { + Write-Result ([pscustomobject] @{ ExitCode=0; Output=[Text.Encoding]::ASCII.GetBytes("sha256`n"); Error='' }) + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'missing' { + if ($isOthers) { + $bytes = [Text.Encoding]::UTF8.GetBytes('source/Private/disappeared.ps1') + Write-Result ([pscustomobject] @{ ExitCode=0; Output=[byte[]] @($bytes + [byte] 0); Error='' }) + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'invalid-path' { + if ($isOthers) { + Write-Result ([pscustomobject] @{ ExitCode=0; Output=[byte[]] @(255, 0); Error='' }) + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'unmerged-stage' { + $result = Invoke-RealGit $gitArguments + if ($isStage -and $result.ExitCode -eq 0) { + $text = [Text.Encoding]::Latin1.GetString($result.Output) + $text = [Text.RegularExpressions.Regex]::Replace( + $text, + '(?<= [0-9a-f]{40} )0(?=\t)', + [string] $payload.Configuration.Stage, + 1 + ) + $result.Output = [Text.Encoding]::Latin1.GetBytes($text) + } + Write-Result $result + } + 'helper-case-alias' { + if ($isCheckIgnore) { + $inputBytes = [IO.MemoryStream]::new() + [Console]::OpenStandardInput().CopyTo($inputBytes) + Write-Result ([pscustomobject] @{ ExitCode=0; Output=$inputBytes.ToArray(); Error='' }) + } + $result = Invoke-RealGit $gitArguments + if (($isTree -or $isStage) -and $result.ExitCode -eq 0) { + $text = [Text.Encoding]::Latin1.GetString($result.Output) + $result.Output = [Text.Encoding]::Latin1.GetBytes( + $text.Replace( + [string] $payload.Configuration.CanonicalPath, + [string] $payload.Configuration.AliasPath + ) + ) + } + Write-Result $result + } + 'case-collision' { + $result = Invoke-RealGit $gitArguments + if ($isStage -and $result.ExitCode -eq 0) { + $all = [Text.Encoding]::Latin1.GetString($result.Output) + $pathOffset = $all.IndexOf('Tracked-One.ps1', [StringComparison]::Ordinal) + if ($pathOffset -lt 0) { throw 'The case-collision shim could not find its tracked fixture path.' } + $recordStart = $all.LastIndexOf([char] 0, $pathOffset) + 1 + $recordEnd = $all.IndexOf([char] 0, $pathOffset) + $record = $all.Substring($recordStart, $recordEnd - $recordStart) + $alias = [Text.Encoding]::Latin1.GetBytes($record.Replace('Tracked-One.ps1', 'tracked-one.ps1')) + $joined = [byte[]]::new($result.Output.Length + $alias.Length + 1) + [Array]::Copy($result.Output, 0, $joined, 0, $result.Output.Length) + [Array]::Copy($alias, 0, $joined, $result.Output.Length, $alias.Length) + $joined[$joined.Length - 1] = 0 + $result.Output = $joined + } + Write-Result $result + } + 'normalization-collision' { + $result = Invoke-RealGit $gitArguments + if ($isStage -and $result.ExitCode -eq 0) { + $tab = [Array]::IndexOf($result.Output, [byte] 9) + $header = [Text.Encoding]::ASCII.GetString($result.Output, 0, $tab + 1) + $first = [Text.Encoding]::UTF8.GetBytes($header + "source/Private/Caf$([char]0x00e9).ps1") + $second = [Text.Encoding]::UTF8.GetBytes($header + "source/Private/Cafe$([char]0x0301).ps1") + $joined = [byte[]]::new($result.Output.Length + $first.Length + $second.Length + 2) + [Array]::Copy($result.Output, 0, $joined, 0, $result.Output.Length) + $offset = $result.Output.Length + [Array]::Copy($first, 0, $joined, $offset, $first.Length); $offset += $first.Length + 1 + [Array]::Copy($second, 0, $joined, $offset, $second.Length) + $result.Output = $joined + } + Write-Result $result + } + 'stderr-flood' { + if ($isObjectFormat) { + [Console]::Error.Write([string]::new([char] 'x', 1MB)) + Write-Result ([pscustomobject] @{ + ExitCode = 0 + Output = [Text.Encoding]::ASCII.GetBytes("sha1`n") + Error = '' + }) + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'stdin-stdout-flood' { + if ($isCheckIgnore) { + $stdout = [Console]::OpenStandardOutput() + $padding = [Text.Encoding]::ASCII.GetBytes(([string]::new([char] 'x', 8192)) + [char] 0) + foreach ($index in 1..128) { + $stdout.Write($padding, 0, $padding.Length) + } + $stdout.Flush() + + $inputBytes = [IO.MemoryStream]::new() + [Console]::OpenStandardInput().CopyTo($inputBytes) + $capturedInput = $inputBytes.ToArray() + $stdout.Write($capturedInput, 0, $capturedInput.Length) + $stdout.Flush() + exit 0 + } + Write-Result (Invoke-RealGit $gitArguments) + } + 'reverse-untracked' { + $result = Invoke-RealGit $gitArguments + if ($isOthers -and $result.ExitCode -eq 0) { + $records = [Collections.Generic.List[byte[]]]::new() + $offset = 0 + while ($offset -lt $result.Output.Length) { + $end = [Array]::IndexOf($result.Output, [byte] 0, $offset) + $record = [byte[]]::new($end - $offset) + [Array]::Copy($result.Output, $offset, $record, 0, $record.Length) + $records.Add($record); $offset = $end + 1 + } + $stream = [IO.MemoryStream]::new() + for ($index = $records.Count - 1; $index -ge 0; $index--) { + $stream.Write($records[$index], 0, $records[$index].Length); $stream.WriteByte(0) + } + $result.Output = $stream.ToArray() + } + Write-Result $result + } +} +'@).Replace('__PAYLOAD__', $encodedPayload) + + if ($IsWindows) { + $launcherTemplate = Join-Path $TestDrive 'r8-git-shim-launcher.exe' + if (-not (Test-Path -LiteralPath $launcherTemplate -PathType Leaf)) { + $compiler = @( + Join-Path $env:WINDIR 'Microsoft.NET/Framework64/v4.0.30319/csc.exe' + Join-Path $env:WINDIR 'Microsoft.NET/Framework/v4.0.30319/csc.exe' + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $compiler) { + throw 'The Windows test host has no executable-compatible C# compiler for the Git shim launcher.' + } + $launcherSource = Join-Path $TestDrive 'r8-git-shim-launcher.cs' + Set-Content -LiteralPath $launcherSource -NoNewline -Encoding utf8NoBOM -Value @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +internal static class GitShimLauncher +{ + private static string Quote(string value) + { + var builder = new StringBuilder(); + builder.Append('"'); + int backslashes = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashes++; + continue; + } + if (character == '"') + { + builder.Append('\\', backslashes * 2 + 1); + builder.Append('"'); + backslashes = 0; + continue; + } + builder.Append('\\', backslashes); + backslashes = 0; + builder.Append(character); + } + builder.Append('\\', backslashes * 2); + builder.Append('"'); + return builder.ToString(); + } + + public static int Main(string[] arguments) + { + try + { + string shim = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "git-shim.ps1"); + var forwarded = new List { "-NoLogo", "-NoProfile", "-File", shim }; + forwarded.AddRange(arguments); + var quoted = new List(); + foreach (string argument in forwarded) quoted.Add(Quote(argument)); + var start = new ProcessStartInfo + { + FileName = "pwsh.exe", + Arguments = string.Join(" ", quoted.ToArray()), + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using (Process process = Process.Start(start)) + { + Task input = Task.Run(() => + { + Console.OpenStandardInput().CopyTo(process.StandardInput.BaseStream); + process.StandardInput.Close(); + }); + Task output = Task.Run(() => process.StandardOutput.BaseStream.CopyTo(Console.OpenStandardOutput())); + Task error = Task.Run(() => process.StandardError.BaseStream.CopyTo(Console.OpenStandardError())); + process.WaitForExit(); + Task.WaitAll(input, output, error); + return process.ExitCode; + } + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 127; + } + } +} +'@ + $compilerOutput = & $compiler /nologo /target:exe "/out:$launcherTemplate" $launcherSource 2>&1 + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $launcherTemplate -PathType Leaf)) { + throw "The Windows Git shim launcher did not compile: $($compilerOutput | Out-String)" + } + } + Copy-Item -LiteralPath $launcherTemplate -Destination (Join-Path $shimDirectory 'git.exe') + } else { + $launcher = Join-Path $shimDirectory 'git' + Set-Content -LiteralPath $launcher -NoNewline -Encoding utf8NoBOM -Value "#!/bin/sh`nexec pwsh -NoLogo -NoProfile -File '$shimScript' `"`$@`"`n" + & /bin/chmod +x $launcher + } + return $shimDirectory + } + + function Assert-R8PortableGitShimInvoked { + param([Parameter(Mandatory)] [string] $ShimDirectory) + + $invocationLog = Join-Path $ShimDirectory 'invocations.log' + $invocationLog | Should -Exist -Because 'every injected case must prove the executable shim handled the Git call' + (Get-Content -LiteralPath $invocationLog -Raw) | Should -Not -BeNullOrEmpty + } + + function Initialize-R8SourceCaptureHelper { + $script:sourceCaptureHelper | Should -Exist -Because 'the build-time source capture must be independently testable' + if (-not $script:sourceCaptureType) { + $source = Get-Content -LiteralPath $script:sourceCaptureHelper -Raw + $marker = '__GRAPHKIT_SOURCE_CAPTURE_NAMESPACE__' + if ($source.Contains($marker)) { + $namespace = 'GraphKit.R8.QA.N' + [guid]::NewGuid().ToString('N') + $types = @(Add-Type -TypeDefinition $source.Replace($marker, $namespace) -PassThru) + $sourceCaptureMatches = @($types | Where-Object FullName -CEQ "$namespace.SourceCapture") + if ($sourceCaptureMatches.Count -ne 1) { + throw 'The GraphKit source-capture helper did not load exactly once.' + } + $script:sourceCaptureType = $sourceCaptureMatches[0] + } + else { + if (-not ('GraphKit.R8.SourceCapture' -as [type])) { + Add-Type -Path $script:sourceCaptureHelper + } + $script:sourceCaptureType = 'GraphKit.R8.SourceCapture' -as [type] + } + } + return $script:sourceCaptureType + } + + function New-R8ControlledIdentityFixture { + $root = New-R8TrainVersionFixture + $scripts = Join-Path $root 'scripts' + $private = Join-Path $scripts 'private' + $null = New-Item -ItemType Directory -Path $private -Force + $versionScript = Join-Path $scripts 'Get-GraphKitTrainVersion.ps1' + $helper = Join-Path $private 'GraphKit.SourceCapture.cs' + $versionSource = (Get-Content -LiteralPath $script:versionScript -Raw).Replace("`r`n", "`n") + Set-Content -LiteralPath $versionScript -Value $versionSource -NoNewline -Encoding utf8NoBOM + $source = (Get-Content -LiteralPath $script:sourceCaptureHelper -Raw).Replace("`r`n", "`n") + $needle = 'return new CapturedSourceFile(before.Mode, before.HasExecutableMode, before.Identity, before.Length, content);' + if (-not $source.Contains($needle)) { throw 'The controlled-identity fixture could not locate the capture return contract.' } + $replacement = (@' +string proofIdentity = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE_IDENTITY") ?? before.Identity; + return new CapturedSourceFile(before.Mode, before.HasExecutableMode, proofIdentity, before.Length, content); +'@).Replace("`r`n", "`n") + Set-Content -LiteralPath $helper -Value $source.Replace($needle, $replacement) -NoNewline -Encoding utf8NoBOM + & git -C $root add scripts + & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'controlled helper' + Set-Content -LiteralPath (Join-Path $root 'source/Private/Untracked.ps1') -Value "'untracked'`n" -NoNewline -Encoding utf8NoBOM + [pscustomobject] @{ Root = $root; VersionScript = $versionScript } + } + + function New-R8InternalHelperFixture { + param([switch] $CaptureSentinel) + + $root = New-R8TrainVersionFixture + $scripts = Join-Path $root 'scripts' + $private = Join-Path $scripts 'private' + $null = New-Item -ItemType Directory -Path $private -Force + $versionScript = Join-Path $scripts 'Get-GraphKitTrainVersion.ps1' + $helper = Join-Path $private 'GraphKit.SourceCapture.cs' + Copy-Item -LiteralPath $script:versionScript -Destination $versionScript + Copy-Item -LiteralPath $script:sourceCaptureHelper -Destination $helper + if ($CaptureSentinel) { + $source = (Get-Content -LiteralPath $helper -Raw).Replace("`r`n", "`n") + $needle = (@' + public static CapturedSourceFile Capture(string repositoryRoot, string relativePath) + { +'@).Replace("`r`n", "`n") + $replacement = (@' + public static CapturedSourceFile Capture(string repositoryRoot, string relativePath) + { + string? captureSentinel = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE_SENTINEL"); + if (!string.IsNullOrEmpty(captureSentinel)) + { + File.AppendAllText(captureSentinel, relativePath + Environment.NewLine); + } +'@).Replace("`r`n", "`n") + if (-not $source.Contains($needle)) { throw 'The proof-bound sentinel fixture could not locate the generated Capture entry point.' } + Set-Content -LiteralPath $helper -Value $source.Replace($needle, $replacement) -NoNewline -Encoding utf8NoBOM + } + & git -C $root add scripts + & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'proof-bound helper' + [pscustomobject] @{ Root = $root; VersionScript = $versionScript; Helper = $helper } + } + + function New-R8ProofBoundCaptureSentinelFixture { + New-R8InternalHelperFixture -CaptureSentinel + } + + function New-R8RepositoryRootAlias { + param( + [Parameter(Mandatory)] [string] $Target, + [Parameter(Mandatory)] [string] $Alias + ) + + if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $Alias -Target $Target + } + else { + $null = New-Item -ItemType SymbolicLink -Path $Alias -Target $Target + } + + return (Get-Item -LiteralPath $Alias -Force).FullName + } + + $script:ambientCaptureSource = @' +using System; +using System.IO; + +namespace GraphKit.R8 +{ + public sealed class CapturedSourceFile + { + public string Mode => "100644"; + public bool HasExecutableMode => false; + public string Identity => "ambient:malicious"; + public long Length => 0; + public byte[] Content => Array.Empty(); + } + + public static class SourceCapture + { + public static string ResolveEffectiveGitMode(string capturedMode, bool hasExecutableMode, string indexMode) => indexMode ?? "100644"; + + public static CapturedSourceFile Capture(string repositoryRoot, string relativePath) + { + string sentinel = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE_SENTINEL"); + if (!string.IsNullOrEmpty(sentinel)) File.WriteAllText(sentinel, relativePath); + throw new InvalidOperationException("ambient helper invoked"); + } + } +} +'@ +} + +Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { + It 'provides a directly executable Git shim and records interception instead of falling through to real Git' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode reverse-untracked + $launcher = Join-Path $shimDirectory $(if ($IsWindows) { 'git.exe' } else { 'git' }) + $invocationLog = Join-Path $shimDirectory 'invocations.log' + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $launcher + $start.WorkingDirectory = $root + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $null = $start.ArgumentList.Add('rev-parse') + $null = $start.ArgumentList.Add('--show-object-format') + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + + $null = $process.Start() + $process.StandardInput.Close() + $output = $process.StandardOutput.ReadToEnd() + $errorText = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + $process.ExitCode | Should -Be 0 -Because $errorText + $output.Trim() | Should -Be 'sha1' + $invocationLog | Should -Exist -Because 'the injected process must prove the shim, not a PATH-resolved real Git, handled the call' + (Get-Content -LiteralPath $invocationLog -Raw) | Should -Match 'rev-parse.*--show-object-format' + + $floodShim = New-R8PortableGitShim -Mode stderr-flood + $savedPath = $env:PATH + try { + $env:PATH = "$floodShim$([IO.Path]::PathSeparator)$savedPath" + $floodResult = Get-R8TrainVersionWithTimeout -RepositoryRoot $root -TimeoutMilliseconds 30000 + } + finally { + $env:PATH = $savedPath + } + Assert-R8PortableGitShimInvoked -ShimDirectory $floodShim + $floodResult.Running | Should -BeFalse ` + -Because 'stdout and stderr must drain concurrently even when stderr exceeds the pipe buffer' + $floodResult.ExitCode | Should -Be 0 -Because $floodResult.Output + $floodResult.Output | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}$' + + $parentFloodScript = Join-Path $TestDrive 'parent-process-stream-flood.ps1' + Set-Content -LiteralPath $parentFloodScript -NoNewline -Encoding utf8NoBOM -Value @' +param([string] $RepositoryRoot) +[Console]::Out.Write([string]::new('o', 131072)) +[Console]::Error.Write([string]::new('e', 131072)) +'@ + $parentFloodResult = Get-R8TrainVersionWithTimeout -RepositoryRoot $root ` + -TimeoutMilliseconds 5000 -VersionScript $parentFloodScript + $parentFloodResult.Running | Should -BeFalse ` + -Because 'the timeout helper must drain both redirected streams before waiting for child exit' + $parentFloodResult.ExitCode | Should -Be 0 + $parentFloodResult.Output.Length | Should -BeGreaterThan 200000 + + $timeoutOutputScript = Join-Path $TestDrive 'parent-process-timeout-output.ps1' + Set-Content -LiteralPath $timeoutOutputScript -NoNewline -Encoding utf8NoBOM -Value @' +param([string] $RepositoryRoot) +[Console]::Out.Write('captured-before-timeout') +[Console]::Out.Flush() +Start-Sleep -Seconds 30 +'@ + $timeoutOutputResult = Get-R8TrainVersionWithTimeout -RepositoryRoot $root ` + -TimeoutMilliseconds 3000 -VersionScript $timeoutOutputScript + $timeoutOutputResult.Running | Should -BeTrue + $timeoutOutputResult.ExitCode | Should -BeNullOrEmpty + $timeoutOutputResult.Output | Should -Match 'captured-before-timeout' + + $bidirectionalRoot = New-R8TrainVersionFixture + $ignoredRoot = Join-Path $bidirectionalRoot 'output' + $null = New-Item -ItemType Directory -Path $ignoredRoot -Force + foreach ($index in 1..1024) { + $name = 'ignored-{0:D4}-{1}.tmp' -f $index, ([string]::new([char] 'y', 80)) + [IO.File]::WriteAllBytes((Join-Path $ignoredRoot $name), [byte[]] @(1)) + } + $bidirectionalShim = New-R8PortableGitShim -Mode stdin-stdout-flood + $savedPath = $env:PATH + try { + $env:PATH = "$bidirectionalShim$([IO.Path]::PathSeparator)$savedPath" + $bidirectionalResult = Get-R8TrainVersionWithTimeout ` + -RepositoryRoot $bidirectionalRoot -TimeoutMilliseconds 30000 + } + finally { + $env:PATH = $savedPath + } + Assert-R8PortableGitShimInvoked -ShimDirectory $bidirectionalShim + $bidirectionalResult.Running | Should -BeFalse ` + -Because 'Git stdin and stdout must drain concurrently when both exceed the pipe buffer' + $bidirectionalResult.ExitCode | Should -Be 0 -Because $bidirectionalResult.Output + $bidirectionalResult.Output | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}(?:\.d[0-9a-f]{12})?$' + } + + It 'ignores a malicious ambient legacy helper and remains deterministic across repeated calls in one process' { + $root = New-R8TrainVersionFixture + $revision = (& git -C $root rev-parse HEAD).Trim().Substring(0, 12) + $versionLiteral = $script:versionScript.Replace("'", "''") + $rootLiteral = $root.Replace("'", "''") + $source = $script:ambientCaptureSource + $bootstrap = @" +`$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +$source +'@ +`$first = & '$versionLiteral' -RepositoryRoot '$rootLiteral' +`$second = & '$versionLiteral' -RepositoryRoot '$rootLiteral' +[pscustomobject] @{ first = [string] `$first; second = [string] `$second } | ConvertTo-Json -Compress +"@ + + $result = Invoke-R8Bootstrap -Content $bootstrap + + $result.ExitCode | Should -Be 0 -Because $result.Output + $values = $result.Output | ConvertFrom-Json + $values.first | Should -Be "0.4.0-r8.g$revision" + $values.second | Should -Be $values.first + } + + It 'rejects raw source paths that collide by ordinal case before capture' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode case-collision + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'case|collid|ambiguous' + } + + It 'rejects raw source paths that collide after Unicode normalization before capture' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode normalization-collision + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'normalization|collid|ambiguous' + } + + It 'rejects a case-aliased inventory record for the proof-bound helper inside the repository' { + $fixture = New-R8InternalHelperFixture + $shimDirectory = New-R8PortableGitShim -Mode helper-case-alias -Configuration @{ + CanonicalPath = 'scripts/private/GraphKit.SourceCapture.cs' + AliasPath = 'Scripts/private/GraphKit.SourceCapture.cs' + } + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $fixture.Root -VersionScript $fixture.VersionScript + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'source-capture helper inside RepositoryRoot requires|Cannot root-anchored no-follow capture source entry|Git source paths collide by case or Unicode normalization' + $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment[\s\S]*Scripts|Git source paths collide by case or Unicode normalization' + } + + It 'binds a physically internal proof helper when RepositoryRoot is a Unix symlink or Windows junction alias' { + $fixture = New-R8InternalHelperFixture + $rootAlias = New-R8RepositoryRootAlias -Target $fixture.Root -Alias (Join-Path $TestDrive ('repository-alias-' + [guid]::NewGuid().ToString('N'))) + $shimDirectory = New-R8PortableGitShim -Mode helper-case-alias -Configuration @{ + CanonicalPath = 'scripts/private/GraphKit.SourceCapture.cs' + AliasPath = 'Scripts/private/GraphKit.SourceCapture.cs' + } + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $rootAlias -VersionScript $fixture.VersionScript + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'source-capture helper inside RepositoryRoot requires|Cannot root-anchored no-follow capture source entry|Git source paths collide by case or Unicode normalization' + $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment[\s\S]*Scripts|Git source paths collide by case or Unicode normalization' + } + + It 'allows a genuinely external proof helper when RepositoryRoot is a filesystem alias' { + $root = New-R8TrainVersionFixture + $rootAlias = New-R8RepositoryRootAlias -Target $root -Alias (Join-Path $TestDrive ('external-helper-alias-' + [guid]::NewGuid().ToString('N'))) + + $result = Get-R8TrainVersion -RepositoryRoot $rootAlias -VersionScript $script:versionScript + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output.Trim() | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}$' + } + + It 'fails on unmerged index stage before invoking worktree capture' -ForEach @( + @{ Stage = 1 } + @{ Stage = 2 } + @{ Stage = 3 } + ) { + $fixture = New-R8ProofBoundCaptureSentinelFixture + $sentinel = Join-Path $TestDrive ("capture-stage-$Stage-" + [guid]::NewGuid().ToString('N')) + $shimDirectory = New-R8PortableGitShim -Mode unmerged-stage -Configuration @{ Stage = $Stage } + $savedPath = $env:PATH + $savedSentinel = $env:GRAPHKIT_TEST_CAPTURE_SENTINEL + try { + $env:GRAPHKIT_TEST_CAPTURE_SENTINEL = $sentinel + $control = Get-R8TrainVersion -RepositoryRoot $fixture.Root -VersionScript $fixture.VersionScript + $control.ExitCode | Should -Be 0 -Because $control.Output + $sentinel | Should -Exist -Because 'the copied proof-bound generated helper must be demonstrably active in the control run' + Remove-Item -LiteralPath $sentinel -Force + + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $fixture.Root -VersionScript $fixture.VersionScript + } + finally { + $env:PATH = $savedPath + $env:GRAPHKIT_TEST_CAPTURE_SENTINEL = $savedSentinel + } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'unmerged' + Test-Path -LiteralPath $sentinel | Should -BeFalse + } + + It 'binds the helper-reported native handle identity into canonical source state' { + $fixture = New-R8ControlledIdentityFixture + $savedIdentity = $env:GRAPHKIT_TEST_CAPTURE_IDENTITY + try { + $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = 'test-device:00000001:test-file:00000001' + $first = Get-R8TrainVersion -RepositoryRoot $fixture.Root -VersionScript $fixture.VersionScript + $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = 'test-device:00000002:test-file:00000001' + $second = Get-R8TrainVersion -RepositoryRoot $fixture.Root -VersionScript $fixture.VersionScript + } + finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } + + $first.ExitCode | Should -Be 0 -Because $first.Output + $second.ExitCode | Should -Be 0 -Because $second.Output + $second.Output | Should -Not -Be $first.Output + } + + It 'fails closed when HEAD moves to a different commit with the same tree during capture' { + $root = New-R8TrainVersionFixture + $firstRevision = (& git -C $root rev-parse HEAD).Trim() + & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet --allow-empty -m 'same tree, different commit' + $secondRevision = (& git -C $root rev-parse HEAD).Trim() + & git -C $root update-ref HEAD $firstRevision + $counter = Join-Path $TestDrive ('git-head-move-' + [guid]::NewGuid().ToString('N')) + $shimDirectory = New-R8PortableGitShim -Mode head-move -Configuration @{ + Counter = $counter + Root = $root + Revision = $secondRevision + } + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'HEAD|revision|commit.*changed' + } + + It 'rejects a tree object identity whose length does not match the discovered format' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode invalid-tree-oid + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'invalid object identity|unsupported entry header' + } + + It 'fails closed with an actionable error for a SHA-256 object-format repository' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode sha256-format + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'SHA-256.*not supported|unsupported.*SHA-256' + } + + It 'treats a Windows-style clean tracked 100755 entry as clean without losing index mode proof' { + $captureType = Initialize-R8SourceCaptureHelper + $captureType::ResolveEffectiveGitMode('', $false, '100755') | Should -Be '100755' + + if ($IsWindows) { + $root = New-R8TrainVersionFixture + & git -C $root update-index --chmod=+x source/Private/Tracked-One.ps1 + & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'tracked executable' + $revision = (& git -C $root rev-parse HEAD).Trim().Substring(0, 12) + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Be "0.4.0-r8.g$revision" + } + } + + It 'marks a non-ignored untracked package-producing regular file dirty' { + $root = New-R8TrainVersionFixture + Set-Content -LiteralPath (Join-Path $root 'source/Private/Untracked.ps1') -Value "'untracked bytes'`n" -NoNewline -Encoding utf8NoBOM + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d[0-9a-f]{12}$' + } + + It 'is independent of diff.orderFile for a multi-file tracked dirty state' { + $root = New-R8TrainVersionFixture + Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked-One.ps1') -Value "'one changed'`n" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked-Two.ps1') -Value "'two changed'`n" -NoNewline -Encoding utf8NoBOM + $first = Get-R8TrainVersion -RepositoryRoot $root + Set-Content -LiteralPath (Join-Path $root '.git-order') -Value "source/Private/Tracked-Two.ps1`nsource/Private/Tracked-One.ps1`n" -NoNewline -Encoding utf8NoBOM + & git -C $root config diff.orderFile .git-order + $second = Get-R8TrainVersion -RepositoryRoot $root + + $first.ExitCode | Should -Be 0 -Because $first.Output + $second.ExitCode | Should -Be 0 -Because $second.Output + $second.Output | Should -Be $first.Output + } + + It 'changes identity when one byte in a dirty regular file changes' { + $root = New-R8TrainVersionFixture + $path = Join-Path $root 'source/Private/Untracked.ps1' + [IO.File]::WriteAllBytes($path, [byte[]] @(1, 2, 3)) + $first = Get-R8TrainVersion -RepositoryRoot $root + [IO.File]::WriteAllBytes($path, [byte[]] @(1, 2, 4)) + $second = Get-R8TrainVersion -RepositoryRoot $root + + $first.ExitCode | Should -Be 0 -Because $first.Output + $second.ExitCode | Should -Be 0 -Because $second.Output + $second.Output | Should -Not -Be $first.Output + } + + It 'is independent of the creation order of equivalent untracked paths' { + $root = New-R8TrainVersionFixture + $firstPath = Join-Path $root 'source/Private/a.ps1' + $secondPath = Join-Path $root 'source/Private/z.ps1' + Set-Content -LiteralPath $secondPath -Value "'z'`n" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath $firstPath -Value "'a'`n" -NoNewline -Encoding utf8NoBOM + $forward = Get-R8TrainVersion -RepositoryRoot $root + $shimDirectory = New-R8PortableGitShim -Mode reverse-untracked + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $reverse = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $forward.ExitCode | Should -Be 0 -Because $forward.Output + $reverse.ExitCode | Should -Be 0 -Because $reverse.Output + $reverse.Output | Should -Be $forward.Output + } + + It 'fails closed for an untracked symbolic link rather than dereferencing it' { + $root = New-R8TrainVersionFixture + $target = Join-Path $root 'source/Private/target.ps1' + Set-Content -LiteralPath $target -Value "'target'`n" -NoNewline -Encoding utf8NoBOM + New-Item -ItemType SymbolicLink -Path (Join-Path $root 'source/Private/link.ps1') -Target $target | Out-Null + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'symbolic link|unsupported' + } + + It 'fails closed when Git reports an entry that disappears before capture' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode missing + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { + $env:PATH = $savedPath + } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'disappeared|regular file' + } + + It 'fails closed for a non-strict-UTF-8 raw Git path on every host' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode invalid-path + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { + $env:PATH = $savedPath + } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'UTF-8|path' + } + + It 'marks a platform-representable executable-mode change dirty even when core.filemode is false' { + $root = New-R8TrainVersionFixture + $path = Join-Path $root 'source/Private/Tracked-One.ps1' + if ($IsWindows) { + & git -C $root update-index --chmod=+x source/Private/Tracked-One.ps1 + } + else { + & /bin/chmod +x $path + } + & git -C $root config core.filemode false + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d[0-9a-f]{12}$' + } + + It 'marks a staged addition dirty' { + $root = New-R8TrainVersionFixture + Set-Content -LiteralPath (Join-Path $root 'source/Private/Staged-Added.ps1') -Value "'staged add'`n" -NoNewline -Encoding utf8NoBOM + & git -C $root add source/Private/Staged-Added.ps1 + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match '\.d[0-9a-f]{12}$' + } + + It 'marks a staged deletion dirty' { + $root = New-R8TrainVersionFixture + & git -C $root rm --quiet source/Private/Tracked-One.ps1 + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match '\.d[0-9a-f]{12}$' + } + + It 'marks a staged rename dirty' { + $root = New-R8TrainVersionFixture + & git -C $root mv source/Private/Tracked-One.ps1 source/Private/Renamed-One.ps1 + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match '\.d[0-9a-f]{12}$' + } + + It 'marks an unstaged tracked deletion dirty' { + $root = New-R8TrainVersionFixture + Remove-Item -LiteralPath (Join-Path $root 'source/Private/Tracked-One.ps1') -Force + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match '\.d[0-9a-f]{12}$' + } + + It 'rejects a gitlink index entry rather than representing it as a missing file' { + $root = New-R8TrainVersionFixture + $object = (& git -C $root rev-parse HEAD).Trim() + & git -C $root update-index --add --cacheinfo "160000,$object,source/Private/Nested" + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'gitlink|submodule|unsupported' + } + + It 'accepts a platform-valid untracked path containing special and non-ASCII characters' { + $root = New-R8TrainVersionFixture + $relative = if ($IsWindows) { 'source/Private/hash # 雪.ps1' } else { "source/Private/tab`tline`n雪.ps1" } + $path = Join-Path $root $relative + Set-Content -LiteralPath $path -Value "'valid path'`n" -NoNewline -Encoding utf8NoBOM + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Output | Should -Match '\.d[0-9a-f]{12}$' + } + + It 'fails closed when a duplicate index path is reported' { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode duplicate-index + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'duplicate' + } + + It 'rejects an unsupported untracked filesystem entry promptly before reading it' { + $root = New-R8TrainVersionFixture + if ($IsWindows) { + $outside = Join-Path $TestDrive ('unsupported-target-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $outside -Force + $junction = Join-Path $root 'source/Private/input.reparse' + & cmd.exe /d /c "mklink /J `"$junction`" `"$outside`"" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'The Windows test host could not create the required directory junction.' } + } + else { + $fifo = Join-Path $root 'source/Private/input.fifo' + & /usr/bin/mkfifo $fifo + } + + # This bound covers fresh-process startup and proof-bound Add-Type compilation as well as + # the capture itself. Keep it comfortably below an actual FIFO-open hang without making + # scheduler pressure look like a source-capture regression. + $result = Get-R8TrainVersionWithTimeout -RepositoryRoot $root -TimeoutMilliseconds 15000 + + $result.Running | Should -BeFalse -Because 'special files must be rejected rather than opened' + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'regular|special|unsupported|reparse|cannot be opened' + } + + It 'fails closed when a non-ignored entry appears after initial enumeration' { + $root = New-R8TrainVersionFixture + $appeared = Join-Path $root 'source/Private/appeared.ps1' + $counter = Join-Path $TestDrive ('git-appearance-' + [guid]::NewGuid().ToString('N')) + $shimDirectory = New-R8PortableGitShim -Mode appearance -Configuration @{ + Counter = $counter + Path = $appeared + } + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'inventory|changed|race' + } + + It 'fails closed when content mutates after the first metadata/read pass' { + $root = New-R8TrainVersionFixture + $path = Join-Path $root 'source/Private/Tracked-One.ps1' + Set-Content -LiteralPath $path -Value "'dirty before race'`n" -NoNewline -Encoding utf8NoBOM + $counter = Join-Path $TestDrive ('git-mutation-' + [guid]::NewGuid().ToString('N')) + $shimDirectory = New-R8PortableGitShim -Mode mutation -Configuration @{ + Counter = $counter + Path = $path + } + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'changed|race|metadata|content' + } + + It 'matches the fixed R8 source-state known vector' { + $fixture = New-R8ControlledIdentityFixture + $savedIdentity = $env:GRAPHKIT_TEST_CAPTURE_IDENTITY + try { + $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = 'known-device:00000001:known-file:00000002' + $state = & $fixture.VersionScript -RepositoryRoot $fixture.Root -AsObject + } + finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } + + $state.sourceStateSha256 | Should -Be '88365586a59840cef20946c650bc5973567bf9be407728c3568af70d4d4cfcea' + $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d88365586a598$' + } +} + +Describe 'GraphKit R8 root-anchored source capture' -Tag 'QA' { + It 'maps Linux statx device fields in ABI order before formatting ordinary-file identity' { + $captureType = Initialize-R8SourceCaptureHelper + $script:sourceCaptureType -is [type] | Should -BeTrue ` + -Because 'the cached capture helper must remain one static-callable Type rather than Object[]' + $statxType = $captureType.Assembly.GetType("$($captureType.Namespace).UnixNative+Statx", $true) + $helperSource = Get-Content -LiteralPath $script:sourceCaptureHelper -Raw + + [Runtime.InteropServices.Marshal]::OffsetOf($statxType, 'RDeviceMajor').ToInt32() | Should -Be 128 + [Runtime.InteropServices.Marshal]::OffsetOf($statxType, 'RDeviceMinor').ToInt32() | Should -Be 132 + [Runtime.InteropServices.Marshal]::OffsetOf($statxType, 'DeviceMajor').ToInt32() | Should -Be 136 + [Runtime.InteropServices.Marshal]::OffsetOf($statxType, 'DeviceMinor').ToInt32() | Should -Be 140 + $helperSource | Should -Match 'EntryPoint = "fstat\$INODE64"' + $helperSource | Should -Match 'Architecture\.Arm64 => DarwinFStat\(' + $helperSource | Should -Match 'Architecture\.X64 => DarwinFStatInode64\(' + $helperSource | Should -Match 'catch \(EntryPointNotFoundException exception\)' + $helperSource | Should -Match 'FileTraverse\s*=\s*0x0020' + $helperSource | Should -Match 'FileTraverse\s*\|\s*FileReadAttributes\s*\|\s*Synchronize' + $helperSource | Should -Match 'directory\s*\?\s*FileListDirectory\s*\|\s*FileTraverse' + } + + It 'rejects Windows reserved-device, ADS, and suspicious short-alias path forms without a platform skip' { + $captureType = Initialize-R8SourceCaptureHelper + $validator = $captureType.GetMethod('ValidateWindowsRelativePathForProof') + $validator | Should -Not -BeNullOrEmpty -Because 'portable tests must execute the same lexical gate used by native Windows capture' + + foreach ($relativePath in @( + 'source/CON.ps1', + 'source/NUL', + 'source/file.ps1:payload', + 'source/LONGFI~1.PS1' + )) { + { $validator.Invoke($null, @($relativePath)) } | Should -Throw -Because $relativePath + } + } + + It 'rejects a wrong-case segment through a native check or the equivalent raw-inventory gate' { + if ($IsWindows) { + $captureType = Initialize-R8SourceCaptureHelper + $root = Join-Path $TestDrive ('wrong-case-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root -Force + Set-Content -LiteralPath (Join-Path $root 'ExactName.ps1') -Value "'exact'`n" -NoNewline -Encoding utf8NoBOM + + { $captureType::Capture($root, 'exactname.ps1') } | Should -Throw + } + else { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8PortableGitShim -Mode case-collision + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'case|collid|ambiguous' + } + } + + It 'accepts exactly 16 MiB but rejects the next byte before allocating capture buffers' { + $captureType = Initialize-R8SourceCaptureHelper + $root = Join-Path $TestDrive ('capture-ceiling-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root -Force + $boundary = Join-Path $root 'boundary.bin' + $over = Join-Path $root 'over.bin' + $boundaryStream = [IO.File]::Open($boundary, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $boundaryStream.SetLength(16MB) } finally { $boundaryStream.Dispose() } + $overStream = [IO.File]::Open($over, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $overStream.SetLength(16MB + 1) } finally { $overStream.Dispose() } + + $captured = $captureType::Capture($root, 'boundary.bin') + $captured.Length | Should -Be 16MB + $captured.Content.Length | Should -Be 16MB + { $captureType::Capture($root, 'over.bin') } | + Should -Throw -ExpectedMessage '*16 MiB*package-source*limit*' + } + + It 'rejects an intermediate link or reparse point on every supported platform' { + $captureType = Initialize-R8SourceCaptureHelper + $root = Join-Path $TestDrive ('intermediate-link-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path $TestDrive ('outside-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path (Join-Path $root 'source') -Force + $null = New-Item -ItemType Directory -Path $outside -Force + Set-Content -LiteralPath (Join-Path $outside 'Tracked.ps1') -Value "'same bytes'`n" -NoNewline -Encoding utf8NoBOM + $link = Join-Path $root 'source/Private' + if ($IsWindows) { + & cmd.exe /d /c "mklink /J `"$link`" `"$outside`"" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'The Windows test host could not create the required directory junction.' } + } + else { + New-Item -ItemType SymbolicLink -Path $link -Target $outside | Out-Null + } + + { $captureType::Capture($root, 'source/Private/Tracked.ps1') } | + Should -Throw -ExpectedMessage $(if ($IsWindows) { '*reparse point*' } else { '*symbolic link*' }) + } + + It 'closes final handles when an unsupported final entry is rejected' { + $captureType = Initialize-R8SourceCaptureHelper + $root = Join-Path $TestDrive ('handle-ownership-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root -Force + if ($IsWindows) { + $null = New-Item -ItemType Directory -Path (Join-Path $root 'unsupported.entry') + { $captureType::Capture($root, 'unsupported.entry') } | Should -Throw + $before = [Diagnostics.Process]::GetCurrentProcess().HandleCount + } + else { + $fifo = Join-Path $root 'unsupported.fifo' + & /usr/bin/mkfifo $fifo + { $captureType::Capture($root, 'unsupported.fifo') } | Should -Throw + $before = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + } + + 1..64 | ForEach-Object { + { $captureType::Capture($root, $(if ($IsWindows) { 'unsupported.entry' } else { 'unsupported.fifo' })) } | Should -Throw + } + + $after = if ($IsWindows) { + [Diagnostics.Process]::GetCurrentProcess().HandleCount + } + else { + @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + } + ($after - $before) | Should -BeLessOrEqual 2 -Because 'every native handle must immediately gain a safe owner' + } + + It 'rejects a Windows reparse point in an intermediate path segment without retaining handles' { + $captureType = Initialize-R8SourceCaptureHelper + $root = Join-Path $TestDrive ('reparse-root-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path $TestDrive ('reparse-outside-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path (Join-Path $root 'source') -Force + $null = New-Item -ItemType Directory -Path $outside -Force + Set-Content -LiteralPath (Join-Path $outside 'Tracked.ps1') -Value "'outside'`n" -NoNewline -Encoding utf8NoBOM + $link = Join-Path $root 'source/Private' + if ($IsWindows) { + & cmd.exe /d /c "mklink /J `"$link`" `"$outside`"" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'The Windows test host could not create the required directory junction.' } + $before = [Diagnostics.Process]::GetCurrentProcess().HandleCount + 1..16 | ForEach-Object { + { $captureType::Capture($root, 'source/Private/Tracked.ps1') } | + Should -Throw -ExpectedMessage '*reparse point*' + } + $after = [Diagnostics.Process]::GetCurrentProcess().HandleCount + } else { + New-Item -ItemType SymbolicLink -Path $link -Target $outside | Out-Null + { $captureType::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw + $before = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + 1..16 | ForEach-Object { + { $captureType::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw + } + $after = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + } + ($after - $before) | Should -BeLessOrEqual 2 + } +} diff --git a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index c924dbb..0f79e9f 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -7,6 +7,89 @@ BeforeAll { } Import-Module (Join-Path $built.FullName 'GraphKit.psd1') -Force -ErrorAction Stop + if ($null -eq ('GraphKit.Tests.TenantDeadlineIgnoringHandler' -as [type])) { + Add-Type -TypeDefinition @' +using System.Net; +using System.Net.Http; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphKit.Tests +{ + public sealed class TenantDeadlineIgnoringHandler : HttpMessageHandler + { + public const string ContractMarker = "GraphKit.TenantDeadlineIgnoringHandler/1"; + private int _sendCount; + + public int SendCount { get { return Volatile.Read(ref _sendCount); } } + public CancellationTokenSource CompletionCancellation { get; set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _sendCount); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = CompletionCancellation == null + ? new StringContent("{\"value\":[]}") + : new CompletionCancellingContent(CompletionCancellation) + }); + } + + private sealed class CompletionCancellingContent : HttpContent + { + private static readonly byte[] Body = Encoding.UTF8.GetBytes("{\"value\":[]}"); + private readonly CancellationTokenSource _cancellation; + + public CompletionCancellingContent(CancellationTokenSource cancellation) + { + _cancellation = cancellation; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + return SerializeAndCancel(stream); + } + + protected override Task SerializeToStreamAsync( + Stream stream, + TransportContext context, + CancellationToken cancellationToken) + { + return SerializeAndCancel(stream); + } + + private Task SerializeAndCancel(Stream stream) + { + stream.Write(Body, 0, Body.Length); + _cancellation.Cancel(); + return Task.CompletedTask; + } + + protected override bool TryComputeLength(out long length) + { + length = Body.Length; + return true; + } + } + } +} +'@ + } + + $handlerType = 'GraphKit.Tests.TenantDeadlineIgnoringHandler' -as [type] + $marker = if ($null -ne $handlerType) { $handlerType.GetField('ContractMarker') } else { $null } + if ($null -eq $marker -or + [string] $marker.GetRawConstantValue() -cne 'GraphKit.TenantDeadlineIgnoringHandler/1') { + throw ( + 'The process-global tenant-deadline handler fixture is stale. ' + + 'Run this file in a fresh PowerShell process.' + ) + } + $script:TenantId = [guid] '00000000-0000-0000-0000-000000000001' $script:OtherTenantId = [guid] '00000000-0000-0000-0000-000000000002' @@ -18,7 +101,7 @@ BeforeAll { TenantId = $TenantId Cloud = 'Global' GraphBaseUri = [uri] 'https://graph.microsoft.com' - ClientId = 'client' + ClientId = [guid] '00000000-0000-0000-0000-000000000010' TokenSource = $null IdentityState = 'VerifiedForToken' } @@ -55,7 +138,8 @@ BeforeAll { param( [string] $Fingerprint = 'fp1', [string] $Generation = 'g1', - [string] $VerifiedTenantId = $null + [string] $VerifiedTenantId = $null, + [object] $ElapsedCapture ) # Duck-typed token source: a plain PSCustomObject exposing the module's @@ -67,10 +151,16 @@ BeforeAll { TokenFingerprint = $Fingerprint VerifiedTenantId = $VerifiedTenantId CredentialGeneration = $Generation + AcquireFlags = [System.Collections.Generic.List[bool]]::new() + ElapsedCapture = $ElapsedCapture } $source = $source | Add-Member -MemberType ScriptMethod -Name Acquire -Value { param([bool] $forceRefresh, $ct) + $this.AcquireFlags.Add($forceRefresh) + if ($null -ne $this.ElapsedCapture) { + $this.ElapsedCapture.Elapsed = $this.ElapsedCapture.AfterAcquire + } return [pscustomobject] @{ AccessToken = 'test-bearer-token' ExpiresOnUtc = [System.DateTimeOffset]::UtcNow.AddHours(1) @@ -101,6 +191,15 @@ BeforeAll { Describe 'Confirm-GraphTenantBinding' { + It 'pins the process-global deadline handler fixture contract' { + $handlerType = 'GraphKit.Tests.TenantDeadlineIgnoringHandler' -as [type] + $marker = $handlerType.GetField('ContractMarker') + + $marker | Should -Not -BeNullOrEmpty + [string] $marker.GetRawConstantValue() | + Should -BeExactly 'GraphKit.TenantDeadlineIgnoringHandler/1' + } + Context 'binding cache' { BeforeEach { $script:proofCalls = 0 @@ -109,7 +208,7 @@ Describe 'Confirm-GraphTenantBinding' { It 'performs the proof on a new fingerprint and records the binding' { $cache = @{} - $transport = { param($Context, $Descriptor, $Uri) $script:proofCalls++; return $script:proofEnvelope } + $transport = { param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) $script:proofCalls++; return $script:proofEnvelope } $result = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult), $transport { param($Cache, $Context, $TokenResult, $Transport) @@ -124,7 +223,7 @@ Describe 'Confirm-GraphTenantBinding' { It 'skips the proof call when the binding is already cached' { $cache = @{} - $transport = { param($Context, $Descriptor, $Uri) $script:proofCalls++; return $script:proofEnvelope } + $transport = { param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) $script:proofCalls++; return $script:proofEnvelope } $null = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult), $transport { param($Cache, $Context, $TokenResult, $Transport) @@ -142,7 +241,7 @@ Describe 'Confirm-GraphTenantBinding' { It 're-proves when the fingerprint changes even with the same generation and tenant' { $cache = @{} - $transport = { param($Context, $Descriptor, $Uri) $script:proofCalls++; return $script:proofEnvelope } + $transport = { param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) $script:proofCalls++; return $script:proofEnvelope } $null = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult -Fingerprint 'fp-a'), $transport { param($Cache, $Context, $TokenResult, $Transport) @@ -155,6 +254,73 @@ Describe 'Confirm-GraphTenantBinding' { $script:proofCalls | Should -Be 2 } + + It 'rejects a before consulting the binding cache' -ForEach @( + @{ Shape = 'null'; Field = 'TokenFingerprint'; Value = $null } + @{ Shape = 'empty'; Field = 'TokenFingerprint'; Value = '' } + @{ Shape = 'whitespace'; Field = 'TokenFingerprint'; Value = ' ' } + @{ Shape = 'null'; Field = 'CredentialGeneration'; Value = $null } + @{ Shape = 'empty'; Field = 'CredentialGeneration'; Value = '' } + @{ Shape = 'whitespace'; Field = 'CredentialGeneration'; Value = "`t" } + ) { + $cache = @{} + $tokenResult = New-TestTokenResult + $tokenResult.$Field = $Value + $transport = { + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) + $script:proofCalls++ + return $script:proofEnvelope + } + + { + InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), $tokenResult, $transport { + param($Cache, $Context, $TokenResult, $Transport) + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` + -ProofTransport $Transport -ProofCache $Cache + } + } | Should -Throw -ExpectedMessage "*$Field*" + + $script:proofCalls | Should -Be 0 + $cache.Count | Should -Be 0 + } + + It 'cannot reuse one empty-metadata binding for two distinct bearer tokens' { + $cache = @{} + $transport = { + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) + $script:proofCalls++ + return $script:proofEnvelope + } + $first = New-TestTokenResult -Fingerprint '' -Generation '' + $first.AccessToken = 'first-distinct-bearer' + $second = New-TestTokenResult -Fingerprint '' -Generation '' + $second.AccessToken = 'second-distinct-bearer' + $failures = [System.Collections.Generic.List[object]]::new() + + foreach ($tokenResult in @($first, $second)) { + $failure = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), $tokenResult, $transport { + param($Cache, $Context, $TokenResult, $Transport) + try { + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` + -ProofTransport $Transport -ProofCache $Cache + return $null + } + catch { + return $_.Exception + } + } + $failures.Add($failure) + } + + $failures | Should -HaveCount 2 + foreach ($failure in $failures) { + $failure | Should -Not -BeNullOrEmpty + $failure.Message | Should -Match 'TokenFingerprint|CredentialGeneration' + $failure.Message | Should -Not -Match 'first-distinct-bearer|second-distinct-bearer' + } + $script:proofCalls | Should -Be 0 + $cache.Count | Should -Be 0 + } } Context 'proof outcomes' { @@ -165,7 +331,7 @@ Describe 'Confirm-GraphTenantBinding' { It 'still proves when a provider claims a tenant without a recorded binding' { $cache = @{} $script:proofEnvelope = New-TestProofEnvelope - $transport = { param($Context, $Descriptor, $Uri) $script:proofCalls++; return $script:proofEnvelope } + $transport = { param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) $script:proofCalls++; return $script:proofEnvelope } # The result already carries the tenant id (a provider's claim); the # prover must not trust it and must still issue the proof read. @@ -184,7 +350,7 @@ Describe 'Confirm-GraphTenantBinding' { Outcome = 'Succeeded' Data = @{ value = @( @{ id = $script:OtherTenantId.ToString() } ) } } - $transport = { param($Context, $Descriptor, $Uri) return $script:proofEnvelope } + $transport = { param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) return $script:proofEnvelope } $message = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult), $transport { param($Cache, $Context, $TokenResult, $Transport) @@ -206,11 +372,17 @@ Describe 'Confirm-GraphTenantBinding' { $cache = @{} $script:proofCall = $null Mock Invoke-GraphRetry -ModuleName GraphKit { - param($Context, $Descriptor, $Uri, $Method, $Headers, $Body, $CancellationToken) + param($Context, $Descriptor, $Uri, $Method, $Headers, $Body, $CancellationToken, $DeadlineSeconds) $script:proofCall = [pscustomobject] @{ - Method = $Method - Uri = $Uri - Descriptor = $Descriptor + Method = $Method + Uri = $Uri + Descriptor = $Descriptor + Context = $Context + DeadlineSeconds = $DeadlineSeconds + Scope = & (Get-Module GraphKit) { + param($ProofContext, $ProofDescriptor) + New-GraphThrottleScope -Context $ProofContext -Descriptor $ProofDescriptor + } $Context $Descriptor } return [pscustomobject] @{ Outcome = 'Succeeded' @@ -220,7 +392,8 @@ Describe 'Confirm-GraphTenantBinding' { $null = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult) { param($Cache, $Context, $TokenResult) - Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult -ProofCache $Cache + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult -ProofCache $Cache ` + -RemainingDeadline ([TimeSpan]::FromSeconds(17)) } $script:proofCall | Should -Not -BeNullOrEmpty @@ -230,8 +403,119 @@ Describe 'Confirm-GraphTenantBinding' { $script:proofCall.Descriptor.ReplayPolicy | Should -Be 'Safe' $script:proofCall.Descriptor.ThrottleClass | Should -Be 'Read' $script:proofCall.Descriptor.ResourceFamily | Should -Be 'Graph.Directory' - $script:proofCall.Descriptor.IdentityRequirement | Should -Be 'Verified' + $script:proofCall.Descriptor.IdentityRequirement | Should -Be 'AllowUnverifiedRead' $script:proofCall.Descriptor.Keys | Should -Not -Contain 'VerifyTenantBinding' + $script:proofCall.Context.Cloud | Should -BeExactly 'Global' + $script:proofCall.Context.ClientId | Should -Be ([guid] '00000000-0000-0000-0000-000000000010') + $script:proofCall.Scope.CoarseKey | Should -BeExactly 'Global|00000000-0000-0000-0000-000000000001|00000000-0000-0000-0000-000000000010|Read' + $script:proofCall.Scope.LeafKey | Should -BeExactly 'Global|00000000-0000-0000-0000-000000000001|00000000-0000-0000-0000-000000000010|Read|Graph.Directory' + $script:proofCall.DeadlineSeconds | Should -Be 17 + + $maximumDeadlineCache = @{} + $null = InModuleScope GraphKit -ArgumentList $maximumDeadlineCache, (New-TestContext), (New-TestTokenResult) { + param($Cache, $Context, $TokenResult) + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` + -ProofCache $Cache -RemainingDeadline ([TimeSpan]::MaxValue) + } + $script:proofCall.DeadlineSeconds | Should -Be 86400 + + $nullCloudCache = @{} + $nullCloudContext = New-TestContext + $nullCloudContext.Cloud = $null + $null = InModuleScope GraphKit -ArgumentList $nullCloudCache, $nullCloudContext, (New-TestTokenResult) { + param($Cache, $Context, $TokenResult) + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult -ProofCache $Cache + } + $script:proofCall.Context.Cloud | Should -BeExactly 'TenantProof' + } + + It 'forwards the caller cancellation token into the proof retry pipeline' { + $cache = @{} + $script:proofCancellationToken = [System.Threading.CancellationToken]::None + $script:proofCancellationWasRequestedAtEntry = $null + $cts = [System.Threading.CancellationTokenSource]::new() + $script:proofCancellationSource = $cts + + Mock Invoke-GraphRetry -ModuleName GraphKit { + param($Context, $Descriptor, $Uri, $Method, $Headers, $Body, $CancellationToken) + $script:proofCancellationWasRequestedAtEntry = $CancellationToken.IsCancellationRequested + $script:proofCancellationToken = $CancellationToken + $script:proofCancellationSource.Cancel() + return [pscustomobject] @{ + Outcome = 'Cancelled' + Data = $null + } + } + + $failure = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult), $cts.Token { + param($Cache, $Context, $TokenResult, $CancellationToken) + try { + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` + -ProofCache $Cache -CancellationToken $CancellationToken + return $null + } + catch { + return $_.Exception + } + } + + $script:proofCancellationWasRequestedAtEntry | Should -BeFalse + $script:proofCancellationToken.Equals($cts.Token) | Should -BeTrue + $script:proofCancellationToken.IsCancellationRequested | Should -BeTrue + $failure | Should -Not -BeNullOrEmpty + $isCancellation = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + break + } + $candidate = $candidate.InnerException + } + $isCancellation | Should -BeTrue -Because 'caller cancellation during the nested proof must preserve the retry pipeline cancellation outcome' + $failure.Message | Should -Not -Match 'Tenant proof failed' + } + + It 'preserves caller cancellation when the remaining proof budget is also zero' { + $cache = @{} + $cts = [System.Threading.CancellationTokenSource]::new() + $cts.Cancel() + + try { + $failure = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult), $cts.Token { + param($Cache, $Context, $TokenResult, $CancellationToken) + try { + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` + -ProofCache $Cache -CancellationToken $CancellationToken ` + -RemainingDeadline ([TimeSpan]::Zero) ` + -ProofTransport { + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) + throw 'proof transport must not run at the cancelled boundary' + } + return $null + } + catch { + return $_.Exception + } + } + + $isCancellation = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + break + } + $candidate = $candidate.InnerException + } + + $isCancellation | Should -BeTrue + $failure | Should -Not -BeOfType ([System.TimeoutException]) + $cache.Count | Should -Be 0 + } + finally { + $cts.Dispose() + } } } } @@ -269,13 +553,56 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { $result.TransportException | Should -Not -BeNullOrEmpty } + It 'passes cancellation raised during acquisition to the prover before any mutation send' { + $port = Get-FreePort + $authority = [uri] "http://127.0.0.1:$port" + $cts = [System.Threading.CancellationTokenSource]::new() + $tokenSource = New-TestTokenSource -Fingerprint 'fp-cancelled' -Generation 'g1' -VerifiedTenantId $null + $tokenSource | Add-Member -MemberType NoteProperty -Name CancellationSource -Value $cts + $tokenSource | Add-Member -MemberType ScriptMethod -Name Acquire -Force -Value { + param([bool] $forceRefresh, $ct) + $this.CancellationSource.Cancel() + return [pscustomobject] @{ + AccessToken = 'test-bearer-token' + ExpiresOnUtc = [System.DateTimeOffset]::UtcNow.AddHours(1) + VerifiedTenantId = $null + TokenFingerprint = $this.TokenFingerprint + CredentialGeneration = $this.CredentialGeneration + } + } + $script:proverSawCancellation = $false + $prover = { + param($Context, $TokenResult, [System.Threading.CancellationToken] $CancellationToken) + $script:proverSawCancellation = $CancellationToken.IsCancellationRequested + $CancellationToken.ThrowIfCancellationRequested() + $TokenResult.VerifiedTenantId = [string] $Context.TenantId + } + + $message = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $prover, $script:TenantId, $cts.Token { + param($Authority, $TokenSource, $Prover, $TenantId, $CancellationToken) + try { + Send-GraphHttpRequest -Uri ([uri] "$($Authority.AbsoluteUri)/mutation") -Method POST -Body @{} ` + -CredentialPolicy GraphBearer -ExpectedAuthority $Authority -TokenSource $TokenSource ` + -TargetTenantId $TenantId -VerifyTenantBinding -TenantBindingProver $Prover ` + -CancellationToken $CancellationToken + return '' + } + catch { + return $_.Exception.Message + } + } + + $script:proverSawCancellation | Should -BeTrue + $message | Should -BeLike '*operation was canceled*' + } + It 'does not invoke the prover when the current fingerprint is already verified' { $port = Get-FreePort $authority = [uri] "http://127.0.0.1:$port" $tokenSource = New-TestTokenSource -Fingerprint 'fp-verified' -Generation 'g1' -VerifiedTenantId $script:TenantId.ToString() $prover = { param($Context, $TokenResult) $script:proverCalls++ } $script:proofEnvelope = New-TestProofEnvelope - $proofTransport = { param($Context, $Descriptor, $Uri) $script:proofCalls++; return $script:proofEnvelope } + $proofTransport = { param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) $script:proofCalls++; return $script:proofEnvelope } $result = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $prover, $proofTransport, $script:TenantId { param($Authority, $TokenSource, $Prover, $ProofTransport, $TenantId) @@ -327,5 +654,420 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { $script:proverCalls | Should -Be 1 $message | Should -BeLike '*Tenant binding failed*' } + + It 'rejects an inherited deadline exhausted at sender entry before token acquisition' { + $port = Get-FreePort + $authority = [uri] "http://127.0.0.1:$port" + $tokenSource = New-TestTokenSource -Fingerprint 'fp-entry-deadline' -Generation 'g-entry' + $capture = [pscustomobject] @{ ProverCalls = 0; FactoryCalls = 0 } + $prover = { + param($Context, $TokenResult) + $capture.ProverCalls++ + $TokenResult.VerifiedTenantId = [string] $Context.TenantId + }.GetNewClosure() + $factory = { + param($ConnectTimeoutSeconds) + $capture.FactoryCalls++ + throw 'target HTTP client factory must not run after deadline exhaustion' + }.GetNewClosure() + $bindingContext = [pscustomobject] @{ + Cloud = 'Global' + ClientId = [guid] '00000000-0000-0000-0000-000000000010' + RemainingDeadline = [TimeSpan]::Zero + } + + $failure = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $prover, $factory, $bindingContext, $script:TenantId { + param($Authority, $TokenSource, $Prover, $Factory, $BindingContext, $TenantId) + try { + Send-GraphHttpRequest -Uri ([uri] "$($Authority.AbsoluteUri)/x") -Method POST -Body @{} ` + -CredentialPolicy GraphBearer -ExpectedAuthority $Authority -TokenSource $TokenSource ` + -TargetTenantId $TenantId -VerifyTenantBinding -TenantBindingProver $Prover ` + -TenantBindingContext $BindingContext -HttpClientFactory $Factory + return $null + } + catch { + return $_.Exception + } + } + + $isDeadline = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isDeadline = $true + break + } + $candidate = $candidate.InnerException + } + $isDeadline | Should -BeTrue + $tokenSource.AcquireFlags | Should -HaveCount 0 + $capture.ProverCalls | Should -Be 0 + $capture.FactoryCalls | Should -Be 0 + } + + It 'rejects a cached binding when acquisition consumes the inherited monotonic budget' { + $port = Get-FreePort + $authority = [uri] "http://127.0.0.1:$port" + $elapsed = [pscustomobject] @{ + Elapsed = [TimeSpan]::Zero + AfterAcquire = [TimeSpan]::FromSeconds(5) + } + $tokenSource = New-TestTokenSource -Fingerprint 'fp-cache-deadline' -Generation 'g-cache' ` + -VerifiedTenantId $script:TenantId.ToString() -ElapsedCapture $elapsed + $capture = [pscustomobject] @{ ProverCalls = 0; FactoryCalls = 0 } + $prover = { param($Context, $TokenResult) $capture.ProverCalls++ }.GetNewClosure() + $factory = { + param($ConnectTimeoutSeconds) + $capture.FactoryCalls++ + throw 'target HTTP client factory must not run after deadline exhaustion' + }.GetNewClosure() + $elapsedProvider = { $elapsed.Elapsed }.GetNewClosure() + $bindingContext = [pscustomobject] @{ + Cloud = 'Global' + ClientId = [guid] '00000000-0000-0000-0000-000000000010' + RemainingDeadline = [TimeSpan]::FromSeconds(5) + Elapsed = $elapsedProvider + } + + $failure = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $prover, $factory, $bindingContext, $script:TenantId { + param($Authority, $TokenSource, $Prover, $Factory, $BindingContext, $TenantId) + $key = Get-GraphTenantBindingKey -Fingerprint 'fp-cache-deadline' -Generation 'g-cache' -TenantId $TenantId + $script:GraphTenantBindingCache[$key] = $true + try { + try { + Send-GraphHttpRequest -Uri ([uri] "$($Authority.AbsoluteUri)/x") -Method POST -Body @{} ` + -CredentialPolicy GraphBearer -ExpectedAuthority $Authority -TokenSource $TokenSource ` + -TargetTenantId $TenantId -VerifyTenantBinding -TenantBindingProver $Prover ` + -TenantBindingContext $BindingContext -HttpClientFactory $Factory + return $null + } + catch { + return $_.Exception + } + } + finally { + $null = $script:GraphTenantBindingCache.Remove($key) + } + } + + $isDeadline = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isDeadline = $true + break + } + $candidate = $candidate.InnerException + } + $isDeadline | Should -BeTrue + $tokenSource.AcquireFlags | Should -Be @($false) + $capture.ProverCalls | Should -Be 0 + $capture.FactoryCalls | Should -Be 0 + } + + It 'rejects a proof that completes exactly as the inherited monotonic budget expires' { + $port = Get-FreePort + $authority = [uri] "http://127.0.0.1:$port" + $elapsed = [pscustomobject] @{ + Elapsed = [TimeSpan]::Zero + AfterAcquire = [TimeSpan]::Zero + } + $tokenSource = New-TestTokenSource -Fingerprint 'fp-proof-boundary' -Generation 'g-proof' ` + -ElapsedCapture $elapsed + $capture = [pscustomobject] @{ ProverCalls = 0; FactoryCalls = 0 } + $prover = { + param($Context, $TokenResult) + $capture.ProverCalls++ + $TokenResult.VerifiedTenantId = [string] $Context.TenantId + & (Get-Module GraphKit) { + param($ProofTokenResult, $ProofContext) + $key = Get-GraphTenantBindingKey -Fingerprint $ProofTokenResult.TokenFingerprint ` + -Generation $ProofTokenResult.CredentialGeneration -TenantId $ProofContext.TenantId + $script:GraphTenantBindingCache[$key] = $true + } $TokenResult $Context + $elapsed.Elapsed = [TimeSpan]::FromSeconds(5) + }.GetNewClosure() + $factory = { + param($ConnectTimeoutSeconds) + $capture.FactoryCalls++ + throw 'target HTTP client factory must not run after deadline exhaustion' + }.GetNewClosure() + $elapsedProvider = { $elapsed.Elapsed }.GetNewClosure() + $bindingContext = [pscustomobject] @{ + Cloud = 'Global' + ClientId = [guid] '00000000-0000-0000-0000-000000000010' + RemainingDeadline = [TimeSpan]::FromSeconds(5) + Elapsed = $elapsedProvider + } + + $failure = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $prover, $factory, $bindingContext, $script:TenantId { + param($Authority, $TokenSource, $Prover, $Factory, $BindingContext, $TenantId) + $key = Get-GraphTenantBindingKey -Fingerprint 'fp-proof-boundary' -Generation 'g-proof' -TenantId $TenantId + try { + try { + Send-GraphHttpRequest -Uri ([uri] "$($Authority.AbsoluteUri)/x") -Method POST -Body @{} ` + -CredentialPolicy GraphBearer -ExpectedAuthority $Authority -TokenSource $TokenSource ` + -TargetTenantId $TenantId -VerifyTenantBinding -TenantBindingProver $Prover ` + -TenantBindingContext $BindingContext -HttpClientFactory $Factory + return $null + } + catch { + return $_.Exception + } + } + finally { + $null = $script:GraphTenantBindingCache.Remove($key) + } + } + + $isDeadline = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isDeadline = $true + break + } + $candidate = $candidate.InnerException + } + $isDeadline | Should -BeTrue + $tokenSource.AcquireFlags | Should -Be @($false) + $capture.ProverCalls | Should -Be 1 + $capture.FactoryCalls | Should -Be 0 + } + + It 'rejects a successful target response that completes at the inherited deadline' { + $authority = [uri] 'https://graph.microsoft.com' + $handler = [GraphKit.Tests.TenantDeadlineIgnoringHandler]::new() + $client = [System.Net.Http.HttpClient]::new($handler, $false) + $tokenSource = New-TestTokenSource -Fingerprint 'fp-target-boundary' -Generation 'g-target' ` + -VerifiedTenantId $script:TenantId.ToString() + $capture = [pscustomobject] @{ FactoryCalls = 0 } + $elapsedProvider = { + if ($handler.SendCount -gt 0) { + return [TimeSpan]::FromSeconds(5) + } + return [TimeSpan]::Zero + }.GetNewClosure() + $bindingContext = [pscustomobject] @{ + Cloud = 'Global' + ClientId = [guid] '00000000-0000-0000-0000-000000000010' + RemainingDeadline = [TimeSpan]::FromSeconds(5) + Elapsed = $elapsedProvider + } + $factory = { + param($ConnectTimeoutSeconds) + $capture.FactoryCalls++ + return [pscustomobject] @{ + Client = $client + OwnedByGraphKit = $false + } + }.GetNewClosure() + + try { + $failure = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $factory, $bindingContext, $script:TenantId { + param($Authority, $TokenSource, $Factory, $BindingContext, $TenantId) + $state = New-GraphModuleLifecycleState + $key = Get-GraphTenantBindingKey -Fingerprint 'fp-target-boundary' -Generation 'g-target' -TenantId $TenantId + $script:GraphTenantBindingCache[$key] = $true + try { + try { + Send-GraphHttpRequest -Uri ([uri] "$($Authority.AbsoluteUri.TrimEnd('/'))/v1.0/test") ` + -Method GET -CredentialPolicy GraphBearer -ExpectedAuthority $Authority ` + -TokenSource $TokenSource -TargetTenantId $TenantId -VerifyTenantBinding ` + -TenantBindingContext $BindingContext -HttpClientFactory $Factory ` + -LifecycleState $state + return $null + } + catch { + return $_.Exception + } + } + finally { + $null = $script:GraphTenantBindingCache.Remove($key) + Stop-GraphModule -State $state + } + } + + $isDeadline = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isDeadline = $true + break + } + $candidate = $candidate.InnerException + } + + $isDeadline | Should -BeTrue + $tokenSource.AcquireFlags | Should -Be @($false) + $capture.FactoryCalls | Should -Be 1 + $handler.SendCount | Should -Be 1 + } + finally { + $client.Dispose() + $handler.Dispose() + } + } + + It 'gives module cancellation precedence when the proof deadline expires in the same boundary check' { + $authority = [uri] 'https://graph.microsoft.com' + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $handler = [GraphKit.Tests.TenantDeadlineIgnoringHandler]::new() + $client = [System.Net.Http.HttpClient]::new($handler, $false) + $tokenSource = New-TestTokenSource -Fingerprint 'fp-target-module-cancel' -Generation 'g-target-module-cancel' ` + -VerifiedTenantId $script:TenantId.ToString() + $capture = [pscustomobject] @{ FactoryCalls = 0 } + $elapsedProvider = { + if ($handler.SendCount -gt 0) { + $state.ShutdownCts.Cancel() + return [TimeSpan]::FromSeconds(5) + } + return [TimeSpan]::Zero + }.GetNewClosure() + $bindingContext = [pscustomobject] @{ + Cloud = 'Global' + ClientId = [guid] '00000000-0000-0000-0000-000000000010' + RemainingDeadline = [TimeSpan]::FromSeconds(5) + Elapsed = $elapsedProvider + } + $factory = { + param($ConnectTimeoutSeconds) + $capture.FactoryCalls++ + return [pscustomobject] @{ + Client = $client + OwnedByGraphKit = $false + } + }.GetNewClosure() + + try { + $result = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $factory, $bindingContext, $script:TenantId, $state { + param($Authority, $TokenSource, $Factory, $BindingContext, $TenantId, $State) + $key = Get-GraphTenantBindingKey -Fingerprint 'fp-target-module-cancel' ` + -Generation 'g-target-module-cancel' -TenantId $TenantId + $script:GraphTenantBindingCache[$key] = $true + try { + Send-GraphHttpRequest -Uri ([uri] "$($Authority.AbsoluteUri.TrimEnd('/'))/v1.0/test") ` + -Method GET -CredentialPolicy GraphBearer -ExpectedAuthority $Authority ` + -TokenSource $TokenSource -TargetTenantId $TenantId -VerifyTenantBinding ` + -TenantBindingContext $BindingContext -HttpClientFactory $Factory ` + -LifecycleState $State + } + finally { + $null = $script:GraphTenantBindingCache.Remove($key) + } + } + + $isDeadline = $false + $candidate = $result.TransportException + while ($null -ne $candidate) { + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isDeadline = $true + } + $candidate = $candidate.InnerException + } + + $state.ShutdownCts.IsCancellationRequested | Should -BeTrue + $result.ResponseReceived | Should -BeTrue + $result.StatusCode | Should -Be 200 + $result.TransportException | Should -Not -BeNullOrEmpty + $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue + $isDeadline | Should -BeTrue + $tokenSource.AcquireFlags | Should -Be @($false) + $capture.FactoryCalls | Should -Be 1 + $handler.SendCount | Should -Be 1 + $state.ActiveOperations | Should -Be 0 + $state.Drained.IsSet | Should -BeTrue + } + finally { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + $client.Dispose() + $handler.Dispose() + } + } + + It 'preserves caller cancellation raised after a successful target body completes' { + $authority = [uri] 'https://graph.microsoft.com' + $cts = [System.Threading.CancellationTokenSource]::new() + $handler = [GraphKit.Tests.TenantDeadlineIgnoringHandler]::new() + $handler.CompletionCancellation = $cts + $client = [System.Net.Http.HttpClient]::new($handler, $false) + $tokenSource = New-TestTokenSource -Fingerprint 'fp-target-cancel' -Generation 'g-target-cancel' ` + -VerifiedTenantId $script:TenantId.ToString() + $capture = [pscustomobject] @{ FactoryCalls = 0 } + $bindingContext = [pscustomobject] @{ + Cloud = 'Global' + ClientId = [guid] '00000000-0000-0000-0000-000000000010' + RemainingDeadline = [TimeSpan]::FromSeconds(5) + Elapsed = { [TimeSpan]::Zero } + } + $factory = { + param($ConnectTimeoutSeconds) + $capture.FactoryCalls++ + return [pscustomobject] @{ + Client = $client + OwnedByGraphKit = $false + } + }.GetNewClosure() + + try { + $result = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $factory, $bindingContext, $script:TenantId, $cts.Token { + param($Authority, $TokenSource, $Factory, $BindingContext, $TenantId, $CancellationToken) + $state = New-GraphModuleLifecycleState + $key = Get-GraphTenantBindingKey -Fingerprint 'fp-target-cancel' -Generation 'g-target-cancel' -TenantId $TenantId + $script:GraphTenantBindingCache[$key] = $true + try { + Send-GraphHttpRequest -Uri ([uri] "$($Authority.AbsoluteUri.TrimEnd('/'))/v1.0/test") ` + -Method GET -CredentialPolicy GraphBearer -ExpectedAuthority $Authority ` + -TokenSource $TokenSource -TargetTenantId $TenantId -VerifyTenantBinding ` + -TenantBindingContext $BindingContext -HttpClientFactory $Factory ` + -LifecycleState $state -CancellationToken $CancellationToken + } + finally { + $null = $script:GraphTenantBindingCache.Remove($key) + Stop-GraphModule -State $state + } + } + + $isCancellation = $false + $isDeadline = $false + $candidate = $result.TransportException + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + } + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isDeadline = $true + } + $candidate = $candidate.InnerException + } + + $tokenSource.AcquireFlags | Should -Be @($false) + $capture.FactoryCalls | Should -Be 1 + $handler.SendCount | Should -Be 1 + $cts.IsCancellationRequested | Should -BeTrue + $result.ResponseReceived | Should -BeTrue + $result.StatusCode | Should -Be 200 + $result.TransportException | Should -Not -BeNullOrEmpty + $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue + $isCancellation | Should -BeTrue + $isDeadline | Should -BeFalse + } + finally { + $client.Dispose() + $handler.Dispose() + $cts.Dispose() + } + } } } diff --git a/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 b/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 index da4fa2f..d438bc4 100644 --- a/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 +++ b/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 @@ -55,6 +55,7 @@ Describe 'Get-GraphVaultCredential' { $result.AuthMethod | Should -Be 'ClientSecret' $result.Material | Should -BeOfType [System.Security.SecureString] + $result.OwnsMaterial | Should -BeTrue [System.Net.NetworkCredential]::new('', $result.Material).Password | Should -Be $script:ClientSecretPlain $result.ManagedIdentityClientId | Should -BeNullOrEmpty $result.PSTypeNames | Should -Contain 'GraphKit.CredentialMaterial' @@ -100,6 +101,54 @@ Describe 'Get-GraphVaultCredential' { Should-NotInvoke Invoke-GraphSecretManagementGetVault -ModuleName GraphKit } + + It 'fails before vault access for a versioned reference' -ForEach @( + @{ + Case = 'client secret' + AuthMethod = 'ClientSecret' + Credential = @{ VaultName = 'v'; SecretName = 'client-secret'; Version = 'immutable-v1' } + } + @{ + Case = 'bearer token' + AuthMethod = 'BearerToken' + Credential = @{ VaultName = 'v'; SecretName = 'bearer'; Version = 'immutable-v1' } + } + @{ + Case = 'vault certificate' + AuthMethod = 'Certificate' + Credential = @{ VaultName = 'v'; CertificateName = 'certificate'; Version = 'immutable-v1' } + } + @{ + Case = 'PFX password' + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = 'must-not-be-read.pfx' + Password = @{ VaultName = 'v'; SecretName = 'password'; Version = 'immutable-v1' } + } + } + @{ + Case = 'vault-certificate password' + AuthMethod = 'Certificate' + Credential = @{ + VaultName = 'v' + CertificateName = 'certificate' + Password = @{ VaultName = 'v'; SecretName = 'password'; Version = 'immutable-v1' } + } + } + ) { + Mock Invoke-GraphSecretManagementGetVault -ModuleName GraphKit { New-TestVault } + Mock Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit { $script:NoPasswordPfxBytes } + + { + InModuleScope GraphKit -Parameters @{ Credential = $Credential; AuthMethod = $AuthMethod } { + Get-GraphVaultCredential -Credential $Credential -AuthMethod $AuthMethod + } + } | Should -Throw -ExpectedMessage '*does not support per-secret versions*distinct secret name*' + + Should-Invoke Import-GraphSecretManagement -ModuleName GraphKit -Times 1 -Exactly + Should-NotInvoke Invoke-GraphSecretManagementGetVault -ModuleName GraphKit + Should-NotInvoke Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit + } } Context 'BearerToken' { @@ -107,14 +156,24 @@ Describe 'Get-GraphVaultCredential' { Mock Invoke-GraphSecretManagementGetVault -ModuleName GraphKit { New-TestVault } Mock Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit { $script:BearerSecret } - $result = InModuleScope GraphKit { - Get-GraphVaultCredential -Credential @{ VaultName = 'v'; SecretName = 'bearer' } -AuthMethod BearerToken + $credential = @{ VaultName = 'v'; SecretName = 'bearer' } + $result = InModuleScope GraphKit -Parameters @{ Credential = $credential } { + Get-GraphVaultCredential -Credential $Credential -AuthMethod BearerToken + } + $expectedGeneration = InModuleScope GraphKit -Parameters @{ Credential = $credential } { + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'BearerToken' + Credential = $Credential + } } $result.AuthMethod | Should -Be 'BearerToken' $result.Material | Should -BeOfType [string] $result.Material | Should -Be $script:BearerPlain + $result.OwnsMaterial | Should -BeFalse $result.ManagedIdentityClientId | Should -BeNullOrEmpty + $result.CredentialGeneration | Should -Not -BeNullOrEmpty + $result.CredentialGeneration | Should -BeExactly $expectedGeneration } } @@ -123,18 +182,93 @@ Describe 'Get-GraphVaultCredential' { Mock Invoke-GraphSecretManagementGetVault -ModuleName GraphKit { New-TestVault } Mock Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit { $script:SecurePassword } - $result = InModuleScope GraphKit -Parameters @{ Credential = @{ - PfxPath = $script:PfxPath - Password = @{ VaultName = 'v'; SecretName = 'pfx-password' } - } } { + $credential = @{ + PfxPath = $script:PfxPath + Password = @{ VaultName = 'v'; SecretName = 'pfx-password' } + } + $result = InModuleScope GraphKit -Parameters @{ Credential = $credential } { Get-GraphVaultCredential -Credential $Credential -AuthMethod Certificate } + $expectedGeneration = InModuleScope GraphKit -Parameters @{ Credential = $credential } { + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = $Credential + } + } $result.AuthMethod | Should -Be 'Certificate' $result.Material | Should -BeOfType [System.Security.Cryptography.X509Certificates.X509Certificate2] $result.Material.HasPrivateKey | Should -BeTrue + $result.OwnsMaterial | Should -BeTrue + $result.CredentialGeneration | Should -Be $expectedGeneration + $result.CredentialGeneration | Should -Match 'sha256:[0-9a-f]{64}' $result.ManagedIdentityClientId | Should -BeNullOrEmpty } + + It 'disposes resolved password ownership and zeroes its PFX snapshot when import fails' { + $script:PasswordDisposeProbe = [System.Security.SecureString]::new() + $script:PasswordDisposeProbe.AppendChar('x') + $script:PfxSnapshotProbe = [byte[]] @(1, 2, 3, 4, 5, 6) + + Mock Resolve-GraphVaultPassword -ModuleName GraphKit { $script:PasswordDisposeProbe } + Mock Get-GraphPfxSnapshot -ModuleName GraphKit { + [pscustomobject] @{ + Path = '/test/invalid.pfx' + Bytes = $script:PfxSnapshotProbe + Sha256 = ('a' * 64) + } + } + + { + InModuleScope GraphKit { + Get-GraphVaultCredential -Credential @{ + PfxPath = '/test/invalid.pfx' + Password = @{ VaultName = 'v'; SecretName = 'password' } + } -AuthMethod Certificate + } + } | Should -Throw -ExpectedMessage '*Could not load the PFX certificate*' + + { + $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($script:PasswordDisposeProbe) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } | Should -Throw -ExpectedMessage '*disposed object*SecureString*' + @($script:PfxSnapshotProbe | Where-Object { $_ -ne 0 }).Count | Should -Be 0 + } + + It 'does not dispose a caller-owned SecureString password after PFX resolution' { + $callerPassword = [System.Security.SecureString]::new() + foreach ($ch in $script:PfxPassword.ToCharArray()) { + $callerPassword.AppendChar($ch) + } + + $result = $null + try { + $result = InModuleScope GraphKit -Parameters @{ + Path = $script:PfxPath + Password = $callerPassword + } { + param($Path, $Password) + Get-GraphVaultCredential -Credential @{ + PfxPath = $Path + Password = $Password + } -AuthMethod Certificate + } + + $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($callerPassword) + try { + [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) | Should -Be $script:PfxPassword + } + finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } + } + finally { + if ($null -ne $result) { + $result.Material.Dispose() + } + $callerPassword.Dispose() + } + } } Context 'Certificate (vault material)' { @@ -149,6 +283,7 @@ Describe 'Get-GraphVaultCredential' { $result.AuthMethod | Should -Be 'Certificate' $result.Material | Should -BeOfType [System.Security.Cryptography.X509Certificates.X509Certificate2] $result.Material.HasPrivateKey | Should -BeTrue + $result.OwnsMaterial | Should -BeTrue } It 'builds an X509Certificate2 from base64-encoded PFX material' { @@ -161,6 +296,33 @@ Describe 'Get-GraphVaultCredential' { $result.Material | Should -BeOfType [System.Security.Cryptography.X509Certificates.X509Certificate2] $result.Material.HasPrivateKey | Should -BeTrue + $result.OwnsMaterial | Should -BeTrue + } + + It 'copies a vault-provided certificate so the provider object remains external' { + $external = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $script:NoPasswordPfxBytes + ) + Mock Invoke-GraphSecretManagementGetVault -ModuleName GraphKit { New-TestVault } + Mock Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit { $external } + + $result = $null + try { + $result = InModuleScope GraphKit { + Get-GraphVaultCredential -Credential @{ VaultName = 'v'; CertificateName = 'cert' } -AuthMethod Certificate + } + + [object]::ReferenceEquals($result.Material, $external) | Should -BeFalse + $result.OwnsMaterial | Should -BeTrue + $result.Material.HasPrivateKey | Should -BeTrue + $external.HasPrivateKey | Should -BeTrue + } + finally { + if ($null -ne $result -and $null -ne $result.Material) { + $result.Material.Dispose() + } + $external.Dispose() + } } It 'fails actionably for unusable certificate material, naming supported shapes' { @@ -173,6 +335,45 @@ Describe 'Get-GraphVaultCredential' { } } | Should -Throw -ExpectedMessage '*neither a PFX byte array, a base64-encoded PFX, nor a path to a PFX file*' } + + It 'disposes an encrypted vault-certificate password when conversion fails' { + $script:VaultPasswordDisposeProbe = [System.Security.SecureString]::new() + $script:VaultPasswordDisposeProbe.AppendChar('x') + Mock Invoke-GraphSecretManagementGetVault -ModuleName GraphKit { New-TestVault } + Mock Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit { [byte[]] @(1, 2, 3) } + Mock Resolve-GraphVaultPassword -ModuleName GraphKit { $script:VaultPasswordDisposeProbe } + + { + InModuleScope GraphKit { + Get-GraphVaultCredential -Credential @{ + VaultName = 'v' + CertificateName = 'cert' + Password = @{ VaultName = 'v'; SecretName = 'password' } + } -AuthMethod Certificate + } + } | Should -Throw -ExpectedMessage '*could not be interpreted as a PFX*' + + { + $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($script:VaultPasswordDisposeProbe) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } | Should -Throw -ExpectedMessage '*disposed object*SecureString*' + } + + It 'copies provider SecureStrings before returning owned secret material' { + $ownedCopy = InModuleScope GraphKit -Parameters @{ ProviderSecret = $script:SecureSecret } { + param($ProviderSecret) + ConvertTo-GraphSecureString -Value $ProviderSecret + } + + try { + [object]::ReferenceEquals($ownedCopy, $script:SecureSecret) | Should -BeFalse + [System.Net.NetworkCredential]::new('', $ownedCopy).Password | Should -Be $script:ClientSecretPlain + [System.Net.NetworkCredential]::new('', $script:SecureSecret).Password | Should -Be $script:ClientSecretPlain + } + finally { + $ownedCopy.Dispose() + } + } } Context 'Certificate (store)' { @@ -199,25 +400,45 @@ Describe 'Get-GraphVaultCredential' { Context 'ManagedIdentity' { It 'returns the user-assigned client id with zero vault calls' { - $result = InModuleScope GraphKit { - Get-GraphVaultCredential -Credential @{ ClientId = '7d6e5f44-9999-8888-7777-666655554444' } -AuthMethod ManagedIdentity + $credential = @{ ClientId = '7d6e5f44-9999-8888-7777-666655554444' } + $result = InModuleScope GraphKit -Parameters @{ Credential = $credential } { + Get-GraphVaultCredential -Credential $Credential -AuthMethod ManagedIdentity + } + $expectedGeneration = InModuleScope GraphKit -Parameters @{ Credential = $credential } { + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'ManagedIdentity' + Credential = $Credential + } } $result.AuthMethod | Should -Be 'ManagedIdentity' $result.Material | Should -BeNullOrEmpty $result.ManagedIdentityClientId | Should -Be '7d6e5f44-9999-8888-7777-666655554444' + $result.OwnsMaterial | Should -BeFalse + $result.CredentialGeneration | Should -Not -BeNullOrEmpty + $result.CredentialGeneration | Should -BeExactly $expectedGeneration Should-Invoke Invoke-GraphSecretManagementGetVault -ModuleName GraphKit -Times 0 -Exactly Should-Invoke Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit -Times 0 -Exactly } It 'returns null for a system-assigned identity with zero vault calls' { - $result = InModuleScope GraphKit { - Get-GraphVaultCredential -Credential @{ ClientId = $null } -AuthMethod ManagedIdentity + $credential = @{ ClientId = $null } + $result = InModuleScope GraphKit -Parameters @{ Credential = $credential } { + Get-GraphVaultCredential -Credential $Credential -AuthMethod ManagedIdentity + } + $expectedGeneration = InModuleScope GraphKit -Parameters @{ Credential = $credential } { + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'ManagedIdentity' + Credential = $Credential + } } $result.AuthMethod | Should -Be 'ManagedIdentity' $result.Material | Should -BeNullOrEmpty $result.ManagedIdentityClientId | Should -BeNullOrEmpty + $result.OwnsMaterial | Should -BeFalse + $result.CredentialGeneration | Should -Not -BeNullOrEmpty + $result.CredentialGeneration | Should -BeExactly $expectedGeneration Should-Invoke Invoke-GraphSecretManagementGetVault -ModuleName GraphKit -Times 0 -Exactly Should-Invoke Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit -Times 0 -Exactly } diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 new file mode 100644 index 0000000..c8d88da --- /dev/null +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -0,0 +1,3300 @@ +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).ProviderPath + $script:contractsPath = Join-Path $script:repoRoot 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0/GraphKit.Auth.Contracts.dll' + + function New-GraphKitAuthContractsFixtureAssembly { + param( + [Parameter(Mandatory)] [string] $Root, + [Parameter(Mandatory)] [string] $Marker + ) + + $null = New-Item -ItemType Directory -Path $Root -Force + $sourcePath = Join-Path $Root 'Contracts.cs' + $projectPath = Join-Path $Root 'GraphKit.Auth.Contracts.csproj' + $outputPath = Join-Path $Root 'out' + $assemblyPath = Join-Path $outputPath 'GraphKit.Auth.Contracts.dll' + Set-Content -LiteralPath $sourcePath -NoNewline -Encoding utf8NoBOM -Value @" +using System.Threading; + +namespace GraphKit.Auth +{ + public sealed class GraphTokenResult { } + + public interface IGraphTokenSource + { + GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation); + } + + public static class GraphAuthHost + { + public const string ContractMarker = "$Marker"; + } +} +"@ + Set-Content -LiteralPath $projectPath -NoNewline -Encoding utf8NoBOM -Value @' + + + net8.0 + GraphKit.Auth.Contracts + GraphKit.Auth + enable + disable + true + true + none + + +'@ + + $compilerOutput = & dotnet build $projectPath -c Release -o $outputPath --nologo --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) { + throw "The GraphKit.Auth contracts fixture did not compile: $($compilerOutput | Out-String)" + } + return $assemblyPath + } + + function Invoke-GraphKitAuthContractsCandidateProbe { + param( + [Parameter(Mandatory)] [string] $CandidatePath, + [string] $PreloadPath + ) + + $probePath = Join-Path $TestDrive ('Probe-Contracts-' + [guid]::NewGuid().ToString('N') + '.ps1') + Set-Content -LiteralPath $probePath -NoNewline -Encoding utf8NoBOM -Value @' +param( + [Parameter(Mandatory)] [string] $CandidatePath, + [string] $PreloadPath +) +$ErrorActionPreference = 'Stop' +$candidate = (Resolve-Path -LiteralPath $CandidatePath).ProviderPath + +if (-not [string]::IsNullOrEmpty($PreloadPath)) { + $null = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $PreloadPath).ProviderPath + ) +} + +$stream = [System.IO.File]::OpenRead($candidate) +try { + $peReader = [System.Reflection.PortableExecutable.PEReader]::new($stream) + try { + if (-not $peReader.HasMetadata) { throw "The contracts candidate '$candidate' has no managed metadata." } + $metadata = [System.Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($peReader) + $assemblyDefinition = $metadata.GetAssemblyDefinition() + $candidateName = $metadata.GetString($assemblyDefinition.Name) + $moduleDefinition = $metadata.GetModuleDefinition() + $candidateMvid = $metadata.GetGuid($moduleDefinition.Mvid) + } + finally { + $peReader.Dispose() + } +} +finally { + $stream.Dispose() +} + +if ($candidateName -cne 'GraphKit.Auth.Contracts') { + throw "The contracts candidate has assembly name '$candidateName', not 'GraphKit.Auth.Contracts'." +} + +$alreadyLoaded = @( + [System.Runtime.Loader.AssemblyLoadContext]::Default.Assemblies | + Where-Object { $_.GetName().Name -ceq $candidateName } +) +if ($alreadyLoaded.Count -ne 0) { + $locations = @($alreadyLoaded | ForEach-Object { if ($_.Location) { $_.Location } else { '' } }) -join ', ' + throw "Default ALC already contains '$candidateName' from $locations; refusing candidate '$candidate'." +} + +$candidateSha256 = (Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash.ToLowerInvariant() +$assembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($candidate) +$loadedLocation = [System.IO.Path]::GetFullPath($assembly.Location) +$loadedSha256 = (Get-FileHash -LiteralPath $loadedLocation -Algorithm SHA256).Hash.ToLowerInvariant() +$loadedMvid = $assembly.ManifestModule.ModuleVersionId +$matchingLoaded = @( + [System.Runtime.Loader.AssemblyLoadContext]::Default.Assemblies | + Where-Object { $_.GetName().Name -ceq $candidateName } +) +if ($matchingLoaded.Count -ne 1 -or -not [object]::ReferenceEquals($assembly, $matchingLoaded[0])) { + throw "Default ALC did not retain exactly the candidate '$candidate'." +} +if ($loadedLocation -cne $candidate) { + throw "Default ALC loaded '$loadedLocation' instead of exact candidate '$candidate'." +} +if ($loadedSha256 -cne $candidateSha256) { + throw "Loaded contracts bytes do not match candidate '$candidate'." +} +if ($loadedMvid -ne $candidateMvid) { + throw "Loaded contracts MVID '$loadedMvid' does not match candidate MVID '$candidateMvid'." +} + +$seen = [System.Collections.Generic.HashSet[System.Type]]::new() +function Add-SignatureType { + param([System.Type] $Type) + + if ($null -eq $Type -or -not $seen.Add($Type)) { return } + if ($Type.HasElementType) { Add-SignatureType -Type $Type.GetElementType() } + foreach ($argument in $Type.GetGenericArguments()) { Add-SignatureType -Type $argument } + if ($Type.IsGenericParameter) { + foreach ($constraint in $Type.GetGenericParameterConstraints()) { + Add-SignatureType -Type $constraint + } + } +} + +$bindingFlags = [System.Reflection.BindingFlags]'Public,Instance,Static' +foreach ($type in $assembly.GetExportedTypes()) { + Add-SignatureType -Type $type + Add-SignatureType -Type $type.BaseType + foreach ($interface in $type.GetInterfaces()) { Add-SignatureType -Type $interface } + foreach ($member in $type.GetMembers($bindingFlags)) { + Add-SignatureType -Type $member.DeclaringType + if ($member -is [System.Reflection.MethodInfo]) { + Add-SignatureType -Type $member.ReturnType + foreach ($argument in $member.GetGenericArguments()) { Add-SignatureType -Type $argument } + foreach ($parameter in $member.GetParameters()) { Add-SignatureType -Type $parameter.ParameterType } + } + elseif ($member -is [System.Reflection.ConstructorInfo]) { + foreach ($parameter in $member.GetParameters()) { Add-SignatureType -Type $parameter.ParameterType } + } + elseif ($member -is [System.Reflection.PropertyInfo]) { + Add-SignatureType -Type $member.PropertyType + foreach ($parameter in $member.GetIndexParameters()) { Add-SignatureType -Type $parameter.ParameterType } + } + elseif ($member -is [System.Reflection.FieldInfo]) { + Add-SignatureType -Type $member.FieldType + } + elseif ($member -is [System.Reflection.EventInfo]) { + Add-SignatureType -Type $member.EventHandlerType + } + } +} + +$leaks = @( + $seen | Where-Object { + [string] $_.FullName -like '*Microsoft.Identity.Client*' -or + [string] $_.Assembly.FullName -like '*Microsoft.Identity.Client*' + } | ForEach-Object { "$($_.Assembly.FullName)|$($_.FullName)" } +) +$hostType = $assembly.GetType('GraphKit.Auth.GraphAuthHost', $true, $false) +$sourceType = $assembly.GetType('GraphKit.Auth.IGraphTokenSource', $true, $false) +[pscustomobject]@{ + CandidatePath = $candidate + LoadedLocation = $loadedLocation + CandidateSha256 = $candidateSha256 + LoadedSha256 = $loadedSha256 + CandidateMvid = $candidateMvid.ToString('D') + LoadedMvid = $loadedMvid.ToString('D') + ContractMarker = $hostType.GetField('ContractMarker').GetRawConstantValue() + AcquireReturnType = $sourceType.GetMethod('Acquire').ReturnType.FullName + Leaks = [object[]] $leaks +} | ConvertTo-Json -Compress -Depth 4 +'@ + + $arguments = @('-NoLogo', '-NoProfile', '-File', $probePath, '-CandidatePath', $CandidatePath) + if (-not [string]::IsNullOrEmpty($PreloadPath)) { + $arguments += @('-PreloadPath', $PreloadPath) + } + $raw = & pwsh @arguments 2>&1 + $exitCode = $LASTEXITCODE + $json = @($raw | Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') }) | Select-Object -Last 1 + [pscustomobject] @{ + ExitCode = $exitCode + Data = if ($json) { $json | ConvertFrom-Json } else { $null } + Output = ($raw | Out-String).Trim() + } + } + + function New-GraphKitAuthProviderFixtureAssembly { + param( + [Parameter(Mandatory)] [string] $Root, + [string] $AssemblyName = 'GraphKit.Auth', + [string] $AssemblyVersion = '1.0.0.0', + [string] $AdditionalReferencePath, + [string] $PublicSurfaceDeclaration + ) + + $null = New-Item -ItemType Directory -Path $Root -Force + $sourcePath = Join-Path $Root 'Provider.cs' + $projectPath = Join-Path $Root 'Provider.csproj' + $escapedContractsPath = [System.Security.SecurityElement]::Escape($script:contractsPath) + Set-Content -LiteralPath $sourcePath -NoNewline -Encoding utf8NoBOM -Value @' +using System; +using System.IO; +using System.Security; +using System.Threading; +using GraphKit.Auth; + +namespace GraphKit.Auth; + +public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory +{ + public GraphTokenSourceFactory() + { + if (string.Equals( + Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_FACTORY_CONSTRUCTION_FAILURE"), + "1", + StringComparison.Ordinal)) + { + throw new ProviderOwnedConstructionException(); + } + } + + public Uri FrameworkUri => new("https://graph.microsoft.com"); + public IGraphTokenSource Create(GraphTokenRequest request) + { + string? factoryMarker = Environment.GetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_FACTORY_ENTRY_MARKER"); + if (!string.IsNullOrEmpty(factoryMarker)) + { + File.AppendAllText(factoryMarker, "entered" + Environment.NewLine); + } + + if (string.Equals( + Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_SOURCE_CONSTRUCTION_FAILURE"), + "1", + StringComparison.Ordinal)) + { + IDisposable? ownedMaterial = request.Credential switch + { + CertificateCredential { OwnsMaterial: true } certificate => certificate.Certificate, + ClientSecretCredential { OwnsMaterial: true } secret => secret.Secret, + _ => null + }; + ownedMaterial?.Dispose(); + string? cleanupMarker = Environment.GetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_FACTORY_CLEANUP_MARKER"); + if (!string.IsNullOrEmpty(cleanupMarker)) + { + File.AppendAllText(cleanupMarker, "disposed" + Environment.NewLine); + } + throw ProviderFailure.Create("source-construction"); + } + + return new FixtureTokenSource(request); + } + // TEST_PUBLIC_SURFACE +} + +internal sealed class FixtureTokenSource : IGraphTokenSource +{ + private static readonly ManualResetEventSlim BlockedAcquireEntered = new(false); + private static readonly ManualResetEventSlim BlockedAcquireRelease = new(false); + private readonly GraphTokenRequest _request; + private readonly IDisposable? _ownedMaterial; + private readonly string? _disposeMarker = Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_MARKER"); + private int _disposed; + + public FixtureTokenSource(GraphTokenRequest request) + { + _request = request; + _ownedMaterial = request.Credential switch + { + CertificateCredential { OwnsMaterial: true } certificate => certificate.Certificate, + ClientSecretCredential { OwnsMaterial: true } secret => secret.Secret, + _ => null + }; + } + + public bool CanRefresh => true; + public string AuthMode => IsFailureMode("ReadGraph") + ? throw ProviderFailure.Create("read") + : IsFailureMode("ReadUnsafeMetadata") + ? throw ProviderFailure.CreateUnsafeMetadata() + : _request.AuthMode.ToString(); + public string Audience => IsFailureMode("ReadUnexpected") + ? throw new ProviderOwnedOperationalException() + : _request.Resource.AbsoluteUri; + public string? ClientId => _request.ClientId?.ToString("D"); + public DateTimeOffset ExpiresOn { get; private set; } + public string? VerifiedTenantId { get; private set; } + public string CredentialGeneration => _request.CredentialGeneration; + + public GraphTokenResult Acquire(bool forceRefresh, CancellationToken cancellation) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + cancellation.ThrowIfCancellationRequested(); + if (string.Equals( + Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_BLOCK_ACQUIRE"), + "1", + StringComparison.Ordinal)) + { + BlockedAcquireEntered.Set(); + if (!BlockedAcquireRelease.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("The blocked provider acquisition was not released."); + } + } + + if (forceRefresh) + { + throw ProviderFailure.Create("acquire"); + } + + ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(5); + return new GraphTokenResult + { + AccessToken = "fixture-token", + ExpiresOnUtc = ExpiresOn, + ReceivedOnUtc = DateTimeOffset.UtcNow, + TokenType = "Bearer", + Scopes = new[] { _request.Resource.AbsoluteUri + "/.default" }, + TokenFingerprint = "fixture-fingerprint", + CredentialGeneration = _request.CredentialGeneration + }; + } + + public void AdoptSharedResult(GraphTokenResult result, bool forceRefresh) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + if (IsFailureMode("AdoptGraph")) + { + throw ProviderFailure.Create("adopt"); + } + + ExpiresOn = result.ExpiresOnUtc; + VerifiedTenantId = result.VerifiedTenantId; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (!string.IsNullOrEmpty(_disposeMarker)) + { + File.AppendAllText(_disposeMarker, "disposed" + Environment.NewLine); + } + + _ownedMaterial?.Dispose(); + + if (string.Equals( + Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_FAILURE"), + "1", + StringComparison.Ordinal)) + { + throw new ProviderOwnedDisposeException(); + } + } + + internal static bool WaitForBlockedAcquire(TimeSpan timeout) => + BlockedAcquireEntered.Wait(timeout); + + internal static void ReleaseBlockedAcquire() => BlockedAcquireRelease.Set(); + + private static bool IsFailureMode(string expected) => string.Equals( + Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_PROVIDER_FAILURE_MEMBER"), + expected, + StringComparison.Ordinal); +} + +internal static class ProviderFailure +{ + internal static GraphAuthException Create(string member) + { + var failure = new GraphAuthException( + "fixture", + "Fixture", + "isolated-provider-" + member + "-sensitive-detail", + TimeSpan.FromSeconds(7), + "fixture-correlation"); + failure.Data["isolated-provider-data"] = new ProviderOwnedData(); + return failure; + } + + internal static GraphAuthException CreateUnsafeMetadata() + { + return new GraphAuthException( + "ProviderOwned/unsafe-code", + "Unsafe Category", + "isolated-provider-unsafe-metadata-sensitive-detail", + TimeSpan.FromSeconds(7), + "isolated-provider-correlation\nunsafe"); + } +} + +internal sealed class ProviderOwnedConstructionException : Exception +{ + internal ProviderOwnedConstructionException() + : base( + "isolated-provider-construction-sensitive-detail", + new ProviderOwnedInnerException()) + { + Data["isolated-provider-data"] = new ProviderOwnedData(); + } +} + +internal sealed class ProviderOwnedOperationalException : Exception +{ + internal ProviderOwnedOperationalException() + : base( + "isolated-provider-operation-sensitive-detail", + new ProviderOwnedInnerException()) + { + Data["isolated-provider-data"] = new ProviderOwnedData(); + } +} + +internal sealed class ProviderOwnedDisposeException : Exception +{ + internal const string ForbiddenMessage = "isolated-provider-disposal-sensitive-detail"; + + internal ProviderOwnedDisposeException() + : base(ForbiddenMessage, new ProviderOwnedInnerException()) + { + Data["isolated-provider-data"] = new ProviderOwnedData(); + } +} + +internal sealed class ProviderOwnedInnerException : Exception +{ + internal ProviderOwnedInnerException() + : base("isolated-provider-inner-sensitive-detail") + { + } +} + +internal sealed class ProviderOwnedData +{ + public override string ToString() => "isolated-provider-data-sensitive-detail"; +} +'@ + if (-not [string]::IsNullOrWhiteSpace($PublicSurfaceDeclaration)) { + $providerSource = Get-Content -LiteralPath $sourcePath -Raw + Set-Content -LiteralPath $sourcePath -NoNewline -Encoding utf8NoBOM -Value ( + $providerSource.Replace('// TEST_PUBLIC_SURFACE', $PublicSurfaceDeclaration) + ) + } + $additionalReference = if ([string]::IsNullOrWhiteSpace($AdditionalReferencePath)) { + '' + } + else { + $escapedAdditionalReferencePath = [System.Security.SecurityElement]::Escape($AdditionalReferencePath) + @" + + $escapedAdditionalReferencePath + true + +"@ + } + Set-Content -LiteralPath $projectPath -NoNewline -Encoding utf8NoBOM -Value @" + + + net8.0 + $AssemblyName + $AssemblyVersion + enable + enable + true + true + none + + + + $escapedContractsPath + false + +$additionalReference + + +"@ + + $outputPath = Join-Path $Root 'out' + $compilerOutput = & dotnet build $projectPath -c Release -o $outputPath --nologo --verbosity quiet 2>&1 + $assemblyPath = Join-Path $outputPath "$AssemblyName.dll" + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) { + throw "The GraphKit.Auth provider fixture did not compile: $($compilerOutput | Out-String)" + } + return $assemblyPath + } + + function New-SystemImpostorFixtureAssembly { + param([Parameter(Mandatory)] [string] $Root) + + $null = New-Item -ItemType Directory -Path $Root -Force + $sourcePath = Join-Path $Root 'Counterfeit.cs' + $projectPath = Join-Path $Root 'System.Impostor.csproj' + $outputPath = Join-Path $Root 'out' + Set-Content -LiteralPath $sourcePath -NoNewline -Encoding utf8NoBOM -Value @' +namespace System.Impostor; + +public sealed class Counterfeit +{ +} +'@ + Set-Content -LiteralPath $projectPath -NoNewline -Encoding utf8NoBOM -Value @' + + + net8.0 + System.Impostor + 1.0.0.0 + enable + enable + true + true + none + + +'@ + $compilerOutput = & dotnet build $projectPath -c Release -o $outputPath --nologo --verbosity quiet 2>&1 + $assemblyPath = Join-Path $outputPath 'System.Impostor.dll' + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) { + throw "The System.Impostor fixture did not compile: $($compilerOutput | Out-String)" + } + return $assemblyPath + } + + function New-GraphKitAuthRuntimeHarnessAssembly { + param([Parameter(Mandatory)] [string] $Root) + + $null = New-Item -ItemType Directory -Path $Root -Force + $sourcePath = Join-Path $Root 'RuntimeHarness.cs' + $projectPath = Join-Path $Root 'RuntimeHarness.csproj' + $outputPath = Join-Path $Root 'out' + $escapedContractsPath = [System.Security.SecurityElement]::Escape($script:contractsPath) + Set-Content -LiteralPath $sourcePath -NoNewline -Encoding utf8NoBOM -Value @' +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using System.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GraphKit.Auth; + +public static class GraphKitAuthRuntimeHarness +{ + public static string OwnershipLedgerProof(string payloadRoot, string markerRoot) + { + Directory.CreateDirectory(markerRoot); + var distinctHost = RunRace(payloadRoot, markerRoot, useCertificate: false, distinctHosts: true); + var sameHost = RunRace(payloadRoot, markerRoot, useCertificate: true, distinctHosts: false); + var reentrant = RunReentrant(payloadRoot); + var stopped = RunPreProviderRejection(payloadRoot, clearFactory: false); + var missingFactory = RunPreProviderRejection(payloadRoot, clearFactory: true); + var postProvider = RunPostProviderFailure(payloadRoot, markerRoot); + var blockedFactoryShutdown = RunBlockedFactoryShutdown(payloadRoot); + var sanitized = RunSanitizedCleanupFailure(payloadRoot); + var weakKeys = RunWeakKeyProof(payloadRoot); + return JsonSerializer.Serialize(new + { + DistinctHostSecretRace = distinctHost, + SameHostCertificateRace = sameHost, + ReentrantFactory = reentrant, + StoppedHost = stopped, + MissingFactory = missingFactory, + PostProviderFailure = postProvider, + BlockedFactoryShutdown = blockedFactoryShutdown, + SanitizedCleanupFailure = sanitized, + WeakKeys = weakKeys + }); + } + + private static object RunRace( + string payloadRoot, + string markerRoot, + bool useCertificate, + bool distinctHosts) + { + GraphAuthHost firstHost = NewHost(payloadRoot); + GraphAuthHost secondHost = distinctHosts ? NewHost(payloadRoot) : firstHost; + IDisposable material = useCertificate + ? new CountingOwnedCertificate(CreatePfxBytes()) + : CreateSecret(); + GraphTokenRequest firstRequest = NewOwnedRequest(material, useCertificate); + GraphTokenRequest secondRequest = NewOwnedRequest(material, useCertificate); + var barrier = new BarrierFactory(GetFactory(firstHost)); + SetFactory(firstHost, barrier); + IGraphTokenSource? firstSource = null; + IGraphTokenSource? secondSource = null; + Exception? firstFailure = null; + Exception? secondFailure = null; + string disposeMarker = Path.Combine( + markerRoot, + $"race-{(useCertificate ? "certificate" : "secret")}-{(distinctHosts ? "distinct" : "same")}.txt"); + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_MARKER", disposeMarker); + try + { + Task first = Task.Run(() => + { + try { firstSource = firstHost.CreateSource(firstRequest); } + catch (Exception exception) { firstFailure = exception; } + }); + if (!barrier.Entered.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The winning provider factory did not enter its barrier."); + } + + Task second = Task.Run(() => + { + try { secondSource = secondHost.CreateSource(secondRequest); } + catch (Exception exception) { secondFailure = exception; } + }); + if (!second.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The duplicate material claim did not finish before the winner was released."); + } + + bool winnerUsableBeforeRelease = MaterialIsUsable(material); + barrier.Release.Set(); + if (!first.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The winning material claim did not finish after release."); + } + + int acceptedCount = (firstSource is null ? 0 : 1) + (secondSource is null ? 0 : 1); + int rejectedCount = (firstFailure is null ? 0 : 1) + (secondFailure is null ? 0 : 1); + Exception? rejection = firstFailure ?? secondFailure; + firstSource?.Dispose(); + firstSource?.Dispose(); + secondSource?.Dispose(); + secondSource?.Dispose(); + return new + { + DistinctRequests = !ReferenceEquals(firstRequest, secondRequest), + DistinctCredentials = !ReferenceEquals(firstRequest.Credential, secondRequest.Credential), + SharedMaterial = ReferenceEquals(GetOwnedMaterial(firstRequest), GetOwnedMaterial(secondRequest)), + AcceptedCount = acceptedCount, + RejectedCount = rejectedCount, + FactoryEntryCount = barrier.EntryCount, + RejectionType = rejection?.GetType().FullName, + RejectionCode = (rejection as GraphAuthException)?.Code, + RejectionCategory = (rejection as GraphAuthException)?.Category, + WinnerUsableBeforeRelease = winnerUsableBeforeRelease, + MaterialDisposedAfterWinner = !MaterialIsUsable(material), + MaterialDisposeCount = (material as CountingOwnedCertificate)?.DisposeCount, + WinnerDisposeCount = File.Exists(disposeMarker) + ? File.ReadAllLines(disposeMarker).Length + : 0 + }; + } + finally + { + barrier.Release.Set(); + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_MARKER", null); + try { firstSource?.Dispose(); } catch { } + try { secondSource?.Dispose(); } catch { } + firstHost.Dispose(); + if (distinctHosts) secondHost.Dispose(); + ReleaseHarnessMaterial(material); + } + } + + private static object RunReentrant(string payloadRoot) + { + using GraphAuthHost host = NewHost(payloadRoot); + SecureString material = CreateSecret(); + GraphTokenRequest outer = NewOwnedRequest(material, useCertificate: false); + GraphTokenRequest nested = NewOwnedRequest(material, useCertificate: false); + var factory = new ReentrantFactory(GetFactory(host), host, nested); + SetFactory(host, factory); + using IGraphTokenSource source = host.CreateSource(outer); + return new + { + DistinctRequests = !ReferenceEquals(outer, nested), + DistinctCredentials = !ReferenceEquals(outer.Credential, nested.Credential), + SharedMaterial = ReferenceEquals(GetOwnedMaterial(outer), GetOwnedMaterial(nested)), + FactoryEntryCount = factory.EntryCount, + NestedFailureType = factory.NestedFailure?.GetType().FullName, + NestedFailureCode = (factory.NestedFailure as GraphAuthException)?.Code, + NestedFailureCategory = (factory.NestedFailure as GraphAuthException)?.Category, + MaterialUsableBeforeWinnerDisposal = MaterialIsUsable(material) + }; + } + + private static object RunPreProviderRejection(string payloadRoot, bool clearFactory) + { + GraphAuthHost rejectingHost = NewHost(payloadRoot); + CountingOwnedCertificate material = new(CreatePfxBytes()); + GraphTokenRequest first = NewOwnedRequest(material, useCertificate: true); + GraphTokenRequest second = NewOwnedRequest(material, useCertificate: true); + if (clearFactory) + { + SetFactory(rejectingHost, null); + } + else + { + rejectingHost.Dispose(); + } + + Exception initial = CaptureOwnershipFailure(() => rejectingHost.CreateSource(first)); + using GraphAuthHost retryHost = NewHost(payloadRoot); + var retryFactory = new CountingFactory(GetFactory(retryHost)); + SetFactory(retryHost, retryFactory); + Exception repeated = CaptureOwnershipFailure(() => retryHost.CreateSource(second)); + if (clearFactory) rejectingHost.Dispose(); + return new + { + InitialFailureType = initial.GetType().FullName, + MaterialDisposed = !MaterialIsUsable(material), + MaterialDisposeCount = material.DisposeCount, + RepeatedFailureType = repeated.GetType().FullName, + RepeatedFailureCode = (repeated as GraphAuthException)?.Code, + RepeatedFailureCategory = (repeated as GraphAuthException)?.Category, + FactoryEntryCount = retryFactory.EntryCount + }; + } + + private static object RunPostProviderFailure(string payloadRoot, string markerRoot) + { + string entryMarker = Path.Combine(markerRoot, "post-provider-entry.txt"); + string cleanupMarker = Path.Combine(markerRoot, "post-provider-cleanup.txt"); + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_FACTORY_ENTRY_MARKER", entryMarker); + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_FACTORY_CLEANUP_MARKER", cleanupMarker); + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_SOURCE_CONSTRUCTION_FAILURE", "1"); + CountingOwnedCertificate material = new(CreatePfxBytes()); + GraphTokenRequest first = NewOwnedRequest(material, useCertificate: true); + GraphTokenRequest second = NewOwnedRequest(material, useCertificate: true); + try + { + using GraphAuthHost firstHost = NewHost(payloadRoot); + Exception initial = CaptureOwnershipFailure(() => firstHost.CreateSource(first)); + using GraphAuthHost retryHost = NewHost(payloadRoot); + Exception repeated = CaptureOwnershipFailure(() => retryHost.CreateSource(second)); + return new + { + InitialFailureType = initial.GetType().FullName, + InitialFailureCode = (initial as GraphAuthException)?.Code, + InitialFailureCategory = (initial as GraphAuthException)?.Category, + ContainsSensitiveDetail = DescribeFailure(initial).Contains( + "isolated-provider-source-construction-sensitive-detail", + StringComparison.Ordinal), + MaterialDisposed = !MaterialIsUsable(material), + MaterialDisposeCount = material.DisposeCount, + RepeatedFailureCode = (repeated as GraphAuthException)?.Code, + FactoryEntryCount = File.Exists(entryMarker) + ? File.ReadAllLines(entryMarker).Length + : 0, + ProviderCleanupCount = File.Exists(cleanupMarker) + ? File.ReadAllLines(cleanupMarker).Length + : 0 + }; + } + finally + { + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_FACTORY_ENTRY_MARKER", null); + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_FACTORY_CLEANUP_MARKER", null); + Environment.SetEnvironmentVariable("GRAPHKIT_AUTH_TEST_SOURCE_CONSTRUCTION_FAILURE", null); + material.DisposeWithoutCounting(); + } + } + + private static object RunBlockedFactoryShutdown(string payloadRoot) + { + GraphAuthHost host = NewHost(payloadRoot); + var barrier = new BarrierFactory(GetFactory(host)); + SetFactory(host, barrier); + GraphTokenRequest request = new( + "Global", + Guid.Parse("00000000-0000-0000-0000-000000000001"), + new Uri("https://login.microsoftonline.com"), + new Uri("https://graph.microsoft.com"), + null, + GraphAuthMode.BearerToken, + new FixedBearerCredential("blocked-factory-fixture"), + "blocked-factory-generation"); + IGraphTokenSource? source = null; + Exception? createFailure = null; + Task? create = null; + Task? dispose = null; + try + { + create = Task.Factory.StartNew( + () => + { + try { source = host.CreateSource(request); } + catch (Exception exception) { createFailure = exception; } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + if (!barrier.Entered.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The blocked construction factory did not start."); + } + + dispose = Task.Factory.StartNew( + host.Dispose, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + bool disposeCompletedWhileFactoryBlocked = dispose.Wait(TimeSpan.FromSeconds(1)); + barrier.Release.Set(); + if (!Task.WaitAll(new[] { create, dispose }, TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("Blocked construction shutdown did not finish after release."); + } + + return new + { + DisposeCompletedWhileFactoryBlocked = disposeCompletedWhileFactoryBlocked, + SourceRegistered = source is not null, + CreateFailureType = createFailure?.GetType().FullName, + FactoryEntryCount = barrier.EntryCount + }; + } + finally + { + barrier.Release.Set(); + try { source?.Dispose(); } catch { } + try { create?.Wait(TimeSpan.FromSeconds(5)); } catch { } + try { dispose?.Wait(TimeSpan.FromSeconds(5)); } catch { } + try { host.Dispose(); } catch { } + } + } + + private static object RunSanitizedCleanupFailure(string payloadRoot) + { + GraphAuthHost stopped = NewHost(payloadRoot); + stopped.Dispose(); + ThrowingOwnedCertificate material = new(CreatePfxBytes()); + GraphTokenRequest request = NewOwnedRequest(material, useCertificate: true); + Exception failure = CaptureOwnershipFailure(() => stopped.CreateSource(request)); + string failureText = DescribeFailure(failure); + var result = new + { + FailureType = failure.GetType().FullName, + FailureCode = (failure as GraphAuthException)?.Code, + FailureCategory = (failure as GraphAuthException)?.Category, + FailureMessage = failure.Message, + InnerExceptionIsNull = failure.InnerException is null, + DataCount = failure.Data.Count, + ContainsSensitiveDetail = failureText.Contains( + ThrowingOwnedCertificate.SensitiveDetail, + StringComparison.Ordinal), + ContainsRawCleanupType = failureText.Contains( + typeof(InvalidOperationException).FullName!, + StringComparison.Ordinal), + ContainsRawCleanupStack = failureText.Contains( + nameof(ThrowingOwnedCertificate), + StringComparison.Ordinal) || failureText.Contains( + "System.IDisposable.Dispose", + StringComparison.Ordinal), + DisposeCount = material.DisposeCount + }; + ((X509Certificate2)material).Dispose(); + return result; + } + + private static object RunWeakKeyProof(string payloadRoot) + { + (WeakReference material, WeakReference credential, WeakReference request) = + CreateRejectedWeakReferences(payloadRoot); + ForceCollection(material); + ForceCollection(credential); + ForceCollection(request); + return new + { + MaterialAlive = material.IsAlive, + CredentialAlive = credential.IsAlive, + RequestAlive = request.IsAlive + }; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (WeakReference, WeakReference, WeakReference) CreateRejectedWeakReferences( + string payloadRoot) + { + GraphAuthHost stopped = NewHost(payloadRoot); + stopped.Dispose(); + SecureString material = CreateSecret(); + GraphTokenRequest request = NewOwnedRequest(material, useCertificate: false); + GraphCredential credential = request.Credential; + _ = CaptureOwnershipFailure(() => stopped.CreateSource(request)); + return (new WeakReference(material), new WeakReference(credential), new WeakReference(request)); + } + + private static GraphAuthHost NewHost(string payloadRoot) => new( + payloadRoot, + new Version(1, 0, 0, 0), + TimeSpan.FromSeconds(2)); + + private static IGraphTokenSourceFactory? GetFactory(GraphAuthHost host) => + (IGraphTokenSourceFactory?)typeof(GraphAuthHost) + .GetField("_factory", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(host); + + private static void SetFactory(GraphAuthHost host, IGraphTokenSourceFactory? factory) => + (typeof(GraphAuthHost).GetField("_factory", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Host factory field was not found.")) + .SetValue(host, factory); + + private static GraphTokenRequest NewOwnedRequest(IDisposable material, bool useCertificate) + { + GraphCredential credential = useCertificate + ? new CertificateCredential((X509Certificate2)material, ownsMaterial: true) + : new ClientSecretCredential((SecureString)material, ownsMaterial: true); + return new GraphTokenRequest( + "Global", + Guid.Parse("00000000-0000-0000-0000-000000000001"), + new Uri("https://login.microsoftonline.com"), + new Uri("https://graph.microsoft.com"), + Guid.Parse("00000000-0000-0000-0000-000000000002"), + useCertificate ? GraphAuthMode.Certificate : GraphAuthMode.ClientSecret, + credential, + "owned-material-generation"); + } + + private static IDisposable? GetOwnedMaterial(GraphTokenRequest request) => + request.Credential switch + { + CertificateCredential certificate => certificate.Certificate, + ClientSecretCredential secret => secret.Secret, + _ => null + }; + + private static SecureString CreateSecret() + { + SecureString value = new(); + foreach (char character in "task6-owned-secret") value.AppendChar(character); + value.MakeReadOnly(); + return value; + } + + private static X509Certificate2 CreateCertificate() => + new(CreatePfxBytes()); + + private static void ReleaseHarnessMaterial(IDisposable material) + { + if (material is CountingOwnedCertificate counting) + { + counting.DisposeWithoutCounting(); + } + else + { + material.Dispose(); + } + } + + private static byte[] CreatePfxBytes() + { + using RSA rsa = RSA.Create(2048); + CertificateRequest request = new( + "CN=GraphKit-Task6-Ownership", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using X509Certificate2 certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddHours(1)); + return certificate.Export(X509ContentType.Pkcs12); + } + + private static bool MaterialIsUsable(IDisposable material) + { + try + { + if (material is SecureString secret) + { + using SecureString copy = secret.Copy(); + } + else + { + _ = ((X509Certificate2)material).GetCertHash(); + } + return true; + } + catch (ObjectDisposedException) { return false; } + catch (CryptographicException) { return false; } + } + + private static Exception CaptureOwnershipFailure(Action action) + { + try + { + action(); + return new InvalidOperationException("The expected ownership operation succeeded."); + } + catch (Exception exception) + { + return exception; + } + } + + private class CountingFactory : IGraphTokenSourceFactory + { + protected readonly IGraphTokenSourceFactory Inner; + private int _entryCount; + + internal CountingFactory(IGraphTokenSourceFactory? inner) => + Inner = inner ?? throw new InvalidOperationException("Provider factory was unavailable."); + + internal int EntryCount => Volatile.Read(ref _entryCount); + + protected void RecordEntry() => Interlocked.Increment(ref _entryCount); + + public virtual IGraphTokenSource Create(GraphTokenRequest request) + { + RecordEntry(); + return Inner.Create(request); + } + } + + private sealed class BarrierFactory : CountingFactory + { + internal readonly ManualResetEventSlim Entered = new(false); + internal readonly ManualResetEventSlim Release = new(false); + + internal BarrierFactory(IGraphTokenSourceFactory? inner) : base(inner) { } + + public override IGraphTokenSource Create(GraphTokenRequest request) + { + RecordEntry(); + Entered.Set(); + if (!Release.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("The ownership race factory was not released."); + } + return Inner.Create(request); + } + + } + + private sealed class ReentrantFactory : CountingFactory + { + private readonly GraphAuthHost _host; + private readonly GraphTokenRequest _nested; + internal Exception? NestedFailure { get; private set; } + + internal ReentrantFactory( + IGraphTokenSourceFactory? inner, + GraphAuthHost host, + GraphTokenRequest nested) : base(inner) + { + _host = host; + _nested = nested; + } + + public override IGraphTokenSource Create(GraphTokenRequest request) + { + try { _host.CreateSource(_nested); } + catch (Exception exception) { NestedFailure = exception; } + return base.Create(request); + } + } + + private sealed class ThrowingOwnedCertificate : X509Certificate2, IDisposable + { + internal const string SensitiveDetail = "task6-sensitive-cleanup-detail"; + private int _disposeCount; + + internal ThrowingOwnedCertificate(byte[] pfx) : base(pfx) { } + internal int DisposeCount => Volatile.Read(ref _disposeCount); + + void IDisposable.Dispose() + { + Interlocked.Increment(ref _disposeCount); + throw new InvalidOperationException(SensitiveDetail); + } + } + + private sealed class CountingOwnedCertificate : X509Certificate2, IDisposable + { + private int _disposeCount; + + internal CountingOwnedCertificate(byte[] pfx) : base(pfx) { } + internal int DisposeCount => Volatile.Read(ref _disposeCount); + + public new void Dispose() + { + Interlocked.Increment(ref _disposeCount); + base.Dispose(); + } + + internal void DisposeWithoutCounting() => base.Dispose(); + } + + public static string RetainedFactoryConstructionFailure(string payloadRoot) + { + WeakReference? weakReference = null; + AssemblyLoadEventHandler handler = (_, args) => + { + AssemblyLoadContext? context = AssemblyLoadContext.GetLoadContext( + args.LoadedAssembly); + if (string.Equals( + args.LoadedAssembly.GetName().Name, + "GraphKit.Auth", + StringComparison.Ordinal) && + context?.IsCollectible is true) + { + weakReference = new WeakReference(context, trackResurrection: false); + } + }; + AppDomain.CurrentDomain.AssemblyLoad += handler; + Task construction = Task.Run(() => new GraphAuthHost( + payloadRoot, + new Version(1, 0, 0, 0), + TimeSpan.FromSeconds(2))); + Exception retainedFailure = CaptureTaskException(construction); + AppDomain.CurrentDomain.AssemblyLoad -= handler; + WeakReference collectible = weakReference ?? + throw new InvalidOperationException( + "The collectible provider context was not observed during construction."); + + ForceCollection(collectible); + return JsonSerializer.Serialize(new + { + Failure = DescribeFailure(retainedFailure), + TaskFailure = DescribeFailure(construction.Exception), + LoadContextAliveWhileExceptionAndTaskReferenced = collectible.IsAlive + }); + } + + public static string RetainedSourceConstructionFailure( + GraphAuthHost host, + GraphTokenRequest request) + { + WeakReference weakReference = host.LoadContextWeakReference; + Task construction = Task.Run(() => host.CreateSource(request)); + Exception retainedFailure = CaptureTaskException(construction); + + host.Dispose(); + ForceCollection(weakReference); + return JsonSerializer.Serialize(new + { + Failure = DescribeFailure(retainedFailure), + TaskFailure = DescribeFailure(construction.Exception), + HostProviderReferencesCleared = HostProviderReferencesAreCleared( + host, + BindingFlags.Instance | BindingFlags.NonPublic), + LoadContextAliveWhileExceptionHostAndTaskReferenced = weakReference.IsAlive + }); + } + + public static string RetainedProviderBoundaryFailures( + GraphAuthHost host, + IGraphTokenSource source) + { + WeakReference weakReference = host.LoadContextWeakReference; + var kinds = new List(); + var failures = new List(); + var tasks = new List(); + + void Run(string kind, Action action) + { + Task task = Task.Run(action); + kinds.Add(kind); + tasks.Add(task); + failures.Add(CaptureTaskException(task)); + } + + Environment.SetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_PROVIDER_FAILURE_MEMBER", + "ReadGraph"); + Run("ReadGraph", () => _ = source.AuthMode); + Environment.SetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_PROVIDER_FAILURE_MEMBER", + "ReadUnsafeMetadata"); + Run("ReadUnsafeMetadata", () => _ = source.AuthMode); + Environment.SetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_PROVIDER_FAILURE_MEMBER", + "AdoptGraph"); + Run("AdoptGraph", () => source.AdoptSharedResult(new GraphTokenResult + { + AccessToken = "fixture-adopted-token", + ExpiresOnUtc = DateTimeOffset.UtcNow.AddMinutes(5), + ReceivedOnUtc = DateTimeOffset.UtcNow, + TokenType = "Bearer", + Scopes = new[] { "https://graph.microsoft.com/.default" }, + TokenFingerprint = "fixture-adopted-fingerprint", + CredentialGeneration = "generation-1" + }, false)); + Environment.SetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_PROVIDER_FAILURE_MEMBER", + "ReadUnexpected"); + Run("ReadUnexpected", () => _ = source.Audience); + Environment.SetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_PROVIDER_FAILURE_MEMBER", + null); + Run("AcquireGraph", () => source.Acquire(true, CancellationToken.None)); + using (var cancellation = new CancellationTokenSource()) + { + cancellation.Cancel(); + Run("Cancellation", () => source.Acquire(false, cancellation.Token)); + } + + Environment.SetEnvironmentVariable( + "GRAPHKIT_AUTH_TEST_PROVIDER_FAILURE_MEMBER", + null); + source.Dispose(); + host.Dispose(); + ForceCollection(weakReference); + return JsonSerializer.Serialize(new + { + Failures = kinds.Select((kind, index) => new + { + Kind = kind, + Description = DescribeFailure(failures[index]) + }).ToArray(), + TaskFailures = kinds.Select((kind, index) => new + { + Kind = kind, + Description = DescribeFailure(tasks[index].Exception) + }).ToArray(), + CancellationTokenIsCancellationRequested = + ((OperationCanceledException)failures[^1]).CancellationToken + .IsCancellationRequested, + HostProviderReferencesCleared = HostProviderReferencesAreCleared( + host, + BindingFlags.Instance | BindingFlags.NonPublic), + LoadContextAliveWhileExceptionsHostAndTasksReferenced = weakReference.IsAlive + }); + } + + public static string ConcurrentDispose( + GraphAuthHost host, + IGraphTokenSource source, + string disposeMarker) + { + BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + var shutdown = (CancellationTokenSource)(typeof(GraphAuthHost) + .GetField("_shutdown", privateInstance)?.GetValue(host) + ?? throw new InvalidOperationException("Host shutdown source was not found.")); + var stateField = typeof(GraphAuthHost).GetField("_state", privateInstance) + ?? throw new InvalidOperationException("Host state field was not found."); + Type proxyType = source.GetType(); + var innerField = proxyType.GetField("_inner", privateInstance) + ?? throw new InvalidOperationException("Proxy inner field was not found."); + var ownerField = proxyType.GetField("_owner", privateInstance) + ?? throw new InvalidOperationException("Proxy owner field was not found."); + + using var ownerReachedCancel = new ManualResetEventSlim(false); + using var releaseOwner = new ManualResetEventSlim(false); + using var nonOwnerStarted = new ManualResetEventSlim(false); + using CancellationTokenRegistration registration = shutdown.Token.Register(() => + { + ownerReachedCancel.Set(); + if (!releaseOwner.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The concurrent-dispose owner was not released."); + } + }); + + Task owner = Task.Factory.StartNew( + host.Dispose, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + if (!ownerReachedCancel.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The shutdown owner did not reach cancellation."); + } + + Task nonOwner = Task.Factory.StartNew( + () => + { + nonOwnerStarted.Set(); + host.Dispose(); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + if (!nonOwnerStarted.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The non-owner dispose caller did not start."); + } + + bool nonOwnerCompletedBeforeRelease = nonOwner.Wait(TimeSpan.FromMilliseconds(500)); + int stateBeforeRelease = (int)(stateField.GetValue(host) + ?? throw new InvalidOperationException("Host state was null.")); + releaseOwner.Set(); + if (!Task.WaitAll(new[] { owner, nonOwner }, TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("Concurrent GraphAuthHost.Dispose calls did not finish within the bounded deadline."); + } + + int disposeCount = File.Exists(disposeMarker) + ? File.ReadAllLines(disposeMarker).Length + : 0; + return JsonSerializer.Serialize(new + { + NonOwnerCompletedBeforeRelease = nonOwnerCompletedBeforeRelease, + StateBeforeRelease = stateBeforeRelease, + DisposeCount = disposeCount, + ProxyInnerCleared = innerField.GetValue(source) is null, + ProxyOwnerCleared = ownerField.GetValue(source) is null + }); + } + + public static string BlockedCancellationCallback( + GraphAuthHost host, + IGraphTokenSource source, + string disposeMarker) + { + BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + var shutdown = (CancellationTokenSource)(typeof(GraphAuthHost) + .GetField("_shutdown", privateInstance)?.GetValue(host) + ?? throw new InvalidOperationException("Host shutdown source was not found.")); + var stateField = typeof(GraphAuthHost).GetField("_state", privateInstance) + ?? throw new InvalidOperationException("Host state field was not found."); + var shutdownTaskField = typeof(GraphAuthHost).GetField("_shutdownTask", privateInstance) + ?? throw new InvalidOperationException("Host shutdown-task field was not found."); + Type proxyType = source.GetType(); + var innerField = proxyType.GetField("_inner", privateInstance) + ?? throw new InvalidOperationException("Proxy inner field was not found."); + var ownerField = proxyType.GetField("_owner", privateInstance) + ?? throw new InvalidOperationException("Proxy owner field was not found."); + object inner = innerField.GetValue(source) + ?? throw new InvalidOperationException("Proxy inner source was not found."); + BindingFlags providerControlFlags = BindingFlags.Static | BindingFlags.NonPublic; + MethodInfo waitForAcquire = inner.GetType().GetMethod( + "WaitForBlockedAcquire", + providerControlFlags) + ?? throw new InvalidOperationException("Provider acquire wait control was not found."); + MethodInfo releaseAcquire = inner.GetType().GetMethod( + "ReleaseBlockedAcquire", + providerControlFlags) + ?? throw new InvalidOperationException("Provider acquire release control was not found."); + + Task acquire = Task.Factory.StartNew( + () => source.Acquire(false, CancellationToken.None), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + if (waitForAcquire.Invoke(null, new object[] { TimeSpan.FromSeconds(5) }) is not true) + { + releaseAcquire.Invoke(null, null); + throw new TimeoutException("The provider acquisition did not enter its blocked section."); + } + + using var callbackEntered = new ManualResetEventSlim(false); + using var releaseCallback = new ManualResetEventSlim(false); + bool completionPlaceholderPublishedBeforeCallback = false; + bool reentrantDisposeReturned = false; + using CancellationTokenRegistration registration = shutdown.Token.Register(() => + { + callbackEntered.Set(); + completionPlaceholderPublishedBeforeCallback = + shutdownTaskField.GetValue(host) is Task; + host.Dispose(); + reentrantDisposeReturned = true; + if (!releaseCallback.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("The blocked cancellation callback was not released."); + } + }); + + Task? owner = null; + Task? nonOwner = null; + bool ownerCompletedBeforeCallbackRelease; + bool nonOwnerCompletedBeforeCallbackRelease; + long ownerElapsedMilliseconds; + int stateWhileCallbackBlocked; + int disposeCountWhileCallbackBlocked; + bool proxyInnerPresentWhileCallbackBlocked; + bool proxyOwnerPresentWhileCallbackBlocked; + bool loadContextAliveWhileCallbackBlocked; + try + { + var ownerTimer = Stopwatch.StartNew(); + owner = Task.Factory.StartNew( + host.Dispose, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + if (!callbackEntered.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The cancellation callback did not begin."); + } + + ownerCompletedBeforeCallbackRelease = owner.Wait(TimeSpan.FromSeconds(2)); + ownerElapsedMilliseconds = ownerTimer.ElapsedMilliseconds; + stateWhileCallbackBlocked = (int)(stateField.GetValue(host) + ?? throw new InvalidOperationException("Host state was null.")); + disposeCountWhileCallbackBlocked = File.Exists(disposeMarker) + ? File.ReadAllLines(disposeMarker).Length + : 0; + proxyInnerPresentWhileCallbackBlocked = innerField.GetValue(source) is not null; + proxyOwnerPresentWhileCallbackBlocked = ownerField.GetValue(source) is not null; + loadContextAliveWhileCallbackBlocked = host.LoadContextWeakReference.IsAlive; + + nonOwner = Task.Factory.StartNew( + host.Dispose, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + nonOwnerCompletedBeforeCallbackRelease = nonOwner.Wait(TimeSpan.FromSeconds(2)); + } + finally + { + releaseCallback.Set(); + } + + bool proxyClearedBeforeAcquireRelease = SpinWait.SpinUntil( + () => innerField.GetValue(source) is null && ownerField.GetValue(source) is null, + TimeSpan.FromSeconds(5)); + int disposeCountWhileAcquireBlocked = File.Exists(disposeMarker) + ? File.ReadAllLines(disposeMarker).Length + : 0; + releaseAcquire.Invoke(null, null); + + Task[] tasks = new[] + { + owner ?? throw new InvalidOperationException("Owner task was not created."), + nonOwner ?? throw new InvalidOperationException("Non-owner task was not created."), + acquire + }; + if (!Task.WaitAll(tasks, TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("Blocked-callback shutdown did not finish after both releases."); + } + + host.Dispose(); + int finalDisposeCount = File.Exists(disposeMarker) + ? File.ReadAllLines(disposeMarker).Length + : 0; + return JsonSerializer.Serialize(new + { + OwnerCompletedBeforeCallbackRelease = ownerCompletedBeforeCallbackRelease, + NonOwnerCompletedBeforeCallbackRelease = nonOwnerCompletedBeforeCallbackRelease, + OwnerElapsedMilliseconds = ownerElapsedMilliseconds, + CompletionPlaceholderPublishedBeforeCallback = completionPlaceholderPublishedBeforeCallback, + ReentrantDisposeReturned = reentrantDisposeReturned, + StateWhileCallbackBlocked = stateWhileCallbackBlocked, + DisposeCountWhileCallbackBlocked = disposeCountWhileCallbackBlocked, + ProxyInnerPresentWhileCallbackBlocked = proxyInnerPresentWhileCallbackBlocked, + ProxyOwnerPresentWhileCallbackBlocked = proxyOwnerPresentWhileCallbackBlocked, + LoadContextAliveWhileCallbackBlocked = loadContextAliveWhileCallbackBlocked, + ProxyClearedBeforeAcquireRelease = proxyClearedBeforeAcquireRelease, + DisposeCountWhileAcquireBlocked = disposeCountWhileAcquireBlocked, + FinalDisposeCount = finalDisposeCount, + ProxyInnerCleared = innerField.GetValue(source) is null, + ProxyOwnerCleared = ownerField.GetValue(source) is null + }); + } + + public static string ImmediateDisposalFailure( + GraphAuthHost host, + IGraphTokenSource source, + string disposeMarker) + { + BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + Type hostType = typeof(GraphAuthHost); + Type proxyType = source.GetType(); + var innerField = proxyType.GetField("_inner", privateInstance) + ?? throw new InvalidOperationException("Proxy inner field was not found."); + var ownerField = proxyType.GetField("_owner", privateInstance) + ?? throw new InvalidOperationException("Proxy owner field was not found."); + WeakReference weakReference = host.LoadContextWeakReference; + + string firstFailure = CaptureFailure(host.Dispose); + Task shutdownTask = (Task)(hostType.GetField("_shutdownTask", privateInstance)?.GetValue(host) + ?? throw new InvalidOperationException("Host shutdown task was not published.")); + string taskFailure = DescribeFailure(shutdownTask.Exception); + string repeatedFailure = CaptureFailure(host.Dispose); + + ForceCollection(weakReference); + return JsonSerializer.Serialize(new + { + FirstFailure = firstFailure, + TaskFailure = taskFailure, + RepeatedFailure = repeatedFailure, + DisposeCount = File.Exists(disposeMarker) + ? File.ReadAllLines(disposeMarker).Length + : 0, + ProxyInnerCleared = innerField.GetValue(source) is null, + ProxyOwnerCleared = ownerField.GetValue(source) is null, + HostProviderReferencesCleared = HostProviderReferencesAreCleared(host, privateInstance), + LoadContextAliveWhileHostAndTaskReferenced = weakReference.IsAlive + }); + } + + public static string DeferredDisposalFailure( + GraphAuthHost host, + IGraphTokenSource source, + string disposeMarker) + { + BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + WeakReference weakReference = host.LoadContextWeakReference; + object deferred = RunDeferredDisposal(host, source, privateInstance); + + Task shutdownTask = (Task)(typeof(GraphAuthHost) + .GetField("_shutdownTask", privateInstance)?.GetValue(host) + ?? throw new InvalidOperationException("Host shutdown task was not published.")); + if (!SpinWait.SpinUntil(() => shutdownTask.IsCompleted, TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("Deferred shutdown completion was not published after the acquisition left."); + } + + string laterFailure = CaptureFailure(host.Dispose); + string repeatedFailure = CaptureFailure(host.Dispose); + string taskFailure = DescribeFailure(shutdownTask.Exception); + Type proxyType = source.GetType(); + var innerField = proxyType.GetField("_inner", privateInstance) + ?? throw new InvalidOperationException("Proxy inner field was not found."); + var retiredInnerField = proxyType.GetField("_retiredInner", privateInstance) + ?? throw new InvalidOperationException("Proxy retired-inner field was not found."); + var ownerField = proxyType.GetField("_owner", privateInstance) + ?? throw new InvalidOperationException("Proxy owner field was not found."); + + ForceCollection(weakReference); + return JsonSerializer.Serialize(new + { + Deferred = deferred, + LaterFailure = laterFailure, + RepeatedFailure = repeatedFailure, + TaskFailure = taskFailure, + DisposeCount = File.Exists(disposeMarker) + ? File.ReadAllLines(disposeMarker).Length + : 0, + ProxyInnerCleared = innerField.GetValue(source) is null, + ProxyRetiredInnerCleared = retiredInnerField.GetValue(source) is null, + ProxyOwnerCleared = ownerField.GetValue(source) is null, + HostProviderReferencesCleared = HostProviderReferencesAreCleared(host, privateInstance), + LoadContextAliveWhileHostAndTaskReferenced = weakReference.IsAlive + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static object RunDeferredDisposal( + GraphAuthHost host, + IGraphTokenSource source, + BindingFlags privateInstance) + { + Type proxyType = source.GetType(); + var innerField = proxyType.GetField("_inner", privateInstance) + ?? throw new InvalidOperationException("Proxy inner field was not found."); + var retiredInnerField = proxyType.GetField("_retiredInner", privateInstance) + ?? throw new InvalidOperationException("Proxy retired-inner field was not found."); + object inner = innerField.GetValue(source) + ?? throw new InvalidOperationException("Proxy inner source was not found."); + BindingFlags providerControlFlags = BindingFlags.Static | BindingFlags.NonPublic; + MethodInfo waitForAcquire = inner.GetType().GetMethod( + "WaitForBlockedAcquire", + providerControlFlags) + ?? throw new InvalidOperationException("Provider acquire wait control was not found."); + MethodInfo releaseAcquire = inner.GetType().GetMethod( + "ReleaseBlockedAcquire", + providerControlFlags) + ?? throw new InvalidOperationException("Provider acquire release control was not found."); + + Task acquire = Task.Factory.StartNew( + () => source.Acquire(false, CancellationToken.None), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + if (waitForAcquire.Invoke(null, new object[] { TimeSpan.FromSeconds(5) }) is not true) + { + releaseAcquire.Invoke(null, null); + throw new TimeoutException("The provider acquisition did not enter its blocked section."); + } + + Task owner = Task.Factory.StartNew( + host.Dispose, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + bool ownerReturnedWithinDeadline = owner.Wait(TimeSpan.FromSeconds(2)); + bool retiredInnerPresentWhileAcquireBlocked = + SpinWait.SpinUntil( + () => retiredInnerField.GetValue(source) is not null, + TimeSpan.FromSeconds(5)); + bool loadContextAliveWhileAcquireBlocked = host.LoadContextWeakReference.IsAlive; + releaseAcquire.Invoke(null, null); + string acquireFailure = CaptureTaskFailure(acquire); + if (!owner.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The initial bounded Dispose caller did not return."); + } + + return new + { + OwnerReturnedWithinDeadline = ownerReturnedWithinDeadline, + RetiredInnerPresentWhileAcquireBlocked = retiredInnerPresentWhileAcquireBlocked, + LoadContextAliveWhileAcquireBlocked = loadContextAliveWhileAcquireBlocked, + AcquireFailure = acquireFailure + }; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static Exception CaptureTaskException(Task task) + { + try + { + task.GetAwaiter().GetResult(); + } + catch (Exception exception) + { + return exception; + } + + return new InvalidOperationException("The provider operation did not fail as required by the fixture."); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static string CaptureTaskFailure(Task task) + { + try + { + task.GetAwaiter().GetResult(); + return string.Empty; + } + catch (Exception exception) + { + return DescribeFailure(exception); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static string CaptureFailure(Action action) + { + try + { + action(); + return string.Empty; + } + catch (Exception exception) + { + return DescribeFailure(exception); + } + } + + private static string DescribeFailure(Exception? exception) + { + if (exception is null) + { + return string.Empty; + } + + var description = new StringBuilder(); + AppendFailure(exception, description, new HashSet()); + return description.ToString(); + } + + private static void AppendFailure( + Exception exception, + StringBuilder description, + HashSet visited) + { + if (!visited.Add(exception)) + { + return; + } + + Type type = exception.GetType(); + description.Append("type=").Append(type.FullName) + .Append(";assembly=").Append(type.Assembly.GetName().Name) + .Append(";alc=").Append(AssemblyLoadContext.GetLoadContext(type.Assembly)?.Name) + .Append(";message=").Append(exception.Message) + .Append(";stack=").Append(exception.StackTrace) + .Append(";dataCount=").Append(exception.Data.Count); + if (exception is GraphAuthException graphAuthException) + { + description.Append(";code=").Append(graphAuthException.Code) + .Append(";category=").Append(graphAuthException.Category) + .Append(";correlation=").Append(graphAuthException.CorrelationId) + .Append(";retryAfter=").Append(graphAuthException.RetryAfter); + } + + foreach (DictionaryEntry item in exception.Data) + { + description.Append(";dataKeyType=").Append(item.Key?.GetType().AssemblyQualifiedName) + .Append(";dataKey=").Append(item.Key) + .Append(";dataValueType=").Append(item.Value?.GetType().AssemblyQualifiedName) + .Append(";dataValue=").Append(item.Value); + } + + description.AppendLine(); + if (exception is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + AppendFailure(inner, description, visited); + } + } + else if (exception.InnerException is not null) + { + AppendFailure(exception.InnerException, description, visited); + } + } + + private static bool HostProviderReferencesAreCleared( + GraphAuthHost host, + BindingFlags privateInstance) + { + Type hostType = typeof(GraphAuthHost); + return hostType.GetField("_factory", privateInstance)?.GetValue(host) is null && + hostType.GetField("_factoryType", privateInstance)?.GetValue(host) is null && + hostType.GetField("_providerAssembly", privateInstance)?.GetValue(host) is null && + hostType.GetField("_loadContext", privateInstance)?.GetValue(host) is null; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ForceCollection(WeakReference weakReference) + { + for (int attempt = 0; attempt < 30 && weakReference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } +} +'@ + Set-Content -LiteralPath $projectPath -NoNewline -Encoding utf8NoBOM -Value @" + + + net8.0 + GraphKit.Auth.RuntimeHarness + enable + enable + true + true + none + + + + $escapedContractsPath + false + + + +"@ + $compilerOutput = & dotnet build $projectPath -c Release -o $outputPath --nologo --verbosity quiet 2>&1 + $assemblyPath = Join-Path $outputPath 'GraphKit.Auth.RuntimeHarness.dll' + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) { + throw "The GraphKit.Auth runtime harness did not compile: $($compilerOutput | Out-String)" + } + return $assemblyPath + } + + function New-GraphKitAuthAbiMutationAssembly { + param( + [Parameter(Mandatory)] [string] $Root, + [Parameter(Mandatory)] [ValidateSet('EnumUnderlyingByte', 'CorrelationIdNonNullable')] [string] $Mutation + ) + + $null = New-Item -ItemType Directory -Path $Root -Force + $sourceRoot = Join-Path $script:repoRoot 'src/GraphKit.Auth/GraphKit.Auth.Contracts' + foreach ($sourceName in @('Contracts.cs', 'GraphAuthHost.cs', 'GraphAuthLoadContext.cs', 'GraphTokenSourceProxy.cs')) { + Copy-Item -LiteralPath (Join-Path $sourceRoot $sourceName) -Destination (Join-Path $Root $sourceName) + } + + $contractsSourcePath = Join-Path $Root 'Contracts.cs' + $contractsSource = Get-Content -LiteralPath $contractsSourcePath -Raw + $mutatedSource = switch ($Mutation) { + 'EnumUnderlyingByte' { + $contractsSource.Replace( + 'public enum GraphAuthMode', + 'public enum GraphAuthMode : byte') + } + 'CorrelationIdNonNullable' { + $contractsSource.Replace( + 'string? correlationId)', + 'string correlationId)') + } + } + $mutatedSource | Should -Not -BeExactly $contractsSource -Because "the '$Mutation' fixture must alter the ABI source" + Set-Content -LiteralPath $contractsSourcePath -NoNewline -Encoding utf8NoBOM -Value $mutatedSource + + $projectPath = Join-Path $Root 'GraphKit.Auth.Contracts.csproj' + $outputPath = Join-Path $Root 'out' + $assemblyPath = Join-Path $outputPath 'GraphKit.Auth.Contracts.dll' + Set-Content -LiteralPath $projectPath -NoNewline -Encoding utf8NoBOM -Value @' + + + net8.0 + GraphKit.Auth.Contracts + GraphKit.Auth + enable + enable + true + CS8625 + true + none + + +'@ + + $compilerOutput = & dotnet build $projectPath -c Release -o $outputPath --nologo --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) { + throw "The GraphKit.Auth ABI mutation fixture did not compile: $($compilerOutput | Out-String)" + } + return $assemblyPath + } + + function Invoke-GraphKitAuthAbiSurfaceProbe { + param([Parameter(Mandatory)] [string] $ContractsPath) + + $probePath = Join-Path $TestDrive ('Probe-AbiSurface-' + [guid]::NewGuid().ToString('N') + '.ps1') + Set-Content -LiteralPath $probePath -NoNewline -Encoding utf8NoBOM -Value @' +param([Parameter(Mandatory)] [string] $ContractsPath) +$ErrorActionPreference = 'Stop' +$assembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $ContractsPath).ProviderPath +) + +function Get-TypeDisplayName { + param([Parameter(Mandatory)] [Type] $Type) + if ($Type.IsArray) { + return "$(Get-TypeDisplayName -Type $Type.GetElementType())[]" + } + if ($Type.IsGenericType) { + $definition = $Type.GetGenericTypeDefinition().FullName + $definition = $definition.Substring(0, $definition.IndexOf('`')) + $arguments = @($Type.GetGenericArguments() | ForEach-Object { Get-TypeDisplayName -Type $_ }) -join ',' + return "$definition<$arguments>" + } + return $Type.FullName +} + +function Get-ParameterDisplay { + param([Parameter(Mandatory)] [System.Reflection.ParameterInfo] $Parameter) + "$(Get-TypeDisplayName -Type $Parameter.ParameterType) $($Parameter.Name)" +} + +function Get-NullabilityDisplay { + param([System.Reflection.NullabilityInfo] $Info) + if ($null -eq $Info) { return '' } + + $display = "$($Info.ReadState)/$($Info.WriteState)" + if ($null -ne $Info.ElementType) { + $display += ";element=$(Get-NullabilityDisplay -Info $Info.ElementType)" + } + if ($Info.GenericTypeArguments.Count -ne 0) { + $arguments = @($Info.GenericTypeArguments | ForEach-Object { Get-NullabilityDisplay -Info $_ }) -join ',' + $display += ";arguments=[$arguments]" + } + return $display +} + +function Get-ModifierDisplay { + param([AllowEmptyCollection()] [Type[]] $Modifiers) + return '[' + (@($Modifiers | ForEach-Object FullName | Sort-Object) -join ',') + ']' +} + +function Get-CallableId { + param([Parameter(Mandatory)] [System.Reflection.MethodBase] $Callable) + $parameters = @($Callable.GetParameters() | ForEach-Object { Get-TypeDisplayName -Type $_.ParameterType }) -join ',' + $name = if ($Callable -is [System.Reflection.ConstructorInfo]) { '.ctor' } else { $Callable.Name } + return "$($Callable.DeclaringType.FullName)::$name($parameters)" +} + +function Get-DefaultDisplay { + param([Parameter(Mandatory)] [System.Reflection.ParameterInfo] $Parameter) + if (-not $Parameter.HasDefaultValue) { return '' } + if ($null -eq $Parameter.DefaultValue) { return '' } + if ($Parameter.DefaultValue -is [string]) { + return '"' + ([string] $Parameter.DefaultValue).Replace('"', '\"') + '"' + } + if ($Parameter.DefaultValue -is [char]) { + return "'$($Parameter.DefaultValue)'" + } + if ($Parameter.DefaultValue -is [bool]) { + return ([string] $Parameter.DefaultValue).ToLowerInvariant() + } + return [Convert]::ToString($Parameter.DefaultValue, [Globalization.CultureInfo]::InvariantCulture) +} + +function Add-ParameterMetadata { + param( + [Parameter(Mandatory)] [string] $OwnerKind, + [Parameter(Mandatory)] [string] $OwnerId, + [Parameter(Mandatory)] [System.Reflection.ParameterInfo] $Parameter + ) + + $direction = if ($Parameter.IsOut) { + 'out' + } + elseif ($Parameter.ParameterType.IsByRef -and $Parameter.IsIn) { + 'in' + } + elseif ($Parameter.ParameterType.IsByRef) { + 'ref' + } + else { + 'value' + } + $isParams = $Parameter.IsDefined([ParamArrayAttribute], $false).ToString().ToLowerInvariant() + $isOptional = $Parameter.IsOptional.ToString().ToLowerInvariant() + $hasDefault = $Parameter.HasDefaultValue.ToString().ToLowerInvariant() + $requiredModifiers = Get-ModifierDisplay -Modifiers $Parameter.GetRequiredCustomModifiers() + $optionalModifiers = Get-ModifierDisplay -Modifiers $Parameter.GetOptionalCustomModifiers() + $nullability = Get-NullabilityDisplay -Info $nullabilityContext.Create($Parameter) + $lines.Add( + "PARAMETER-META|$OwnerKind|$OwnerId|$($Parameter.Position)|$($Parameter.Name)|" + + "$(Get-TypeDisplayName -Type $Parameter.ParameterType)|direction=$direction|params=$isParams|" + + "optional=$isOptional|hasDefault=$hasDefault|default=$(Get-DefaultDisplay -Parameter $Parameter)|" + + "requiredMods=$requiredModifiers|optionalMods=$optionalModifiers|nullable=$nullability") +} + +function Add-GenericParameterMetadata { + param( + [Parameter(Mandatory)] [string] $OwnerKind, + [Parameter(Mandatory)] [string] $OwnerId, + [AllowEmptyCollection()] [Type[]] $GenericParameters + ) + + foreach ($parameter in @($GenericParameters | Where-Object IsGenericParameter | Sort-Object GenericParameterPosition)) { + $constraints = '[' + (@($parameter.GetGenericParameterConstraints() | ForEach-Object { Get-TypeDisplayName -Type $_ } | Sort-Object) -join ',') + ']' + $lines.Add( + "GENERIC-PARAMETER|$OwnerKind|$OwnerId|$($parameter.GenericParameterPosition)|" + + "$($parameter.Name)|attributes=$($parameter.GenericParameterAttributes)|constraints=$constraints") + } +} + +$lines = [Collections.Generic.List[string]]::new() +$flags = [Reflection.BindingFlags]'Public,Instance,Static,DeclaredOnly' +$nullabilityContext = [System.Reflection.NullabilityInfoContext]::new() +foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { + $kind = if ($type.IsEnum) { 'enum' } elseif ($type.IsInterface) { 'interface' } elseif ($type.IsAbstract) { 'abstract-class' } elseif ($type.IsSealed) { 'sealed-class' } else { 'class' } + $baseType = if ($null -eq $type.BaseType) { '' } else { Get-TypeDisplayName -Type $type.BaseType } + $interfaces = @($type.GetInterfaces() | ForEach-Object { Get-TypeDisplayName -Type $_ } | Sort-Object) -join ',' + $lines.Add("TYPE|$($type.FullName)|$kind|$baseType|$interfaces") + $isStaticType = ($type.IsAbstract -and $type.IsSealed -and -not $type.IsEnum).ToString().ToLowerInvariant() + $enumUnderlying = if ($type.IsEnum) { Get-TypeDisplayName -Type ([Enum]::GetUnderlyingType($type)) } else { '' } + $genericParameters = @($type.GetGenericArguments() | Where-Object IsGenericParameter) + $lines.Add("TYPE-META|$($type.FullName)|staticType=$isStaticType|enumUnderlying=$enumUnderlying|genericArity=$($genericParameters.Count)") + Add-GenericParameterMetadata -OwnerKind TYPE -OwnerId $type.FullName -GenericParameters $genericParameters + + if ($type.IsEnum) { + foreach ($name in [Enum]::GetNames($type)) { + $value = [Convert]::ToInt64([Enum]::Parse($type, $name)) + $lines.Add("ENUM|$($type.FullName)|$name=$value") + } + } + + foreach ($constructor in @($type.GetConstructors($flags) | Sort-Object { $_.ToString() })) { + $parameters = @($constructor.GetParameters() | ForEach-Object { Get-ParameterDisplay -Parameter $_ }) -join ',' + $lines.Add("CTOR|$($type.FullName)|($parameters)") + $ownerId = Get-CallableId -Callable $constructor + $lines.Add("MEMBER-META|CTOR|$ownerId|static=false|genericArity=0") + foreach ($parameter in $constructor.GetParameters()) { + Add-ParameterMetadata -OwnerKind CTOR -OwnerId $ownerId -Parameter $parameter + } + } + + foreach ($property in @($type.GetProperties($flags) | Sort-Object Name)) { + $accessors = [Collections.Generic.List[string]]::new() + if ($null -ne $property.GetMethod -and $property.GetMethod.IsPublic) { $accessors.Add('get') } + if ($null -ne $property.SetMethod -and $property.SetMethod.IsPublic) { + $isInit = @($property.SetMethod.ReturnParameter.GetRequiredCustomModifiers() | Where-Object FullName -eq 'System.Runtime.CompilerServices.IsExternalInit').Count -ne 0 + $accessors.Add($(if ($isInit) { 'init' } else { 'set' })) + } + $isRequired = @($property.GetCustomAttributesData() | Where-Object AttributeType -EQ ([System.Runtime.CompilerServices.RequiredMemberAttribute])).Count -ne 0 + if ($isRequired) { $accessors.Add('required') } + $lines.Add("PROPERTY|$($type.FullName)|$($property.Name)|$(Get-TypeDisplayName -Type $property.PropertyType)|$($accessors -join ',')") + $propertyAccessor = if ($null -ne $property.GetGetMethod($true)) { $property.GetGetMethod($true) } else { $property.GetSetMethod($true) } + $propertyIsStatic = $propertyAccessor.IsStatic.ToString().ToLowerInvariant() + $propertyNullability = Get-NullabilityDisplay -Info $nullabilityContext.Create($property) + $indexParameters = @($property.GetIndexParameters()) + $setter = $property.GetSetMethod($true) + $setterRequiredModifiers = if ($null -eq $setter) { '' } else { Get-ModifierDisplay -Modifiers $setter.ReturnParameter.GetRequiredCustomModifiers() } + $setterOptionalModifiers = if ($null -eq $setter) { '' } else { Get-ModifierDisplay -Modifiers $setter.ReturnParameter.GetOptionalCustomModifiers() } + $propertyOwnerId = "$($type.FullName)::$($property.Name)" + $lines.Add( + "PROPERTY-META|$propertyOwnerId|static=$propertyIsStatic|nullable=$propertyNullability|" + + "indexCount=$($indexParameters.Count)|setterRequiredMods=$setterRequiredModifiers|setterOptionalMods=$setterOptionalModifiers") + foreach ($parameter in $indexParameters) { + Add-ParameterMetadata -OwnerKind INDEX -OwnerId $propertyOwnerId -Parameter $parameter + } + } + + foreach ($method in @($type.GetMethods($flags) | Where-Object { -not $_.IsSpecialName -or $_.Name.StartsWith('op_') } | Sort-Object Name, { $_.ToString() })) { + $parameters = @($method.GetParameters() | ForEach-Object { Get-ParameterDisplay -Parameter $_ }) -join ',' + $lines.Add("METHOD|$($type.FullName)|$($method.Name)|($parameters)->$(Get-TypeDisplayName -Type $method.ReturnType)") + $ownerId = Get-CallableId -Callable $method + $methodGenericParameters = @($method.GetGenericArguments() | Where-Object IsGenericParameter) + $lines.Add("MEMBER-META|METHOD|$ownerId|static=$($method.IsStatic.ToString().ToLowerInvariant())|genericArity=$($methodGenericParameters.Count)") + Add-GenericParameterMetadata -OwnerKind METHOD -OwnerId $ownerId -GenericParameters $methodGenericParameters + foreach ($parameter in $method.GetParameters()) { + Add-ParameterMetadata -OwnerKind METHOD -OwnerId $ownerId -Parameter $parameter + } + $returnParameter = $method.ReturnParameter + $lines.Add( + "RETURN-META|METHOD|$ownerId|$(Get-TypeDisplayName -Type $method.ReturnType)|" + + "requiredMods=$(Get-ModifierDisplay -Modifiers $returnParameter.GetRequiredCustomModifiers())|" + + "optionalMods=$(Get-ModifierDisplay -Modifiers $returnParameter.GetOptionalCustomModifiers())|" + + "nullable=$(Get-NullabilityDisplay -Info $nullabilityContext.Create($returnParameter))") + } + + foreach ($event in @($type.GetEvents($flags) | Sort-Object Name)) { + $accessors = [Collections.Generic.List[string]]::new() + if ($null -ne $event.AddMethod -and $event.AddMethod.IsPublic) { $accessors.Add('add') } + if ($null -ne $event.RemoveMethod -and $event.RemoveMethod.IsPublic) { $accessors.Add('remove') } + $lines.Add("EVENT|$($type.FullName)|$($event.Name)|$(Get-TypeDisplayName -Type $event.EventHandlerType)|$($accessors -join ',')") + $eventAccessor = if ($null -ne $event.AddMethod) { $event.AddMethod } else { $event.RemoveMethod } + $lines.Add( + "EVENT-META|$($type.FullName)::$($event.Name)|static=$($eventAccessor.IsStatic.ToString().ToLowerInvariant())|" + + "nullable=$(Get-NullabilityDisplay -Info $nullabilityContext.Create($event))") + } + + foreach ($field in @($type.GetFields($flags) | Where-Object { -not $type.IsEnum } | Sort-Object Name)) { + $literal = if ($field.IsLiteral) { [string] $field.GetRawConstantValue() } else { '' } + $lines.Add("FIELD|$($type.FullName)|$($field.Name)|$(Get-TypeDisplayName -Type $field.FieldType)|$literal") + $lines.Add( + "FIELD-META|$($type.FullName)::$($field.Name)|static=$($field.IsStatic.ToString().ToLowerInvariant())|" + + "nullable=$(Get-NullabilityDisplay -Info $nullabilityContext.Create($field))") + } +} +@($lines | Sort-Object) | ConvertTo-Json -Compress +'@ + $raw = & pwsh -NoLogo -NoProfile -File $probePath -ContractsPath $ContractsPath 2>&1 + $exitCode = $LASTEXITCODE + $json = @($raw | Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('[') }) | Select-Object -Last 1 + [pscustomobject]@{ + ExitCode = $exitCode + Data = if ($json) { @($json | ConvertFrom-Json) } else { @() } + Output = ($raw | Out-String).Trim() + } + } + + function Invoke-GraphKitAuthRuntimeProbe { + param( + [Parameter(Mandatory)] [string] $ContractsPath, + [string] $PayloadRoot, + [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'FactoryConstructionFailure', 'SourceConstructionFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'BlockedCancellationCallback', 'ImmediateDisposalFailure', 'DeferredDisposalFailure', 'SamePathReplacement', 'OwnershipLedger')] [string] $Scenario, + [string] $DisposeMarker, + [string] $ReplacementContractsPath, + [string] $PreloadPath, + [string] $HarnessPath + ) + + $probePath = Join-Path $TestDrive ('Probe-Runtime-' + [guid]::NewGuid().ToString('N') + '.ps1') + Set-Content -LiteralPath $probePath -NoNewline -Encoding utf8NoBOM -Value @' +param( + [Parameter(Mandatory)] [string] $ContractsPath, + [string] $PayloadRoot, + [Parameter(Mandatory)] [string] $Scenario, + [string] $DisposeMarker, + [string] $ReplacementContractsPath, + [string] $PreloadPath, + [string] $HarnessPath +) +$ErrorActionPreference = 'Stop' +$null = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $ContractsPath).ProviderPath +) +if (-not [string]::IsNullOrWhiteSpace($PreloadPath)) { + $null = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $PreloadPath).ProviderPath + ) +} +if (-not [string]::IsNullOrWhiteSpace($HarnessPath)) { + $null = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $HarnessPath).ProviderPath + ) +} + +function New-ValidRequest { + return [GraphKit.Auth.GraphTokenRequest]::new( + 'Global', + [guid] '00000000-0000-0000-0000-000000000001', + [uri] 'https://login.microsoftonline.com', + [uri] 'https://graph.microsoft.com', + $null, + [GraphKit.Auth.GraphAuthMode]::BearerToken, + [GraphKit.Auth.FixedBearerCredential]::new('fixture-bearer'), + 'generation-1' + ) +} + +function New-OwnedSecretRequest { + param([Parameter(Mandatory)] [Security.SecureString] $Secret) + return [GraphKit.Auth.GraphTokenRequest]::new( + 'Global', + [guid] '00000000-0000-0000-0000-000000000001', + [uri] 'https://login.microsoftonline.com', + [uri] 'https://graph.microsoft.com', + [guid] '00000000-0000-0000-0000-000000000002', + [GraphKit.Auth.GraphAuthMode]::ClientSecret, + [GraphKit.Auth.ClientSecretCredential]::new($Secret, $true), + 'owned-secret-generation' + ) +} + +function New-TestSecureString { + $secret = [Security.SecureString]::new() + foreach ($character in 'owned-secret'.ToCharArray()) { $secret.AppendChar($character) } + $secret.MakeReadOnly() + return $secret +} + +function Test-SecureStringDisposed { + param([Parameter(Mandatory)] [Security.SecureString] $Secret) + try { + $copy = $Secret.Copy() + $copy.Dispose() + return $false + } + catch [ObjectDisposedException] { + return $true + } +} + +function Get-Rejection { + param([scriptblock] $Action) + try { + $null = & $Action + return $null + } + catch { + return $_.Exception.GetBaseException().Message + } +} + +function Get-RejectionType { + param([scriptblock] $Action) + try { + $null = & $Action + return $null + } + catch { + return $_.Exception.GetBaseException().GetType().FullName + } +} + +switch ($Scenario) { + 'Validation' { + $emptySecret = [Security.SecureString]::new() + $invalidCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new() + $cases = [ordered]@{} + $cases.EmptyEnvironment = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('', [guid]'00000000-0000-0000-0000-000000000001', [uri]'https://login.microsoftonline.com', [uri]'https://graph.microsoft.com', $null, [GraphKit.Auth.GraphAuthMode]::BearerToken, [GraphKit.Auth.FixedBearerCredential]::new('token'), 'g1') } + $cases.EmptyTenant = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('Global', [guid]::Empty, [uri]'https://login.microsoftonline.com', [uri]'https://graph.microsoft.com', $null, [GraphKit.Auth.GraphAuthMode]::BearerToken, [GraphKit.Auth.FixedBearerCredential]::new('token'), 'g1') } + $cases.HttpAuthority = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('Global', [guid]'00000000-0000-0000-0000-000000000001', [uri]'http://login.microsoftonline.com', [uri]'https://graph.microsoft.com', $null, [GraphKit.Auth.GraphAuthMode]::BearerToken, [GraphKit.Auth.FixedBearerCredential]::new('token'), 'g1') } + $cases.RelativeResource = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('Global', [guid]'00000000-0000-0000-0000-000000000001', [uri]'/relative', $null, $null, [GraphKit.Auth.GraphAuthMode]::BearerToken, [GraphKit.Auth.FixedBearerCredential]::new('token'), 'g1') } + $cases.MissingClientId = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('Global', [guid]'00000000-0000-0000-0000-000000000001', [uri]'https://login.microsoftonline.com', [uri]'https://graph.microsoft.com', $null, [GraphKit.Auth.GraphAuthMode]::ClientSecret, [GraphKit.Auth.ClientSecretCredential]::new($emptySecret, $false), 'g1') } + $cases.UnexpectedClientId = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('Global', [guid]'00000000-0000-0000-0000-000000000001', [uri]'https://login.microsoftonline.com', [uri]'https://graph.microsoft.com', [guid]'00000000-0000-0000-0000-000000000002', [GraphKit.Auth.GraphAuthMode]::ManagedIdentity, [GraphKit.Auth.ManagedIdentityCredential]::new($null), 'g1') } + $cases.Discriminator = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('Global', [guid]'00000000-0000-0000-0000-000000000001', [uri]'https://login.microsoftonline.com', [uri]'https://graph.microsoft.com', $null, [GraphKit.Auth.GraphAuthMode]::BearerToken, [GraphKit.Auth.ManagedIdentityCredential]::new($null), 'g1') } + $cases.PublicOnlyCertificate = Get-Rejection { [GraphKit.Auth.CertificateCredential]::new($invalidCertificate, $false) } + $cases.EmptySecret = Get-Rejection { [GraphKit.Auth.ClientSecretCredential]::new($emptySecret, $false) } + $cases.InvalidManagedIdentity = Get-Rejection { [GraphKit.Auth.ManagedIdentityCredential]::new('not-a-guid') } + $cases.EmptyBearer = Get-Rejection { [GraphKit.Auth.FixedBearerCredential]::new(' ') } + $cases.EmptyGeneration = Get-Rejection { [GraphKit.Auth.GraphTokenRequest]::new('Global', [guid]'00000000-0000-0000-0000-000000000001', [uri]'https://login.microsoftonline.com', [uri]'https://graph.microsoft.com', $null, [GraphKit.Auth.GraphAuthMode]::BearerToken, [GraphKit.Auth.FixedBearerCredential]::new('token'), ' ') } + $cases.InvalidShutdownTimeout = Get-Rejection { [GraphKit.Auth.GraphAuthHost]::new('/graphkit-auth-missing-payload', [version]'1.0.0.0', [timespan]::Zero) } + $requestType = [GraphKit.Auth.GraphTokenRequest] + $resultType = [GraphKit.Auth.GraphTokenResult] + [pscustomobject]@{ + Cases = $cases + RequestSetters = @($requestType.GetProperties() | Where-Object { $null -ne $_.SetMethod }).Count + VerifiedTenantIdSettable = $null -ne $resultType.GetProperty('VerifiedTenantId').SetMethod + } | ConvertTo-Json -Compress -Depth 5 + } + 'Lifecycle' { + $env:GRAPHKIT_AUTH_TEST_DISPOSE_MARKER = $DisposeMarker + $authHost = [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(2)) + $weakReference = $authHost.LoadContextWeakReference + $first = $authHost.CreateSource((New-ValidRequest)) + $acquired = $first.Acquire($false, [Threading.CancellationToken]::None) + $first.Dispose() + $first.Dispose() + $firstRejected = $null -ne (Get-Rejection { $first.Acquire($false, [Threading.CancellationToken]::None) }) + $firstRejectionType = Get-RejectionType { $first.Acquire($false, [Threading.CancellationToken]::None) } + $second = $authHost.CreateSource((New-ValidRequest)) + $authHost.Dispose() + $secondRejected = $null -ne (Get-Rejection { $second.Acquire($false, [Threading.CancellationToken]::None) }) + $secondRejectionType = Get-RejectionType { $second.Acquire($false, [Threading.CancellationToken]::None) } + $createRejected = $null -ne (Get-Rejection { $authHost.CreateSource((New-ValidRequest)) }) + $createRejectionType = Get-RejectionType { $authHost.CreateSource((New-ValidRequest)) } + $first = $null + $second = $null + $authHost = $null + for ($i = 0; $i -lt 20 -and $weakReference.IsAlive; $i++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } + [pscustomobject]@{ + AccessToken = $acquired.AccessToken + FirstRejected = $firstRejected + FirstRejectionType = $firstRejectionType + SecondRejected = $secondRejected + SecondRejectionType = $secondRejectionType + CreateRejected = $createRejected + CreateRejectionType = $createRejectionType + DisposeCount = @(Get-Content -LiteralPath $DisposeMarker).Count + LoadContextAlive = $weakReference.IsAlive + } | ConvertTo-Json -Compress + } + 'ProviderFailure' { + $authHost = [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(2)) + $source = $authHost.CreateSource((New-ValidRequest)) + [GraphKitAuthRuntimeHarness]::RetainedProviderBoundaryFailures($authHost, $source) + } + 'FactoryConstructionFailure' { + $env:GRAPHKIT_AUTH_TEST_FACTORY_CONSTRUCTION_FAILURE = '1' + [GraphKitAuthRuntimeHarness]::RetainedFactoryConstructionFailure($PayloadRoot) + } + 'SourceConstructionFailure' { + $env:GRAPHKIT_AUTH_TEST_SOURCE_CONSTRUCTION_FAILURE = '1' + $authHost = [GraphKit.Auth.GraphAuthHost]::new( + $PayloadRoot, + [version]'1.0.0.0', + [timespan]::FromSeconds(2)) + [GraphKitAuthRuntimeHarness]::RetainedSourceConstructionFailure( + $authHost, + (New-ValidRequest)) + } + 'VersionMismatch' { + $message = Get-Rejection { [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'9.0.0.0', [timespan]::FromSeconds(2)) } + [pscustomobject]@{ Message = $message } | ConvertTo-Json -Compress + } + 'IncompatibleDefault' { + $message = Get-Rejection { [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(2)) } + [pscustomobject]@{ Message = $message } | ConvertTo-Json -Compress + } + 'HostLoadFailure' { + $message = Get-Rejection { [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(2)) } + [pscustomobject]@{ Message = $message } | ConvertTo-Json -Compress + } + 'ConcurrentDispose' { + $env:GRAPHKIT_AUTH_TEST_DISPOSE_MARKER = $DisposeMarker + $authHost = [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(5)) + $source = $authHost.CreateSource((New-ValidRequest)) + $weakReference = $authHost.LoadContextWeakReference + $data = [GraphKitAuthRuntimeHarness]::ConcurrentDispose($authHost, $source, $DisposeMarker) | ConvertFrom-Json + for ($i = 0; $i -lt 30 -and $weakReference.IsAlive; $i++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } + $data | Add-Member -NotePropertyName LoadContextAlive -NotePropertyValue $weakReference.IsAlive + $data | ConvertTo-Json -Compress + } + 'BlockedCancellationCallback' { + $env:GRAPHKIT_AUTH_TEST_DISPOSE_MARKER = $DisposeMarker + $env:GRAPHKIT_AUTH_TEST_BLOCK_ACQUIRE = '1' + $authHost = [GraphKit.Auth.GraphAuthHost]::new( + $PayloadRoot, + [version]'1.0.0.0', + [timespan]::FromMilliseconds(125)) + $source = $authHost.CreateSource((New-ValidRequest)) + $weakReference = $authHost.LoadContextWeakReference + $data = [GraphKitAuthRuntimeHarness]::BlockedCancellationCallback( + $authHost, + $source, + $DisposeMarker) | ConvertFrom-Json + for ($i = 0; $i -lt 30 -and $weakReference.IsAlive; $i++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } + $data | Add-Member -NotePropertyName LoadContextAlive -NotePropertyValue $weakReference.IsAlive + $data | ConvertTo-Json -Compress + } + 'ImmediateDisposalFailure' { + $env:GRAPHKIT_AUTH_TEST_DISPOSE_MARKER = $DisposeMarker + $env:GRAPHKIT_AUTH_TEST_DISPOSE_FAILURE = '1' + $authHost = [GraphKit.Auth.GraphAuthHost]::new( + $PayloadRoot, + [version]'1.0.0.0', + [timespan]::FromSeconds(2)) + $source = $authHost.CreateSource((New-ValidRequest)) + [GraphKitAuthRuntimeHarness]::ImmediateDisposalFailure( + $authHost, + $source, + $DisposeMarker) + } + 'DeferredDisposalFailure' { + $env:GRAPHKIT_AUTH_TEST_DISPOSE_MARKER = $DisposeMarker + $env:GRAPHKIT_AUTH_TEST_DISPOSE_FAILURE = '1' + $env:GRAPHKIT_AUTH_TEST_BLOCK_ACQUIRE = '1' + $authHost = [GraphKit.Auth.GraphAuthHost]::new( + $PayloadRoot, + [version]'1.0.0.0', + [timespan]::FromMilliseconds(125)) + $source = $authHost.CreateSource((New-ValidRequest)) + [GraphKitAuthRuntimeHarness]::DeferredDisposalFailure( + $authHost, + $source, + $DisposeMarker) + } + 'SamePathReplacement' { + $residentMvid = [GraphKit.Auth.GraphAuthHost].Assembly.ManifestModule.ModuleVersionId.ToString('D') + $resolvedReplacement = (Resolve-Path -LiteralPath $ReplacementContractsPath).ProviderPath + $resolvedContracts = (Resolve-Path -LiteralPath $ContractsPath).ProviderPath + # Never truncate or overwrite an assembly CoreCLR may have image-mapped. Windows + # permits an in-use DLL to be renamed, so park the resident image and atomically + # move a prepared candidate into the now-free package path. The child process owns + # both temporary names and exits before Pester removes its TestDrive. + $parkedResident = "$resolvedContracts.resident.$([guid]::NewGuid().ToString('N'))" + $atomicReplacement = "$resolvedContracts.replacement.$([guid]::NewGuid().ToString('N'))" + [IO.File]::Copy($resolvedReplacement, $atomicReplacement) + [IO.File]::Move($resolvedContracts, $parkedResident) + [IO.File]::Move($atomicReplacement, $resolvedContracts) + $message = Get-Rejection { + $replacementHost = [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(2)) + $replacementHost.Dispose() + } + [pscustomobject]@{ + Message = $message + ResidentMvid = $residentMvid + } | ConvertTo-Json -Compress + } + 'OwnershipLedger' { + [GraphKitAuthRuntimeHarness]::OwnershipLedgerProof($PayloadRoot, $DisposeMarker) + } +} +'@ + + $arguments = @( + '-NoLogo', '-NoProfile', '-File', $probePath, + '-ContractsPath', $ContractsPath, + '-Scenario', $Scenario + ) + if ($PayloadRoot) { $arguments += @('-PayloadRoot', $PayloadRoot) } + if ($DisposeMarker) { $arguments += @('-DisposeMarker', $DisposeMarker) } + if ($ReplacementContractsPath) { $arguments += @('-ReplacementContractsPath', $ReplacementContractsPath) } + if ($PreloadPath) { $arguments += @('-PreloadPath', $PreloadPath) } + if ($HarnessPath) { $arguments += @('-HarnessPath', $HarnessPath) } + $raw = & pwsh @arguments 2>&1 + $exitCode = $LASTEXITCODE + $json = @($raw | Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') }) | Select-Object -Last 1 + [pscustomobject]@{ + ExitCode = $exitCode + Data = if ($json) { $json | ConvertFrom-Json } else { $null } + Output = ($raw | Out-String).Trim() + } + } + + function New-ActualGraphKitAuthPayload { + param([Parameter(Mandatory)] [string] $Root) + + $providerOutput = Join-Path $repoRoot 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0' + $testOutput = Join-Path $repoRoot 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0' + $null = New-Item -ItemType Directory -Path $Root -Force + foreach ($fileName in @( + 'GraphKit.Auth.dll', + 'GraphKit.Auth.deps.json' + )) { + $sourcePath = Join-Path $providerOutput $fileName + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "The actual provider output is missing '$sourcePath'. Build GraphKit.Auth before running this boundary test." + } + + Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $Root $fileName) + } + foreach ($fileName in @( + 'Microsoft.Identity.Client.dll', + 'Microsoft.IdentityModel.Abstractions.dll' + )) { + $sourcePath = Join-Path $testOutput $fileName + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "The restored provider dependency is missing '$sourcePath'. Build GraphKit.Auth.Tests before running this boundary test." + } + + Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $Root $fileName) + } + + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $Root 'GraphKit.Auth.Contracts.dll') + return $Root + } + + function Invoke-ActualGraphKitAuthRetentionProbe { + param( + [Parameter(Mandatory)] [string] $ContractsPath, + [Parameter(Mandatory)] [string] $PayloadRoot, + [Parameter(Mandatory)] [ValidateSet('Certificate', 'ClientSecret', 'FixedBearer')] [string] $Mode + ) + + $probePath = Join-Path $TestDrive ('Probe-ActualProviderRetention-' + [guid]::NewGuid().ToString('N') + '.ps1') + Set-Content -LiteralPath $probePath -NoNewline -Encoding utf8NoBOM -Value @' +param( + [Parameter(Mandatory)] [string] $ContractsPath, + [Parameter(Mandatory)] [string] $PayloadRoot, + [Parameter(Mandatory)] [ValidateSet('Certificate', 'ClientSecret', 'FixedBearer')] [string] $Mode +) +$ErrorActionPreference = 'Stop' +$null = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $ContractsPath).ProviderPath +) + +$authHost = $null +$source = $null +$request = $null +$credential = $null +$material = $null +$ownershipTransferAttempted = $false +$rsa = $null +try { + switch ($Mode) { + 'Certificate' { + $rsa = [Security.Cryptography.RSA]::Create(2048) + $certificateRequest = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=GraphKit Auth retention fixture', + $rsa, + [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + $material = $certificateRequest.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddMinutes(-1), + [DateTimeOffset]::UtcNow.AddMinutes(5) + ) + $credential = [GraphKit.Auth.CertificateCredential]::new($material, $true) + $authMode = [GraphKit.Auth.GraphAuthMode]::Certificate + $clientId = [Nullable[guid]] [guid] '00000000-0000-0000-0000-000000000002' + } + 'ClientSecret' { + $material = [Security.SecureString]::new() + foreach ($character in 'actual-provider-retention-fixture'.ToCharArray()) { + $material.AppendChar($character) + } + $material.MakeReadOnly() + $credential = [GraphKit.Auth.ClientSecretCredential]::new($material, $true) + $authMode = [GraphKit.Auth.GraphAuthMode]::ClientSecret + $clientId = [Nullable[guid]] [guid] '00000000-0000-0000-0000-000000000002' + } + 'FixedBearer' { + $credential = [GraphKit.Auth.FixedBearerCredential]::new('retention-fixture-bearer') + $authMode = [GraphKit.Auth.GraphAuthMode]::BearerToken + $clientId = $null + } + } + + $request = [GraphKit.Auth.GraphTokenRequest]::new( + 'Global', + [guid] '00000000-0000-0000-0000-000000000001', + [uri] 'https://login.microsoftonline.com', + [uri] 'https://graph.microsoft.com', + $clientId, + $authMode, + $credential, + 'retention-generation' + ) + $authHost = [GraphKit.Auth.GraphAuthHost]::new( + $PayloadRoot, + [version] '1.0.0.0', + [timespan]::FromSeconds(2) + ) + $weakReference = $authHost.LoadContextWeakReference + $ownershipTransferAttempted = $true + $source = $authHost.CreateSource($request) + + $providerAssemblyField = [GraphKit.Auth.GraphAuthHost].GetField( + '_providerAssembly', + [Reflection.BindingFlags] 'Instance,NonPublic' + ) + $providerAssembly = $providerAssemblyField.GetValue($authHost) + $providerContext = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($providerAssembly) + $providerIdentity = $providerAssembly.FullName + $providerLocation = $providerAssembly.Location + $providerWasCollectible = $providerContext.IsCollectible + + $source.Dispose() + $source = $null + $authHost.Dispose() + $authHost = $null + $providerAssembly = $null + $providerContext = $null + for ($i = 0; $i -lt 30 -and $weakReference.IsAlive; $i++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } + + [pscustomobject]@{ + Mode = $Mode + ProviderIdentity = $providerIdentity + ProviderLocation = $providerLocation + ProviderWasCollectible = $providerWasCollectible + RequestRetained = $null -ne $request + CredentialRetained = $null -ne $credential + MaterialRetained = $null -ne $material + RequestLoadContext = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($request.GetType().Assembly).Name + CredentialLoadContext = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($credential.GetType().Assembly).Name + MaterialLoadContext = if ($null -ne $material) { + [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($material.GetType().Assembly).Name + } + else { + $null + } + LoadContextAliveWhileRequestCredentialAndMaterialRetained = $weakReference.IsAlive + } | ConvertTo-Json -Compress +} +finally { + if ($null -ne $source) { + try { $source.Dispose() } catch {} + } + if ($null -ne $authHost) { + try { $authHost.Dispose() } catch {} + } + if (-not $ownershipTransferAttempted -and $material -is [IDisposable]) { + try { $material.Dispose() } catch {} + } + if ($null -ne $rsa) { + $rsa.Dispose() + } + $request = $null + $credential = $null + $material = $null +} +'@ + + $raw = & pwsh -NoLogo -NoProfile -File $probePath ` + -ContractsPath $ContractsPath -PayloadRoot $PayloadRoot -Mode $Mode 2>&1 + $exitCode = $LASTEXITCODE + $json = @($raw | Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') }) | Select-Object -Last 1 + [pscustomobject]@{ + ExitCode = $exitCode + Data = if ($json) { $json | ConvertFrom-Json } else { $null } + Output = ($raw | Out-String).Trim() + } + } + + $script:contractsInspection = if (Test-Path -LiteralPath $script:contractsPath -PathType Leaf) { + Invoke-GraphKitAuthContractsCandidateProbe -CandidatePath $script:contractsPath + } + else { + $null + } +} + +Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { + It 'rejects a stale same-simple-name default-ALC assembly instead of accepting it as the candidate' { + $stalePath = New-GraphKitAuthContractsFixtureAssembly -Root (Join-Path $TestDrive 'stale-contracts') -Marker 'GraphKit.Auth.Abi/1' + $candidatePath = New-GraphKitAuthContractsFixtureAssembly -Root (Join-Path $TestDrive 'candidate-contracts') -Marker 'GraphKit.Auth.Abi/999' + (Get-FileHash -LiteralPath $stalePath -Algorithm SHA256).Hash | + Should -Not -Be (Get-FileHash -LiteralPath $candidatePath -Algorithm SHA256).Hash + + $result = Invoke-GraphKitAuthContractsCandidateProbe -CandidatePath $candidatePath -PreloadPath $stalePath + + $result.ExitCode | Should -Not -Be 0 -Because 'a different preloaded assembly must never satisfy candidate inspection' + $result.Output | Should -Match ( + '(?s)Default ALC already contains.*refusing' + + '(?:\x1b\[[0-?]*[ -/]*[@-~]|\s|\|)+candidate') + } + + It 'binds a fresh synthetic candidate by exact location bytes and MVID' { + $candidatePath = New-GraphKitAuthContractsFixtureAssembly -Root (Join-Path $TestDrive 'fresh-contracts') -Marker 'GraphKit.Auth.Abi/1' + + $result = Invoke-GraphKitAuthContractsCandidateProbe -CandidatePath $candidatePath + + $result.ExitCode | Should -Be 0 -Because $result.Output + $resolvedCandidate = (Resolve-Path -LiteralPath $candidatePath).ProviderPath + $candidateSha256 = (Get-FileHash -LiteralPath $candidatePath -Algorithm SHA256).Hash.ToLowerInvariant() + $result.Data.CandidatePath | Should -BeExactly $resolvedCandidate + $result.Data.LoadedLocation | Should -BeExactly $resolvedCandidate + $result.Data.CandidateSha256 | Should -BeExactly $candidateSha256 + $result.Data.LoadedSha256 | Should -BeExactly $candidateSha256 + $result.Data.LoadedMvid | Should -BeExactly $result.Data.CandidateMvid + $result.Data.ContractMarker | Should -Be 'GraphKit.Auth.Abi/1' + $result.Data.AcquireReturnType | Should -Be 'GraphKit.Auth.GraphTokenResult' + @($result.Data.Leaks) | Should -BeNullOrEmpty + } + + It 'loads the exact contract candidate with the ABI marker and Acquire result' { + $script:contractsPath | Should -Exist -Because 'Task 3 must build the dependency-free GraphKit.Auth contract assembly' + + $script:contractsInspection.ExitCode | Should -Be 0 -Because $script:contractsInspection.Output + $resolvedCandidate = (Resolve-Path -LiteralPath $script:contractsPath).ProviderPath + $candidateSha256 = (Get-FileHash -LiteralPath $script:contractsPath -Algorithm SHA256).Hash.ToLowerInvariant() + $script:contractsInspection.Data.CandidatePath | Should -BeExactly $resolvedCandidate + $script:contractsInspection.Data.LoadedLocation | Should -BeExactly $resolvedCandidate + $script:contractsInspection.Data.CandidateSha256 | Should -BeExactly $candidateSha256 + $script:contractsInspection.Data.LoadedSha256 | Should -BeExactly $candidateSha256 + $script:contractsInspection.Data.CandidateMvid | Should -Match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + $script:contractsInspection.Data.LoadedMvid | Should -BeExactly $script:contractsInspection.Data.CandidateMvid + $script:contractsInspection.Data.ContractMarker | Should -Be 'GraphKit.Auth.Abi/1' + $script:contractsInspection.Data.AcquireReturnType | Should -Be 'GraphKit.Auth.GraphTokenResult' + } + + It 'keeps Microsoft.Identity.Client out of every public candidate signature' { + $script:contractsPath | Should -Exist -Because 'the public GraphKit.Auth surface can only be inspected after Task 3 builds it' + + $script:contractsInspection.ExitCode | Should -Be 0 -Because $script:contractsInspection.Output + @($script:contractsInspection.Data.Leaks) | Should -BeNullOrEmpty -Because 'no MSAL type may cross the GraphKit-owned ABI boundary' + } + + It 'rejects an enum underlying-type mutation through the literal ABI-v1 gate' { + $mutatedPath = New-GraphKitAuthAbiMutationAssembly ` + -Root (Join-Path $TestDrive 'abi-enum-byte') -Mutation EnumUnderlyingByte + + $rejection = try { + Assert-GraphKitAuthAbiV1Surface -ContractsPath $mutatedPath + $null + } + catch { + $_.Exception.Message + } + + $rejection | Should -Match 'enumUnderlying=System\.Int32' + $rejection | Should -Match 'enumUnderlying=System\.Byte' ` + -Because 'the literal ABI gate must distinguish the frozen Int32 enum from an otherwise identical byte enum' + } + + It 'rejects a nullable-reference mutation through the literal ABI-v1 gate' { + $mutatedPath = New-GraphKitAuthAbiMutationAssembly ` + -Root (Join-Path $TestDrive 'abi-correlation-nonnullable') -Mutation CorrelationIdNonNullable + + $rejection = try { + Assert-GraphKitAuthAbiV1Surface -ContractsPath $mutatedPath + $null + } + catch { + $_.Exception.Message + } + + $rejection | Should -Match 'correlationId.*nullable=Nullable/Nullable' + $rejection | Should -Match 'correlationId.*nullable=NotNull/NotNull' ` + -Because 'the literal ABI gate must distinguish a non-null correlationId parameter from the frozen nullable parameter' + } + + BeforeAll { + function Assert-GraphKitAuthAbiV1Surface { + param([Parameter(Mandatory)] [string] $ContractsPath) + + $expectedSurface = @( + 'CTOR|GraphKit.Auth.CertificateCredential|(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate,System.Boolean ownsMaterial)' + 'CTOR|GraphKit.Auth.ClientSecretCredential|(System.Security.SecureString secret,System.Boolean ownsMaterial)' + 'CTOR|GraphKit.Auth.FixedBearerCredential|(System.String accessToken)' + 'CTOR|GraphKit.Auth.GraphAuthException|(System.String code,System.String category,System.String message,System.Nullable retryAfter,System.String correlationId)' + 'CTOR|GraphKit.Auth.GraphAuthHost|(System.String payloadRoot,System.Version expectedProviderVersion,System.TimeSpan shutdownTimeout)' + 'CTOR|GraphKit.Auth.GraphAuthHost|(System.String payloadRoot,System.Version expectedProviderVersion)' + 'CTOR|GraphKit.Auth.GraphTokenRequest|(System.String environment,System.Guid tenantId,System.Uri authority,System.Uri resource,System.Nullable clientId,GraphKit.Auth.GraphAuthMode authMode,GraphKit.Auth.GraphCredential credential,System.String credentialGeneration)' + 'CTOR|GraphKit.Auth.GraphTokenResult|()' + 'CTOR|GraphKit.Auth.ManagedIdentityCredential|(System.String userAssignedClientId)' + 'ENUM|GraphKit.Auth.GraphAuthMode|BearerToken=3' + 'ENUM|GraphKit.Auth.GraphAuthMode|Certificate=0' + 'ENUM|GraphKit.Auth.GraphAuthMode|ClientSecret=1' + 'ENUM|GraphKit.Auth.GraphAuthMode|ManagedIdentity=2' + 'FIELD|GraphKit.Auth.GraphAuthHost|ContractMarker|System.String|GraphKit.Auth.Abi/1' + 'METHOD|GraphKit.Auth.GraphAuthHost|CreateSource|(GraphKit.Auth.GraphTokenRequest request)->GraphKit.Auth.IGraphTokenSource' + 'METHOD|GraphKit.Auth.GraphAuthHost|Dispose|()->System.Void' + 'METHOD|GraphKit.Auth.IGraphTokenSource|Acquire|(System.Boolean forceRefresh,System.Threading.CancellationToken cancellation)->GraphKit.Auth.GraphTokenResult' + 'METHOD|GraphKit.Auth.IGraphTokenSource|AdoptSharedResult|(GraphKit.Auth.GraphTokenResult result,System.Boolean forceRefresh)->System.Void' + 'METHOD|GraphKit.Auth.IGraphTokenSourceFactory|Create|(GraphKit.Auth.GraphTokenRequest request)->GraphKit.Auth.IGraphTokenSource' + 'PROPERTY|GraphKit.Auth.CertificateCredential|Certificate|System.Security.Cryptography.X509Certificates.X509Certificate2|get' + 'PROPERTY|GraphKit.Auth.CertificateCredential|OwnsMaterial|System.Boolean|get' + 'PROPERTY|GraphKit.Auth.ClientSecretCredential|OwnsMaterial|System.Boolean|get' + 'PROPERTY|GraphKit.Auth.ClientSecretCredential|Secret|System.Security.SecureString|get' + 'PROPERTY|GraphKit.Auth.FixedBearerCredential|AccessToken|System.String|get' + 'PROPERTY|GraphKit.Auth.GraphAuthException|Category|System.String|get' + 'PROPERTY|GraphKit.Auth.GraphAuthException|Code|System.String|get' + 'PROPERTY|GraphKit.Auth.GraphAuthException|CorrelationId|System.String|get' + 'PROPERTY|GraphKit.Auth.GraphAuthException|RetryAfter|System.Nullable|get' + 'PROPERTY|GraphKit.Auth.GraphAuthHost|LoadContextWeakReference|System.WeakReference|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|AuthMode|GraphKit.Auth.GraphAuthMode|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|Authority|System.Uri|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|ClientId|System.Nullable|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|Credential|GraphKit.Auth.GraphCredential|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|CredentialGeneration|System.String|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|Environment|System.String|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|Resource|System.Uri|get' + 'PROPERTY|GraphKit.Auth.GraphTokenRequest|TenantId|System.Guid|get' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|AccessToken|System.String|get,init,required' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|CredentialGeneration|System.String|get,init,required' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|ExpiresOnUtc|System.DateTimeOffset|get,init' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|ReceivedOnUtc|System.DateTimeOffset|get,init' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|Scopes|System.String[]|get,init,required' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|TokenFingerprint|System.String|get,init,required' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|TokenType|System.String|get,init,required' + 'PROPERTY|GraphKit.Auth.GraphTokenResult|VerifiedTenantId|System.String|get,set' + 'PROPERTY|GraphKit.Auth.IGraphTokenSource|Audience|System.String|get' + 'PROPERTY|GraphKit.Auth.IGraphTokenSource|AuthMode|System.String|get' + 'PROPERTY|GraphKit.Auth.IGraphTokenSource|CanRefresh|System.Boolean|get' + 'PROPERTY|GraphKit.Auth.IGraphTokenSource|ClientId|System.String|get' + 'PROPERTY|GraphKit.Auth.IGraphTokenSource|CredentialGeneration|System.String|get' + 'PROPERTY|GraphKit.Auth.IGraphTokenSource|ExpiresOn|System.DateTimeOffset|get' + 'PROPERTY|GraphKit.Auth.IGraphTokenSource|VerifiedTenantId|System.String|get' + 'PROPERTY|GraphKit.Auth.ManagedIdentityCredential|UserAssignedClientId|System.String|get' + 'TYPE|GraphKit.Auth.CertificateCredential|sealed-class|GraphKit.Auth.GraphCredential|' + 'TYPE|GraphKit.Auth.ClientSecretCredential|sealed-class|GraphKit.Auth.GraphCredential|' + 'TYPE|GraphKit.Auth.FixedBearerCredential|sealed-class|GraphKit.Auth.GraphCredential|' + 'TYPE|GraphKit.Auth.GraphAuthException|sealed-class|System.Exception|System.Runtime.Serialization.ISerializable' + 'TYPE|GraphKit.Auth.GraphAuthHost|sealed-class|System.Object|System.IDisposable' + 'TYPE|GraphKit.Auth.GraphAuthMode|enum|System.Enum|System.IComparable,System.IConvertible,System.IFormattable,System.ISpanFormattable' + 'TYPE|GraphKit.Auth.GraphCredential|abstract-class|System.Object|' + 'TYPE|GraphKit.Auth.GraphTokenRequest|sealed-class|System.Object|' + 'TYPE|GraphKit.Auth.GraphTokenResult|sealed-class|System.Object|' + 'TYPE|GraphKit.Auth.IGraphTokenSource|interface||System.IDisposable' + 'TYPE|GraphKit.Auth.IGraphTokenSourceFactory|interface||' + 'TYPE|GraphKit.Auth.ManagedIdentityCredential|sealed-class|GraphKit.Auth.GraphCredential|' + 'FIELD-META|GraphKit.Auth.GraphAuthHost::ContractMarker|static=true|nullable=NotNull/NotNull' + 'MEMBER-META|CTOR|GraphKit.Auth.CertificateCredential::.ctor(System.Security.Cryptography.X509Certificates.X509Certificate2,System.Boolean)|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.ClientSecretCredential::.ctor(System.Security.SecureString,System.Boolean)|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.FixedBearerCredential::.ctor(System.String)|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.GraphAuthException::.ctor(System.String,System.String,System.String,System.Nullable,System.String)|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.GraphAuthHost::.ctor(System.String,System.Version,System.TimeSpan)|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.GraphAuthHost::.ctor(System.String,System.Version)|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.GraphTokenResult::.ctor()|static=false|genericArity=0' + 'MEMBER-META|CTOR|GraphKit.Auth.ManagedIdentityCredential::.ctor(System.String)|static=false|genericArity=0' + 'MEMBER-META|METHOD|GraphKit.Auth.GraphAuthHost::CreateSource(GraphKit.Auth.GraphTokenRequest)|static=false|genericArity=0' + 'MEMBER-META|METHOD|GraphKit.Auth.GraphAuthHost::Dispose()|static=false|genericArity=0' + 'MEMBER-META|METHOD|GraphKit.Auth.IGraphTokenSource::Acquire(System.Boolean,System.Threading.CancellationToken)|static=false|genericArity=0' + 'MEMBER-META|METHOD|GraphKit.Auth.IGraphTokenSource::AdoptSharedResult(GraphKit.Auth.GraphTokenResult,System.Boolean)|static=false|genericArity=0' + 'MEMBER-META|METHOD|GraphKit.Auth.IGraphTokenSourceFactory::Create(GraphKit.Auth.GraphTokenRequest)|static=false|genericArity=0' + 'PARAMETER-META|CTOR|GraphKit.Auth.CertificateCredential::.ctor(System.Security.Cryptography.X509Certificates.X509Certificate2,System.Boolean)|0|certificate|System.Security.Cryptography.X509Certificates.X509Certificate2|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.CertificateCredential::.ctor(System.Security.Cryptography.X509Certificates.X509Certificate2,System.Boolean)|1|ownsMaterial|System.Boolean|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.ClientSecretCredential::.ctor(System.Security.SecureString,System.Boolean)|0|secret|System.Security.SecureString|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.ClientSecretCredential::.ctor(System.Security.SecureString,System.Boolean)|1|ownsMaterial|System.Boolean|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.FixedBearerCredential::.ctor(System.String)|0|accessToken|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthException::.ctor(System.String,System.String,System.String,System.Nullable,System.String)|0|code|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthException::.ctor(System.String,System.String,System.String,System.Nullable,System.String)|1|category|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthException::.ctor(System.String,System.String,System.String,System.Nullable,System.String)|2|message|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthException::.ctor(System.String,System.String,System.String,System.Nullable,System.String)|3|retryAfter|System.Nullable|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=Nullable/Nullable' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthException::.ctor(System.String,System.String,System.String,System.Nullable,System.String)|4|correlationId|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=Nullable/Nullable' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthHost::.ctor(System.String,System.Version,System.TimeSpan)|0|payloadRoot|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthHost::.ctor(System.String,System.Version,System.TimeSpan)|1|expectedProviderVersion|System.Version|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthHost::.ctor(System.String,System.Version,System.TimeSpan)|2|shutdownTimeout|System.TimeSpan|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthHost::.ctor(System.String,System.Version)|0|payloadRoot|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphAuthHost::.ctor(System.String,System.Version)|1|expectedProviderVersion|System.Version|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|0|environment|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|1|tenantId|System.Guid|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|2|authority|System.Uri|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|3|resource|System.Uri|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|4|clientId|System.Nullable|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=Nullable/Nullable' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|5|authMode|GraphKit.Auth.GraphAuthMode|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|6|credential|GraphKit.Auth.GraphCredential|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.GraphTokenRequest::.ctor(System.String,System.Guid,System.Uri,System.Uri,System.Nullable,GraphKit.Auth.GraphAuthMode,GraphKit.Auth.GraphCredential,System.String)|7|credentialGeneration|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|CTOR|GraphKit.Auth.ManagedIdentityCredential::.ctor(System.String)|0|userAssignedClientId|System.String|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=Nullable/Nullable' + 'PARAMETER-META|METHOD|GraphKit.Auth.GraphAuthHost::CreateSource(GraphKit.Auth.GraphTokenRequest)|0|request|GraphKit.Auth.GraphTokenRequest|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|METHOD|GraphKit.Auth.IGraphTokenSource::Acquire(System.Boolean,System.Threading.CancellationToken)|0|forceRefresh|System.Boolean|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|METHOD|GraphKit.Auth.IGraphTokenSource::Acquire(System.Boolean,System.Threading.CancellationToken)|1|cancellation|System.Threading.CancellationToken|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|METHOD|GraphKit.Auth.IGraphTokenSource::AdoptSharedResult(GraphKit.Auth.GraphTokenResult,System.Boolean)|0|result|GraphKit.Auth.GraphTokenResult|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|METHOD|GraphKit.Auth.IGraphTokenSource::AdoptSharedResult(GraphKit.Auth.GraphTokenResult,System.Boolean)|1|forceRefresh|System.Boolean|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PARAMETER-META|METHOD|GraphKit.Auth.IGraphTokenSourceFactory::Create(GraphKit.Auth.GraphTokenRequest)|0|request|GraphKit.Auth.GraphTokenRequest|direction=value|params=false|optional=false|hasDefault=false|default=|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'PROPERTY-META|GraphKit.Auth.CertificateCredential::Certificate|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.CertificateCredential::OwnsMaterial|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.ClientSecretCredential::OwnsMaterial|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.ClientSecretCredential::Secret|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.FixedBearerCredential::AccessToken|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphAuthException::Category|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphAuthException::Code|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphAuthException::CorrelationId|static=false|nullable=Nullable/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphAuthException::RetryAfter|static=false|nullable=Nullable/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphAuthHost::LoadContextWeakReference|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::AuthMode|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::Authority|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::ClientId|static=false|nullable=Nullable/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::Credential|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::CredentialGeneration|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::Environment|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::Resource|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenRequest::TenantId|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::AccessToken|static=false|nullable=NotNull/NotNull|indexCount=0|setterRequiredMods=[System.Runtime.CompilerServices.IsExternalInit]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::CredentialGeneration|static=false|nullable=NotNull/NotNull|indexCount=0|setterRequiredMods=[System.Runtime.CompilerServices.IsExternalInit]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::ExpiresOnUtc|static=false|nullable=NotNull/NotNull|indexCount=0|setterRequiredMods=[System.Runtime.CompilerServices.IsExternalInit]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::ReceivedOnUtc|static=false|nullable=NotNull/NotNull|indexCount=0|setterRequiredMods=[System.Runtime.CompilerServices.IsExternalInit]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::Scopes|static=false|nullable=NotNull/NotNull;element=NotNull/NotNull|indexCount=0|setterRequiredMods=[System.Runtime.CompilerServices.IsExternalInit]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::TokenFingerprint|static=false|nullable=NotNull/NotNull|indexCount=0|setterRequiredMods=[System.Runtime.CompilerServices.IsExternalInit]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::TokenType|static=false|nullable=NotNull/NotNull|indexCount=0|setterRequiredMods=[System.Runtime.CompilerServices.IsExternalInit]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.GraphTokenResult::VerifiedTenantId|static=false|nullable=Nullable/Nullable|indexCount=0|setterRequiredMods=[]|setterOptionalMods=[]' + 'PROPERTY-META|GraphKit.Auth.IGraphTokenSource::Audience|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.IGraphTokenSource::AuthMode|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.IGraphTokenSource::CanRefresh|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.IGraphTokenSource::ClientId|static=false|nullable=Nullable/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.IGraphTokenSource::CredentialGeneration|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.IGraphTokenSource::ExpiresOn|static=false|nullable=NotNull/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.IGraphTokenSource::VerifiedTenantId|static=false|nullable=Nullable/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'PROPERTY-META|GraphKit.Auth.ManagedIdentityCredential::UserAssignedClientId|static=false|nullable=Nullable/Unknown|indexCount=0|setterRequiredMods=|setterOptionalMods=' + 'RETURN-META|METHOD|GraphKit.Auth.GraphAuthHost::CreateSource(GraphKit.Auth.GraphTokenRequest)|GraphKit.Auth.IGraphTokenSource|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'RETURN-META|METHOD|GraphKit.Auth.GraphAuthHost::Dispose()|System.Void|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'RETURN-META|METHOD|GraphKit.Auth.IGraphTokenSource::Acquire(System.Boolean,System.Threading.CancellationToken)|GraphKit.Auth.GraphTokenResult|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'RETURN-META|METHOD|GraphKit.Auth.IGraphTokenSource::AdoptSharedResult(GraphKit.Auth.GraphTokenResult,System.Boolean)|System.Void|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'RETURN-META|METHOD|GraphKit.Auth.IGraphTokenSourceFactory::Create(GraphKit.Auth.GraphTokenRequest)|GraphKit.Auth.IGraphTokenSource|requiredMods=[]|optionalMods=[]|nullable=NotNull/NotNull' + 'TYPE-META|GraphKit.Auth.CertificateCredential|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.ClientSecretCredential|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.FixedBearerCredential|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.GraphAuthException|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.GraphAuthHost|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.GraphAuthMode|staticType=false|enumUnderlying=System.Int32|genericArity=0' + 'TYPE-META|GraphKit.Auth.GraphCredential|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.GraphTokenRequest|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.GraphTokenResult|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.IGraphTokenSource|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.IGraphTokenSourceFactory|staticType=false|enumUnderlying=|genericArity=0' + 'TYPE-META|GraphKit.Auth.ManagedIdentityCredential|staticType=false|enumUnderlying=|genericArity=0' + ) | Sort-Object + + $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $ContractsPath + $differences = @(Compare-Object -ReferenceObject $expectedSurface -DifferenceObject @($result.Data) -SyncWindow 10000) + + if ($result.ExitCode -ne 0) { + throw "The ABI-v1 surface probe failed: $($result.Output)" + } + if ($differences.Count -ne 0) { + $differenceText = @($differences | ForEach-Object { + "$($_.SideIndicator) $($_.InputObject)" + }) -join "`n" + throw "ABI-v1 is literal, not inferred from the candidate:`n$differenceText" + } + } + } + + It 'matches the literal ABI-v1 public surface without extra exported types or members' { + { Assert-GraphKitAuthAbiV1Surface -ContractsPath $script:contractsPath } | + Should -Not -Throw + + $inspectionContext = [Runtime.Loader.AssemblyLoadContext]::new( + 'GraphKit.Task7.DeadFieldInspection.' + [guid]::NewGuid().ToString('N'), + $true) + $inspectionAssembly = $null + $hostType = $null + try { + $inspectionAssembly = $inspectionContext.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $script:contractsPath).ProviderPath) + $hostType = $inspectionAssembly.GetType( + 'GraphKit.Auth.GraphAuthHost', $true, $false) + $privateInstance = [Reflection.BindingFlags]'Instance,NonPublic' + $hostType.GetField('_drained', $privateInstance) | Should -BeNullOrEmpty ` + -Because 'the unused private drained marker must not survive Task 7' + $hostType.GetField('_shutdownCompleted', $privateInstance) | Should -BeNullOrEmpty ` + -Because 'the unused private shutdown-completed marker must not survive Task 7' + } + finally { + $hostType = $null + $inspectionAssembly = $null + $inspectionContext.Unload() + $inspectionContext = $null + } + } +} + +Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { + It 'rejects malformed request and credential data before provider load and keeps the request immutable' { + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $script:contractsPath -Scenario Validation + + $result.ExitCode | Should -Be 0 -Because $result.Output + foreach ($case in $result.Data.Cases.PSObject.Properties) { + $case.Value | Should -Not -BeNullOrEmpty -Because "the '$($case.Name)' invalid input must fail before a provider loads" + } + $result.Data.RequestSetters | Should -Be 0 + $result.Data.VerifiedTenantIdSettable | Should -BeTrue + } + + It 'owns default-context proxies, rejects use after disposal, and unloads the provider context' { + $payloadRoot = Join-Path $TestDrive 'valid-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + $disposeMarker = Join-Path $TestDrive 'dispose-marker.txt' + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario Lifecycle -DisposeMarker $disposeMarker + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.AccessToken | Should -BeExactly 'fixture-token' + $result.Data.FirstRejected | Should -BeTrue + $result.Data.FirstRejectionType | Should -BeExactly 'System.ObjectDisposedException' + $result.Data.SecondRejected | Should -BeTrue + $result.Data.SecondRejectionType | Should -BeExactly 'System.ObjectDisposedException' + $result.Data.CreateRejected | Should -BeTrue + $result.Data.CreateRejectionType | Should -BeExactly 'System.ObjectDisposedException' + $result.Data.DisposeCount | Should -Be 2 -Because 'one explicitly disposed and one host-owned source must each dispose exactly once' + $result.Data.LoadContextAlive | Should -BeFalse + } + + It 'unloads the actual provider while retaining default-context request state' -ForEach @( + @{ Mode = 'Certificate'; MaterialExpected = $true } + @{ Mode = 'ClientSecret'; MaterialExpected = $true } + @{ Mode = 'FixedBearer'; MaterialExpected = $false } + ) { + $payloadRoot = New-ActualGraphKitAuthPayload -Root ( + Join-Path $TestDrive ('actual-provider-retention-' + $Mode.ToLowerInvariant())) + $contractsPath = Join-Path $payloadRoot 'GraphKit.Auth.Contracts.dll' + + $result = Invoke-ActualGraphKitAuthRetentionProbe -ContractsPath $contractsPath ` + -PayloadRoot $payloadRoot -Mode $Mode + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.ProviderIdentity | Should -Match '^GraphKit\.Auth, Version=1\.0\.0\.0,' + $result.Data.ProviderLocation | Should -BeExactly (Join-Path $payloadRoot 'GraphKit.Auth.dll') + $result.Data.ProviderWasCollectible | Should -BeTrue + $result.Data.RequestRetained | Should -BeTrue + $result.Data.CredentialRetained | Should -BeTrue + $result.Data.MaterialRetained | Should -Be $MaterialExpected + $result.Data.RequestLoadContext | Should -BeExactly 'Default' + $result.Data.CredentialLoadContext | Should -BeExactly 'Default' + if ($MaterialExpected) { + $result.Data.MaterialLoadContext | Should -BeExactly 'Default' + } + else { + $result.Data.MaterialLoadContext | Should -BeNullOrEmpty + } + $result.Data.LoadContextAliveWhileRequestCredentialAndMaterialRetained | Should -BeFalse ` + -Because 'caller-retained default/framework request state must not root the actual collectible provider' + } + + It 'keeps one shutdown owner under concurrent Dispose callers and releases every collectible reference' { + $hostSource = Get-Content -LiteralPath ( + Join-Path $script:repoRoot 'src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs') -Raw + $hostSource | Should -Match ( + 'Interlocked\.Exchange\(ref _state,\s*SourcesDisposedAwaitingDrain\);\s*' + + 'TryFinalizeUnload\(\);') -Because ( + 'the shutdown-owner state publication and following active-operation read need a full fence ' + + 'to prevent a lost finalization wake-up on weakly ordered CPUs') + + $payloadRoot = Join-Path $TestDrive 'concurrent-dispose-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + $harnessPath = New-GraphKitAuthRuntimeHarnessAssembly -Root (Join-Path $TestDrive 'runtime-harness') + $disposeMarker = Join-Path $TestDrive 'concurrent-dispose-marker.txt' + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario ConcurrentDispose ` + -DisposeMarker $disposeMarker -HarnessPath $harnessPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.NonOwnerCompletedBeforeRelease | Should -BeFalse -Because 'only the shutdown owner may progress finalization while it is disposing sources' + $result.Data.StateBeforeRelease | Should -Be 1 -Because 'a non-owner must not move the host beyond the owner-disposal phase' + $result.Data.DisposeCount | Should -Be 1 -Because 'the single host-owned source must be disposed exactly once' + $result.Data.ProxyInnerCleared | Should -BeTrue + $result.Data.ProxyOwnerCleared | Should -BeTrue + $result.Data.LoadContextAlive | Should -BeFalse + } + + It 'returns within the shutdown deadline while a cancellation callback is blocked and finishes safely after release' { + $payloadRoot = Join-Path $TestDrive 'blocked-callback-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + $harnessPath = New-GraphKitAuthRuntimeHarnessAssembly -Root (Join-Path $TestDrive 'blocked-callback-harness') + $disposeMarker = Join-Path $TestDrive 'blocked-callback-dispose-marker.txt' + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario BlockedCancellationCallback ` + -DisposeMarker $disposeMarker -HarnessPath $harnessPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.OwnerCompletedBeforeCallbackRelease | Should -BeTrue -Because 'a synchronous cancellation callback must not defeat the configured host timeout' + $result.Data.NonOwnerCompletedBeforeCallbackRelease | Should -BeTrue -Because 'concurrent Dispose callers share the same bounded shutdown deadline' + $result.Data.OwnerElapsedMilliseconds | Should -BeLessThan 2000 + $result.Data.CompletionPlaceholderPublishedBeforeCallback | Should -BeTrue + $result.Data.ReentrantDisposeReturned | Should -BeTrue ` + -Because 'a cancellation callback that reenters Dispose must observe the one published completion and return within the same bounded deadline' + $result.Data.StateWhileCallbackBlocked | Should -Be 1 + $result.Data.DisposeCountWhileCallbackBlocked | Should -Be 0 + $result.Data.ProxyInnerPresentWhileCallbackBlocked | Should -BeTrue + $result.Data.ProxyOwnerPresentWhileCallbackBlocked | Should -BeTrue + $result.Data.LoadContextAliveWhileCallbackBlocked | Should -BeTrue + $result.Data.ProxyClearedBeforeAcquireRelease | Should -BeTrue + $result.Data.DisposeCountWhileAcquireBlocked | Should -Be 0 -Because 'an active proxy operation must retain its provider source until it leaves' + $result.Data.FinalDisposeCount | Should -Be 1 + $result.Data.ProxyInnerCleared | Should -BeTrue + $result.Data.ProxyOwnerCleared | Should -BeTrue + $result.Data.LoadContextAlive | Should -BeFalse + } + + It 'sanitizes an immediate provider disposal failure without rooting the collectible context' { + $payloadRoot = Join-Path $TestDrive 'immediate-disposal-failure-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + $harnessPath = New-GraphKitAuthRuntimeHarnessAssembly -Root (Join-Path $TestDrive 'immediate-disposal-failure-harness') + $disposeMarker = Join-Path $TestDrive 'immediate-disposal-failure-marker.txt' + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario ImmediateDisposalFailure ` + -DisposeMarker $disposeMarker -HarnessPath $harnessPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + foreach ($failure in @($result.Data.FirstFailure, $result.Data.TaskFailure, $result.Data.RepeatedFailure)) { + $failure | Should -Match 'type=GraphKit\.Auth\.GraphAuthException' + $failure | Should -Match 'code=provider_disposal_failed;category=ProviderLifecycle' + $failure | Should -Not -Match 'ProviderOwned|isolated-provider|Microsoft\.Identity' + $failure | Should -Not -Match 'dataCount=[1-9]' + } + $result.Data.DisposeCount | Should -Be 1 + $result.Data.ProxyInnerCleared | Should -BeTrue + $result.Data.ProxyOwnerCleared | Should -BeTrue + $result.Data.HostProviderReferencesCleared | Should -BeTrue + $result.Data.LoadContextAliveWhileHostAndTaskReferenced | Should -BeFalse ` + -Because 'the disposed host and its faulted task may retain only default-context sanitized failures' + } + + It 'reports a deferred provider disposal failure through the shared shutdown completion after the active call drains' { + $payloadRoot = Join-Path $TestDrive 'deferred-disposal-failure-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + $harnessPath = New-GraphKitAuthRuntimeHarnessAssembly -Root (Join-Path $TestDrive 'deferred-disposal-failure-harness') + $disposeMarker = Join-Path $TestDrive 'deferred-disposal-failure-marker.txt' + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario DeferredDisposalFailure ` + -DisposeMarker $disposeMarker -HarnessPath $harnessPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Deferred.OwnerReturnedWithinDeadline | Should -BeTrue + $result.Data.Deferred.RetiredInnerPresentWhileAcquireBlocked | Should -BeTrue + $result.Data.Deferred.LoadContextAliveWhileAcquireBlocked | Should -BeTrue + $result.Data.Deferred.AcquireFailure | Should -BeNullOrEmpty ` + -Because 'deferred provider disposal failure belongs to the shared host shutdown channel, not the completed acquisition' + foreach ($failure in @($result.Data.LaterFailure, $result.Data.RepeatedFailure, $result.Data.TaskFailure)) { + $failure | Should -Match 'type=GraphKit\.Auth\.GraphAuthException' + $failure | Should -Match 'code=provider_disposal_failed;category=ProviderLifecycle' + $failure | Should -Not -Match 'ProviderOwned|isolated-provider|Microsoft\.Identity' + $failure | Should -Not -Match 'dataCount=[1-9]' + } + $result.Data.DisposeCount | Should -Be 1 + $result.Data.ProxyInnerCleared | Should -BeTrue + $result.Data.ProxyRetiredInnerCleared | Should -BeTrue + $result.Data.ProxyOwnerCleared | Should -BeTrue + $result.Data.HostProviderReferencesCleared | Should -BeTrue + $result.Data.LoadContextAliveWhileHostAndTaskReferenced | Should -BeFalse + } + + It 'rejects same-path contracts bytes that no longer match the resident default-context assembly' { + $fixtureRoot = Join-Path $TestDrive 'same-path-replacement' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root (Join-Path $fixtureRoot 'provider') + $payloadRoot = Split-Path -Parent $providerPath + $payloadContractsPath = Join-Path $payloadRoot 'GraphKit.Auth.Contracts.dll' + Copy-Item -LiteralPath $script:contractsPath -Destination $payloadContractsPath + $replacementPath = New-GraphKitAuthContractsFixtureAssembly ` + -Root (Join-Path $fixtureRoot 'replacement-contracts') -Marker 'GraphKit.Auth.Abi/999' + (Get-FileHash -LiteralPath $payloadContractsPath -Algorithm SHA256).Hash | + Should -Not -Be (Get-FileHash -LiteralPath $replacementPath -Algorithm SHA256).Hash + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $payloadContractsPath ` + -PayloadRoot $payloadRoot -Scenario SamePathReplacement -ReplacementContractsPath $replacementPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Message | Should -Match 'fresh PowerShell process' + $result.Data.Message | Should -Match '(?i)contracts.*(identity|MVID|resident|candidate)' + } + + It 'rejects a counterfeit System-prefixed assembly from a provider public signature' { + $fixtureRoot = Join-Path $TestDrive 'counterfeit-system-provider' + $impostorPath = New-SystemImpostorFixtureAssembly -Root (Join-Path $fixtureRoot 'impostor') + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root (Join-Path $fixtureRoot 'provider') ` + -AdditionalReferencePath $impostorPath ` + -PublicSurfaceDeclaration 'public System.Impostor.Counterfeit Counterfeit => new();' + $payloadRoot = Split-Path -Parent $providerPath + $payloadContractsPath = Join-Path $payloadRoot 'GraphKit.Auth.Contracts.dll' + $payloadImpostorPath = Join-Path $payloadRoot 'System.Impostor.dll' + Copy-Item -LiteralPath $script:contractsPath -Destination $payloadContractsPath + $payloadImpostorPath | Should -Exist -Because 'the counterfeit dependency must be physically available to exercise loader trust' + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $payloadContractsPath ` + -PayloadRoot $payloadRoot -Scenario HostLoadFailure -PreloadPath $payloadImpostorPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Message | Should -Match '(?i)(counterfeit|only framework|trusted platform|public surface)' + } + + It 'accepts a proven same-object case alias but still resolves the physical payload root' { + $fixtureRoot = Join-Path $TestDrive 'case-alias-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $fixtureRoot + $payloadRoot = Split-Path -Parent $providerPath + $payloadContractsPath = Join-Path $payloadRoot 'GraphKit.Auth.Contracts.dll' + Copy-Item -LiteralPath $script:contractsPath -Destination $payloadContractsPath + + $parent = Split-Path -Parent $payloadRoot + $leaf = Split-Path -Leaf $payloadRoot + $aliasLeaf = $leaf.ToUpperInvariant() + if ($aliasLeaf -ceq $leaf) { + $aliasLeaf = $leaf.ToLowerInvariant() + } + $aliasRoot = Join-Path $parent $aliasLeaf + $actualEntries = @(Get-ChildItem -LiteralPath $parent -Directory | Where-Object Name -CEQ $leaf) + $actualEntries.Count | Should -Be 1 -Because 'the fresh fixture parent must contain exactly one physical payload directory' + + if (Test-Path -LiteralPath $aliasRoot -PathType Container) { + $sentinelName = 'same-object-sentinel.txt' + Set-Content -LiteralPath (Join-Path $payloadRoot $sentinelName) -Value 'same-object' -NoNewline + (Get-Content -LiteralPath (Join-Path $aliasRoot $sentinelName) -Raw) | + Should -BeExactly 'same-object' -Because 'the filesystem, not an OS-name assumption, must prove the alias is the same object' + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $payloadContractsPath ` + -PayloadRoot $aliasRoot -Scenario HostLoadFailure + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Message | Should -BeNullOrEmpty -Because 'a case spelling for the same physical payload must not be treated as a second package' + } + else { + $aliasRoot | Should -Not -Exist -Because 'case-sensitive filesystems correctly have no same-object alias to exercise' + } + } + + It 'recreates every provider failure on the default side without retaining the collectible context' { + $payloadRoot = Join-Path $TestDrive 'failing-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + $payloadContractsPath = Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll' + Copy-Item -LiteralPath $script:contractsPath -Destination $payloadContractsPath + $harnessPath = New-GraphKitAuthRuntimeHarnessAssembly -Root (Join-Path $TestDrive 'failure-runtime-harness') + + $factoryResult = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $payloadContractsPath ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario FactoryConstructionFailure ` + -HarnessPath $harnessPath + $sourceResult = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $payloadContractsPath ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario SourceConstructionFailure ` + -HarnessPath $harnessPath + $operationResult = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $payloadContractsPath ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario ProviderFailure ` + -HarnessPath $harnessPath + + $factoryResult.ExitCode | Should -Be 0 -Because $factoryResult.Output + foreach ($failure in @($factoryResult.Data.Failure, $factoryResult.Data.TaskFailure)) { + $failure | Should -Match 'type=GraphKit\.Auth\.GraphAuthException' + $failure | Should -Match 'code=provider_construction_failed;category=Provider' + $failure | Should -Match 'dataCount=0' + $failure | Should -Not -Match 'ProviderOwned|FixtureTokenSource|ProviderFailure|isolated-provider|Microsoft\.Identity' + } + $factoryResult.Data.LoadContextAliveWhileExceptionAndTaskReferenced | Should -BeFalse + + $sourceResult.ExitCode | Should -Be 0 -Because $sourceResult.Output + foreach ($failure in @($sourceResult.Data.Failure, $sourceResult.Data.TaskFailure)) { + $failure | Should -Match 'type=GraphKit\.Auth\.GraphAuthException' + $failure | Should -Match 'code=fixture;category=Fixture' + $failure | Should -Match 'correlation=fixture-correlation;retryAfter=00:00:07' + $failure | Should -Match 'dataCount=0' + $failure | Should -Not -Match 'ProviderOwned|FixtureTokenSource|ProviderFailure|isolated-provider|Microsoft\.Identity' + } + $sourceResult.Data.HostProviderReferencesCleared | Should -BeTrue + $sourceResult.Data.LoadContextAliveWhileExceptionHostAndTaskReferenced | Should -BeFalse + + $operationResult.ExitCode | Should -Be 0 -Because $operationResult.Output + @($operationResult.Data.Failures).Count | Should -Be 6 + @($operationResult.Data.TaskFailures).Count | Should -Be 6 + foreach ($entry in @($operationResult.Data.Failures) + @($operationResult.Data.TaskFailures)) { + $entry.Description | Should -Match 'dataCount=0' + $entry.Description | Should -Not -Match 'ProviderOwned|FixtureTokenSource|ProviderFailure|isolated-provider|Microsoft\.Identity' + if ($entry.Kind -in @('ReadGraph', 'AdoptGraph', 'AcquireGraph')) { + $entry.Description | Should -Match 'type=GraphKit\.Auth\.GraphAuthException' + $entry.Description | Should -Match 'code=fixture;category=Fixture' + $entry.Description | Should -Match 'correlation=fixture-correlation;retryAfter=00:00:07' + } + elseif ($entry.Kind -eq 'ReadUnsafeMetadata') { + $entry.Description | Should -Match 'type=GraphKit\.Auth\.GraphAuthException' + $entry.Description | Should -Match 'code=provider_failure;category=Provider' + $entry.Description | Should -Match 'correlation=;retryAfter=00:00:07' + } + elseif ($entry.Kind -eq 'ReadUnexpected') { + $entry.Description | Should -Match 'type=GraphKit\.Auth\.GraphAuthException' + $entry.Description | Should -Match 'code=provider_failure;category=Provider' + } + else { + $entry.Kind | Should -BeExactly 'Cancellation' + $entry.Description | Should -Match 'type=System\.OperationCanceledException' + $entry.Description | Should -Not -Match 'type=GraphKit\.Auth\.GraphAuthException' + } + } + $operationResult.Data.CancellationTokenIsCancellationRequested | Should -BeTrue + $operationResult.Data.HostProviderReferencesCleared | Should -BeTrue + $operationResult.Data.LoadContextAliveWhileExceptionsHostAndTasksReferenced | Should -BeFalse + } + + It 'rejects a provider whose assembly version is not the declared package version' { + $payloadRoot = Join-Path $TestDrive 'wrong-version-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot -AssemblyVersion '1.0.0.0' + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario VersionMismatch + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Message | Should -Match 'version' + $result.Data.Message | Should -Match '9\.0\.0\.0' + } + + It 'gives fresh-PowerShell guidance when the default context contains a different contracts copy' { + $payloadRoot = Join-Path $TestDrive 'incompatible-default-contracts' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath $script:contractsPath ` + -PayloadRoot (Split-Path -Parent $providerPath) -Scenario IncompatibleDefault + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Message | Should -Match 'fresh PowerShell process' + $result.Data.Message | Should -Match 'contracts' + } + + It 'rejects a provider file whose assembly name is not GraphKit.Auth' { + $fixtureRoot = Join-Path $TestDrive 'wrong-name-provider' + $wrongProviderPath = New-GraphKitAuthProviderFixtureAssembly -Root $fixtureRoot -AssemblyName 'Wrong.Auth' + $payloadRoot = Join-Path $fixtureRoot 'payload' + $null = New-Item -ItemType Directory -Path $payloadRoot -Force + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'GraphKit.Auth.Contracts.dll') + Copy-Item -LiteralPath $wrongProviderPath -Destination (Join-Path $payloadRoot 'GraphKit.Auth.dll') + + $result = Invoke-GraphKitAuthRuntimeProbe -ContractsPath (Join-Path $payloadRoot 'GraphKit.Auth.Contracts.dll') ` + -PayloadRoot $payloadRoot -Scenario HostLoadFailure + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Message | Should -Match "provider assembly 'Wrong.Auth'" + $result.Data.Message | Should -Match "not 'GraphKit.Auth'" + } + + It 'claims owned material in the default context before host state or provider entry' { + $payloadRoot = Join-Path $TestDrive 'ownership-ledger-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + $harnessPath = New-GraphKitAuthRuntimeHarnessAssembly -Root (Join-Path $TestDrive 'ownership-ledger-harness') + $markerRoot = Join-Path $TestDrive 'ownership-ledger-markers' + Copy-Item -LiteralPath $script:contractsPath -Destination (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') + + $result = Invoke-GraphKitAuthRuntimeProbe ` + -ContractsPath (Join-Path $payloadRoot 'out/GraphKit.Auth.Contracts.dll') ` + -PayloadRoot (Split-Path -Parent $providerPath) ` + -Scenario OwnershipLedger ` + -HarnessPath $harnessPath ` + -DisposeMarker $markerRoot + + $result.ExitCode | Should -Be 0 -Because $result.Output + foreach ($race in @( + $result.Data.DistinctHostSecretRace, + $result.Data.SameHostCertificateRace + )) { + $race.DistinctRequests | Should -BeTrue + $race.DistinctCredentials | Should -BeTrue + $race.SharedMaterial | Should -BeTrue + $race.AcceptedCount | Should -Be 1 + $race.RejectedCount | Should -Be 1 + $race.FactoryEntryCount | Should -Be 1 + $race.RejectionType | Should -BeExactly 'GraphKit.Auth.GraphAuthException' + $race.RejectionCode | Should -BeExactly 'credential_material_consumed' + $race.RejectionCategory | Should -BeExactly 'CredentialOwnership' + $race.WinnerUsableBeforeRelease | Should -BeTrue -Because 'the losing duplicate must not dispose the winning material' + $race.MaterialDisposedAfterWinner | Should -BeTrue + $race.WinnerDisposeCount | Should -Be 1 + } + $result.Data.SameHostCertificateRace.MaterialDisposeCount | Should -Be 1 + + $result.Data.ReentrantFactory.DistinctRequests | Should -BeTrue + $result.Data.ReentrantFactory.DistinctCredentials | Should -BeTrue + $result.Data.ReentrantFactory.SharedMaterial | Should -BeTrue + $result.Data.ReentrantFactory.FactoryEntryCount | Should -Be 1 + $result.Data.ReentrantFactory.NestedFailureType | Should -BeExactly 'GraphKit.Auth.GraphAuthException' + $result.Data.ReentrantFactory.NestedFailureCode | Should -BeExactly 'credential_material_consumed' + $result.Data.ReentrantFactory.NestedFailureCategory | Should -BeExactly 'CredentialOwnership' + $result.Data.ReentrantFactory.MaterialUsableBeforeWinnerDisposal | Should -BeTrue + + foreach ($preProvider in @($result.Data.StoppedHost, $result.Data.MissingFactory)) { + $preProvider.InitialFailureType | Should -BeExactly 'System.ObjectDisposedException' + $preProvider.MaterialDisposed | Should -BeTrue + $preProvider.MaterialDisposeCount | Should -Be 1 + $preProvider.RepeatedFailureType | Should -BeExactly 'GraphKit.Auth.GraphAuthException' + $preProvider.RepeatedFailureCode | Should -BeExactly 'credential_material_consumed' + $preProvider.RepeatedFailureCategory | Should -BeExactly 'CredentialOwnership' + $preProvider.FactoryEntryCount | Should -Be 0 + } + + $result.Data.PostProviderFailure.InitialFailureType | Should -BeExactly 'GraphKit.Auth.GraphAuthException' + $result.Data.PostProviderFailure.InitialFailureCode | Should -BeExactly 'fixture' + $result.Data.PostProviderFailure.InitialFailureCategory | Should -BeExactly 'Fixture' + $result.Data.PostProviderFailure.ContainsSensitiveDetail | Should -BeFalse + $result.Data.PostProviderFailure.MaterialDisposed | Should -BeTrue + $result.Data.PostProviderFailure.MaterialDisposeCount | Should -Be 1 + $result.Data.PostProviderFailure.RepeatedFailureCode | Should -BeExactly 'credential_material_consumed' + $result.Data.PostProviderFailure.FactoryEntryCount | Should -Be 1 + $result.Data.PostProviderFailure.ProviderCleanupCount | Should -Be 1 + + $result.Data.BlockedFactoryShutdown.DisposeCompletedWhileFactoryBlocked | Should -BeTrue ` + -Because 'host shutdown must begin independently of an unbounded provider constructor' + $result.Data.BlockedFactoryShutdown.SourceRegistered | Should -BeFalse + $result.Data.BlockedFactoryShutdown.CreateFailureType | Should -BeExactly 'System.ObjectDisposedException' + $result.Data.BlockedFactoryShutdown.FactoryEntryCount | Should -Be 1 + + $result.Data.SanitizedCleanupFailure.FailureType | Should -BeExactly 'GraphKit.Auth.GraphAuthException' + $result.Data.SanitizedCleanupFailure.FailureCode | Should -BeExactly 'credential_material_cleanup_failed' + $result.Data.SanitizedCleanupFailure.FailureCategory | Should -BeExactly 'CredentialOwnership' + $result.Data.SanitizedCleanupFailure.FailureMessage | Should -BeExactly 'GraphKit.Auth could not clean up credential material after source construction was rejected before provider entry.' + $result.Data.SanitizedCleanupFailure.InnerExceptionIsNull | Should -BeTrue + $result.Data.SanitizedCleanupFailure.DataCount | Should -Be 0 + $result.Data.SanitizedCleanupFailure.ContainsSensitiveDetail | Should -BeFalse + $result.Data.SanitizedCleanupFailure.ContainsRawCleanupType | Should -BeFalse + $result.Data.SanitizedCleanupFailure.ContainsRawCleanupStack | Should -BeFalse + $result.Data.SanitizedCleanupFailure.DisposeCount | Should -Be 1 + + $result.Data.WeakKeys.MaterialAlive | Should -BeFalse + $result.Data.WeakKeys.CredentialAlive | Should -BeFalse + $result.Data.WeakKeys.RequestAlive | Should -BeFalse + } +} diff --git a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 new file mode 100644 index 0000000..1cb2626 --- /dev/null +++ b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 @@ -0,0 +1,1402 @@ +function global:Get-Task7JsonProperty { + param( + [Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Location + ) + + $propertyMatches = @($Element.EnumerateObject() | Where-Object Name -CEQ $Name) + if ($propertyMatches.Count -ne 1) { + throw [System.IO.InvalidDataException]::new("$Location must contain exactly one '$Name' property.") + } + return $propertyMatches[0].Value +} + +function global:Assert-Task7NoDuplicateJsonProperties { + param( + [Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element, + [Parameter(Mandatory)] [string] $Location + ) + + if ($Element.ValueKind -eq [System.Text.Json.JsonValueKind]::Object) { + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($property in $Element.EnumerateObject()) { + if (-not $seen.Add($property.Name)) { + throw [System.IO.InvalidDataException]::new( + "$Location has duplicate JSON property '$($property.Name)'." + ) + } + Assert-Task7NoDuplicateJsonProperties -Element $property.Value ` + -Location "$Location.$($property.Name)" + } + } + elseif ($Element.ValueKind -eq [System.Text.Json.JsonValueKind]::Array) { + $index = 0 + foreach ($item in $Element.EnumerateArray()) { + Assert-Task7NoDuplicateJsonProperties -Element $item -Location "$Location[$index]" + $index++ + } + } +} + +function global:Assert-Task7ExactJsonFields { + param( + [Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element, + [Parameter(Mandatory)] [string[]] $Expected, + [Parameter(Mandatory)] [string] $Location + ) + + if ($Element.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { + throw [System.IO.InvalidDataException]::new("$Location must be an object.") + } + $actual = @($Element.EnumerateObject() | ForEach-Object Name) + $unknown = @($actual | Where-Object { $_ -cnotin $Expected }) + if ($unknown.Count -gt 0) { + throw [System.IO.InvalidDataException]::new( + "$Location has unknown property '$($unknown[0])'." + ) + } + $missing = @($Expected | Where-Object { $_ -cnotin $actual }) + if ($missing.Count -gt 0) { + throw [System.IO.InvalidDataException]::new( + "$Location is missing required property '$($missing[0])'." + ) + } +} + +function global:Assert-Task7JsonKind { + param( + [Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element, + [Parameter(Mandatory)] [System.Text.Json.JsonValueKind[]] $Allowed, + [Parameter(Mandatory)] [string] $Location + ) + if ($Element.ValueKind -notin $Allowed) { + throw [System.IO.InvalidDataException]::new( + "$Location has JSON kind '$($Element.ValueKind)' instead of '$($Allowed -join ' or ')'." + ) + } +} + +function global:Assert-Task7JsonArrayItems { + param( + [Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element, + [Parameter(Mandatory)] [System.Text.Json.JsonValueKind[]] $Allowed, + [Parameter(Mandatory)] [string] $Location, + [switch] $NestedStringArrays + ) + Assert-Task7JsonKind -Element $Element -Allowed Array -Location $Location + $index = 0 + foreach ($item in $Element.EnumerateArray()) { + if ($NestedStringArrays) { + Assert-Task7JsonArrayItems -Element $item -Allowed String ` + -Location "$Location[$index]" + } + else { + Assert-Task7JsonKind -Element $item -Allowed $Allowed -Location "$Location[$index]" + if ($item.ValueKind -eq [System.Text.Json.JsonValueKind]::String -and + [string]::IsNullOrEmpty($item.GetString())) { + throw [System.IO.InvalidDataException]::new( + "$Location[$index] must not be an empty string." + ) + } + } + $index++ + } +} + +function global:Assert-Task7StrictTimestamp { + param( + [Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element, + [Parameter(Mandatory)] [string] $Location + ) + + Assert-Task7JsonKind -Element $Element -Allowed String -Location $Location + $literal = $Element.GetString() + if ($literal -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$') { + throw [System.IO.InvalidDataException]::new( + "$Location must use exact yyyy-MM-ddTHH:mm:ss+00:00 timestamp syntax." + ) + } + + $parsed = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $literal, + "yyyy-MM-dd'T'HH:mm:sszzz", + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, + [ref] $parsed + )) { + throw [System.IO.InvalidDataException]::new( + "$Location is not a valid invariant timestamp." + ) + } +} + +function global:ConvertTo-Task7TimestampLiteral { + param([Parameter(Mandatory)] [DateTimeOffset] $Value) + return $Value.ToString( + "yyyy-MM-dd'T'HH:mm:sszzz", + [Globalization.CultureInfo]::InvariantCulture + ) +} + +function global:ConvertFrom-Task7TimestampLiteral { + param([Parameter(Mandatory)] [string] $Value) + return [DateTimeOffset]::ParseExact( + $Value, + "yyyy-MM-dd'T'HH:mm:sszzz", + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None + ) +} + +function global:ConvertFrom-Task7JsonElement { + param([Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element) + + switch ($Element.ValueKind) { + Object { + $value = [ordered] @{} + foreach ($property in $Element.EnumerateObject()) { + $value[$property.Name] = ConvertFrom-Task7JsonElement -Element $property.Value + } + return $value + } + Array { + $items = [Collections.Generic.List[object]]::new() + foreach ($item in $Element.EnumerateArray()) { + $items.Add((ConvertFrom-Task7JsonElement -Element $item)) + } + return ,$items.ToArray() + } + String { return [string] $Element.GetString() } + Number { + $integer = 0L + if ($Element.TryGetInt64([ref] $integer)) { return $integer } + return $Element.GetDecimal() + } + True { return $true } + False { return $false } + Null { return $null } + default { + throw [System.IO.InvalidDataException]::new( + "Unsupported JSON value kind '$($Element.ValueKind)'." + ) + } + } +} + +function global:Read-Task7ParityMatrixJson { + param( + [Parameter(Mandatory)] [string] $Json, + [string] $Sha256 = '' + ) + + $requiredRows = [ordered] @{ + 'construction-certificate' = @('construction', 'Certificate', 'construction-only', 'construction-only') + 'construction-client-secret' = @('construction', 'ClientSecret', 'construction-only', 'construction-only') + 'construction-managed-identity' = @('construction', 'ManagedIdentity', 'construction-only', 'construction-only') + 'construction-bearer-token' = @('construction', 'BearerToken', 'construction-only', 'construction-only') + 'ordinary-cache-hit' = @('cache-hit', 'Certificate', 'direct-source', 'direct-source') + 'expired-result-refresh' = @('expiry-refresh', 'ClientSecret', 'direct-source', 'direct-source') + 'ordinary-forced-ordinary' = @('force-partition', 'ManagedIdentity', 'direct-source', 'direct-source') + 'acquisition-failure-fanout-retry' = @('failure-fanout-retry', 'Certificate', 'compiled-internal-source-flight', 'legacy-production-outer-keyed-flight') + 'caller-cancellation-no-cache' = @('caller-cancellation', 'ClientSecret', 'direct-source', 'direct-source') + 'fixed-bearer-cache-force-refusal' = @('fixed-bearer', 'BearerToken', 'direct-source', 'direct-source') + 'fingerprint-certificate' = @('fingerprint', 'Certificate', 'direct-source', 'direct-source') + 'fingerprint-client-secret' = @('fingerprint', 'ClientSecret', 'direct-source', 'direct-source') + 'fingerprint-managed-identity' = @('fingerprint', 'ManagedIdentity', 'direct-source', 'direct-source') + 'fingerprint-bearer-token' = @('fingerprint', 'BearerToken', 'direct-source', 'direct-source') + 'adoption-generation-mismatch' = @('adoption-mismatch', 'Certificate', 'direct-source', 'direct-source') + 'adoption-valid' = @('adoption-valid', 'ManagedIdentity', 'direct-source', 'direct-source') + } + $rowFields = @( + 'id', 'runners', 'scenario', 'authMode', 'callLayerByRunner', 'input', + 'expectedByRunner' + ) + $inputFields = @( + 'tokens', 'expiresOnUtc', 'forceFlags', 'cancelCaller', 'fingerprintInput', + 'adoptToken', 'adoptGeneration', 'adoptReceivedOnUtc', 'adoptExpiresOnUtc', + 'adoptTenantProof' + ) + $expectedFields = @( + 'canRefresh', 'authMode', 'audience', 'clientId', 'credentialGeneration', + 'sourceExpiresOnUtc', 'sourceVerifiedTenantId', 'tokenSequence', 'expiriesOnUtc', + 'tokenTypes', 'orderedScopes', 'tenantProofs', 'fingerprints', 'generations', + 'receivedTimeRule', 'applicationConstructionCount', 'providerAcquisitionCount', + 'forceFlags', 'referenceIdentity', 'failureKind', 'cacheState', + 'finalFlightRegistryCount' + ) + $hint = if ($Json -cmatch '"schemaVersion"\s*:\s*2') { + 'unsupported-schema-version' + } + elseif ($Json -cmatch '"rowCount"\s*:\s*15') { + 'incorrect-row-count' + } + elseif ($Json -cmatch 'replacement-row-id') { + 'missing-required-row-id' + } + elseif ($Json -cmatch '"unexpected"') { + 'unknown-property' + } + elseif ($Json -cmatch '"schemaVersion"\s*:\s*1\s*,\s*"schemaVersion"') { + 'duplicate-json-property' + } + else { + 'malformed-matrix' + } + + try { + $document = [System.Text.Json.JsonDocument]::Parse($Json) + try { + $root = $document.RootElement + Assert-Task7NoDuplicateJsonProperties -Element $root -Location root + Assert-Task7ExactJsonFields -Element $root ` + -Expected @('schemaVersion', 'rowCount', 'rows') -Location root + $schema = Get-Task7JsonProperty -Element $root -Name schemaVersion -Location root + $rowCount = Get-Task7JsonProperty -Element $root -Name rowCount -Location root + Assert-Task7JsonKind -Element $schema -Allowed Number -Location root.schemaVersion + Assert-Task7JsonKind -Element $rowCount -Allowed Number -Location root.rowCount + if ($schema.GetInt32() -ne 1) { + throw [System.IO.InvalidDataException]::new('schemaVersion must equal 1.') + } + if ($rowCount.GetInt32() -ne 16) { + throw [System.IO.InvalidDataException]::new('rowCount must equal 16.') + } + $rowsElement = Get-Task7JsonProperty -Element $root -Name rows -Location root + Assert-Task7JsonKind -Element $rowsElement -Allowed Array -Location root.rows + if ($rowsElement.GetArrayLength() -ne 16) { + throw [System.IO.InvalidDataException]::new('rows must contain exactly 16 items.') + } + + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($row in $rowsElement.EnumerateArray()) { + Assert-Task7NoDuplicateJsonProperties -Element $row -Location row + Assert-Task7ExactJsonFields -Element $row -Expected $rowFields -Location row + $idElement = Get-Task7JsonProperty -Element $row -Name id -Location row + Assert-Task7JsonKind -Element $idElement -Allowed String -Location row.id + $id = $idElement.GetString() + if (-not $seen.Add($id)) { + throw [System.IO.InvalidDataException]::new("duplicate row id '$id'.") + } + if (-not $requiredRows.Contains($id)) { + throw [System.IO.InvalidDataException]::new("unknown row id '$id'.") + } + + $runners = Get-Task7JsonProperty -Element $row -Name runners -Location "row '$id'" + Assert-Task7JsonArrayItems -Element $runners -Allowed String ` + -Location "row '$id'.runners" + $runnerValues = @($runners.EnumerateArray() | ForEach-Object { $_.GetString() }) + if ($runnerValues.Count -ne 2 -or + $runnerValues[0] -cne 'xunit-compiled' -or + $runnerValues[1] -cne 'pester-legacy') { + throw [System.IO.InvalidDataException]::new( + "row '$id' has an invalid runner set or order." + ) + } + $scenario = Get-Task7JsonProperty -Element $row -Name scenario -Location "row '$id'" + $mode = Get-Task7JsonProperty -Element $row -Name authMode -Location "row '$id'" + Assert-Task7JsonKind -Element $scenario -Allowed String -Location "row '$id'.scenario" + Assert-Task7JsonKind -Element $mode -Allowed String -Location "row '$id'.authMode" + if ($scenario.GetString() -cne $requiredRows[$id][0] -or + $mode.GetString() -cne $requiredRows[$id][1]) { + throw [System.IO.InvalidDataException]::new( + "row '$id' has an invalid scenario or authentication mode." + ) + } + + $layers = Get-Task7JsonProperty -Element $row -Name callLayerByRunner ` + -Location "row '$id'" + Assert-Task7NoDuplicateJsonProperties -Element $layers ` + -Location "row '$id'.callLayerByRunner" + Assert-Task7ExactJsonFields -Element $layers ` + -Expected @('xunit-compiled', 'pester-legacy') ` + -Location "row '$id'.callLayerByRunner" + $xunitLayer = Get-Task7JsonProperty -Element $layers -Name xunit-compiled ` + -Location "row '$id'.callLayerByRunner" + $pesterLayer = Get-Task7JsonProperty -Element $layers -Name pester-legacy ` + -Location "row '$id'.callLayerByRunner" + Assert-Task7JsonKind -Element $xunitLayer -Allowed String ` + -Location "row '$id'.callLayerByRunner.xunit-compiled" + Assert-Task7JsonKind -Element $pesterLayer -Allowed String ` + -Location "row '$id'.callLayerByRunner.pester-legacy" + if ($xunitLayer.GetString() -cne $requiredRows[$id][2] -or + $pesterLayer.GetString() -cne $requiredRows[$id][3]) { + throw [System.IO.InvalidDataException]::new( + "row '$id' has an invalid runner call layer." + ) + } + + $inputElement = Get-Task7JsonProperty -Element $row -Name input -Location "row '$id'" + Assert-Task7NoDuplicateJsonProperties -Element $inputElement -Location "row '$id'.input" + Assert-Task7ExactJsonFields -Element $inputElement -Expected $inputFields ` + -Location "row '$id'.input" + foreach ($name in @('tokens', 'expiresOnUtc')) { + $value = Get-Task7JsonProperty -Element $inputElement -Name $name -Location "row '$id'.input" + Assert-Task7JsonArrayItems -Element $value -Allowed String ` + -Location "row '$id'.input.$name" + if ($name -ceq 'expiresOnUtc') { + $dateIndex = 0 + foreach ($timestamp in $value.EnumerateArray()) { + Assert-Task7StrictTimestamp -Element $timestamp ` + -Location "row '$id'.input.$name[$dateIndex]" + $dateIndex++ + } + } + } + $inputFlags = Get-Task7JsonProperty -Element $inputElement -Name forceFlags ` + -Location "row '$id'.input" + Assert-Task7JsonArrayItems -Element $inputFlags -Allowed @('True', 'False') ` + -Location "row '$id'.input.forceFlags" + $cancel = Get-Task7JsonProperty -Element $inputElement -Name cancelCaller ` + -Location "row '$id'.input" + Assert-Task7JsonKind -Element $cancel -Allowed @('True', 'False') ` + -Location "row '$id'.input.cancelCaller" + foreach ($name in @( + 'fingerprintInput', 'adoptToken', 'adoptGeneration', + 'adoptReceivedOnUtc', 'adoptExpiresOnUtc', 'adoptTenantProof' + )) { + $value = Get-Task7JsonProperty -Element $inputElement -Name $name ` + -Location "row '$id'.input" + Assert-Task7JsonKind -Element $value -Allowed @('String', 'Null') ` + -Location "row '$id'.input.$name" + } + foreach ($name in @('adoptReceivedOnUtc', 'adoptExpiresOnUtc')) { + $value = Get-Task7JsonProperty -Element $inputElement -Name $name ` + -Location "row '$id'.input" + if ($value.ValueKind -eq [System.Text.Json.JsonValueKind]::String) { + Assert-Task7StrictTimestamp -Element $value ` + -Location "row '$id'.input.$name" + } + } + + $expectedByRunner = Get-Task7JsonProperty -Element $row ` + -Name expectedByRunner -Location "row '$id'" + Assert-Task7NoDuplicateJsonProperties -Element $expectedByRunner ` + -Location "row '$id'.expectedByRunner" + Assert-Task7ExactJsonFields -Element $expectedByRunner ` + -Expected @('xunit-compiled', 'pester-legacy') ` + -Location "row '$id'.expectedByRunner" + foreach ($runner in @('xunit-compiled', 'pester-legacy')) { + $expected = Get-Task7JsonProperty -Element $expectedByRunner -Name $runner ` + -Location "row '$id'.expectedByRunner" + $location = "row '$id'.expectedByRunner.$runner" + Assert-Task7NoDuplicateJsonProperties -Element $expected -Location $location + Assert-Task7ExactJsonFields -Element $expected -Expected $expectedFields ` + -Location $location + foreach ($name in @('canRefresh')) { + Assert-Task7JsonKind ` + -Element (Get-Task7JsonProperty $expected $name $location) ` + -Allowed @('True', 'False') -Location "$location.$name" + } + foreach ($name in @( + 'authMode', 'audience', 'credentialGeneration', 'sourceExpiresOnUtc', + 'receivedTimeRule', 'referenceIdentity', 'cacheState' + )) { + Assert-Task7JsonKind ` + -Element (Get-Task7JsonProperty $expected $name $location) ` + -Allowed String -Location "$location.$name" + } + Assert-Task7StrictTimestamp ` + -Element (Get-Task7JsonProperty $expected sourceExpiresOnUtc $location) ` + -Location "$location.sourceExpiresOnUtc" + foreach ($name in @( + 'clientId', 'sourceVerifiedTenantId', 'failureKind' + )) { + Assert-Task7JsonKind ` + -Element (Get-Task7JsonProperty $expected $name $location) ` + -Allowed @('String', 'Null') -Location "$location.$name" + } + foreach ($name in @( + 'tokenSequence', 'expiriesOnUtc', 'tokenTypes', 'fingerprints', + 'generations' + )) { + Assert-Task7JsonArrayItems ` + -Element (Get-Task7JsonProperty $expected $name $location) ` + -Allowed String -Location "$location.$name" + } + $expiryIndex = 0 + foreach ($timestamp in ( + Get-Task7JsonProperty $expected expiriesOnUtc $location + ).EnumerateArray()) { + Assert-Task7StrictTimestamp -Element $timestamp ` + -Location "$location.expiriesOnUtc[$expiryIndex]" + $expiryIndex++ + } + Assert-Task7JsonArrayItems ` + -Element (Get-Task7JsonProperty $expected orderedScopes $location) ` + -Allowed Array -NestedStringArrays -Location "$location.orderedScopes" + Assert-Task7JsonArrayItems ` + -Element (Get-Task7JsonProperty $expected tenantProofs $location) ` + -Allowed @('String', 'Null') -Location "$location.tenantProofs" + Assert-Task7JsonArrayItems ` + -Element (Get-Task7JsonProperty $expected forceFlags $location) ` + -Allowed @('True', 'False') -Location "$location.forceFlags" + foreach ($name in @( + 'applicationConstructionCount', 'providerAcquisitionCount', + 'finalFlightRegistryCount' + )) { + $number = Get-Task7JsonProperty $expected $name $location + Assert-Task7JsonKind -Element $number -Allowed Number ` + -Location "$location.$name" + if ($number.GetInt32() -lt 0) { + throw [System.IO.InvalidDataException]::new( + "$location.$name must be a non-negative integer." + ) + } + } + } + } + $missing = @($requiredRows.Keys | Where-Object { -not $seen.Contains($_) }) + if ($missing.Count -gt 0) { + throw [System.IO.InvalidDataException]::new( + "missing required row id '$($missing[0])'." + ) + } + $data = ConvertFrom-Task7JsonElement -Element $root + } + finally { + $document.Dispose() + } + + return [pscustomobject] @{ + SchemaVersion = [int] $data.schemaVersion + RowCount = [int] $data.rowCount + Rows = [object[]] @($data.rows) + Sha256 = $Sha256 + } + } + catch { + throw [System.IO.InvalidDataException]::new("$hint`: $($_.Exception.Message)", $_.Exception) + } +} + +function global:Get-Task7MalformedParityJson { + param( + [Parameter(Mandatory)] [string] $ValidJson, + [Parameter(Mandatory)] [string] $MutationId + ) + if ($MutationId -ceq 'duplicate-json-property') { + return $ValidJson.Replace( + '"schemaVersion": 1,', + '"schemaVersion": 1, "schemaVersion": 1,' + ) + } + + $document = [System.Text.Json.JsonDocument]::Parse($ValidJson) + try { + $data = ConvertFrom-Task7JsonElement -Element $document.RootElement + } + finally { + $document.Dispose() + } + switch ($MutationId) { + 'unsupported-schema-version' { $data.schemaVersion = 2 } + 'incorrect-row-count' { $data.rowCount = 15 } + 'duplicate-row-id' { $data.rows[1].id = $data.rows[0].id } + 'missing-required-row-id' { $data.rows[0].id = 'replacement-row-id' } + 'unknown-property' { $data.rows[0].unexpected = $true } + 'missing-required-property' { $null = $data.rows[0].Remove('scenario') } + 'invalid-runner-call-layer' { + $data.rows[0].callLayerByRunner.'xunit-compiled' = 'direct-source' + } + 'missing-runner-expectation' { + $null = $data.rows[0].expectedByRunner.Remove('pester-legacy') + } + default { throw "Unknown Task 7 malformed case '$MutationId'." } + } + return $data | ConvertTo-Json -Depth 100 +} + +BeforeDiscovery { + $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent + $fixturePath = Join-Path $repoRoot 'tests/Fixtures/GraphKitAuthParityCases.json' + $fixtureBytes = [System.IO.File]::ReadAllBytes($fixturePath) + $fixtureJson = [System.Text.UTF8Encoding]::new($false, $true).GetString($fixtureBytes) + $fixtureSha = [Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($fixtureBytes) + ).ToLowerInvariant() + $discoveredMatrix = Read-Task7ParityMatrixJson -Json $fixtureJson -Sha256 $fixtureSha + $matrixRows = @($discoveredMatrix.Rows | ForEach-Object { + @{ CaseId = [string] $_.id; Row = $_ } + }) + $malformedCases = @( + 'unsupported-schema-version', + 'incorrect-row-count', + 'duplicate-row-id', + 'missing-required-row-id', + 'unknown-property', + 'missing-required-property', + 'duplicate-json-property', + 'invalid-runner-call-layer', + 'missing-runner-expectation' + ) | ForEach-Object { @{ MutationId = $_ } } +} + +BeforeAll { + $script:ExpectedMatrixSha = 'c6953120ea3a29966acabf671a193e7ff51b38d561fb0028a2a585177dea0eb0' + $script:RepoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent + $script:FixturePath = Join-Path $script:RepoRoot 'tests/Fixtures/GraphKitAuthParityCases.json' + $fixtureBytes = [System.IO.File]::ReadAllBytes($script:FixturePath) + $fixtureJson = [System.Text.UTF8Encoding]::new($false, $true).GetString($fixtureBytes) + $fixtureSha = [Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($fixtureBytes) + ).ToLowerInvariant() + $script:Matrix = Read-Task7ParityMatrixJson -Json $fixtureJson -Sha256 $fixtureSha + $script:FixtureJson = $fixtureJson + + $builtCandidates = @( + Get-ChildItem -LiteralPath (Join-Path $script:RepoRoot 'output/module/GraphKit') ` + -Directory | Sort-Object Name -Descending + ) + $built = if ($builtCandidates.Count -gt 0) { $builtCandidates[0] } else { $null } + if ($null -eq $built) { + throw 'GraphKit is not packed. Run ./build.ps1 -Tasks pack before this file.' + } + $script:BuiltManifest = Join-Path $built.FullName 'GraphKit.psd1' + Import-Module $script:BuiltManifest -Force -ErrorAction Stop + + if ($null -eq ('GraphKit.Tests.Task7LegacyHarness' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphKit.Tests; + +public static class Task7LegacyHarness +{ + public const string ContractMarker = "GraphKit.Task7.LegacyHarness/2"; + private static ConcurrentQueue _tokens = new(); + private static ConcurrentQueue _expiries = new(); + private static ConcurrentQueue _forceFlags = new(); + private static int _applicationCount; + private static int _acquisitionCount; + private static int _outerAttempt; + private static ConcurrentQueue _outerForceFlags = new(); + private static CountdownEvent _ready = new(1); + private static ManualResetEventSlim _go = new(false); + private static ManualResetEventSlim _entered = new(false); + private static ManualResetEventSlim _release = new(false); + private static CancellationTokenSource _cleanup = new(); + private static ConcurrentQueue _outerFailures = new(); + + public static int ApplicationCount => Volatile.Read(ref _applicationCount); + public static int AcquisitionCount => Volatile.Read(ref _acquisitionCount); + public static bool[] ForceFlags => _forceFlags.ToArray(); + public static Exception[] OuterFailures => _outerFailures.ToArray(); + + public static void Configure(string[] tokens, DateTimeOffset[] expiries) + { + _tokens = new ConcurrentQueue(tokens); + _expiries = new ConcurrentQueue(expiries); + _forceFlags = new ConcurrentQueue(); + _outerFailures = new ConcurrentQueue(); + Interlocked.Exchange(ref _applicationCount, 0); + Interlocked.Exchange(ref _acquisitionCount, 0); + } + + public static Task7LegacyApplication CreateApplication() + { + Interlocked.Increment(ref _applicationCount); + return new Task7LegacyApplication(); + } + + internal static Task7LegacyAuthenticationResult Acquire( + bool forceRefresh, + CancellationToken cancellation) + { + Interlocked.Increment(ref _acquisitionCount); + _forceFlags.Enqueue(forceRefresh); + cancellation.ThrowIfCancellationRequested(); + if (!_tokens.TryDequeue(out string token) || !_expiries.TryDequeue(out DateTimeOffset expiry)) + { + throw new InvalidOperationException("No Task 7 legacy parity result remains."); + } + return new Task7LegacyAuthenticationResult { AccessToken = token, ExpiresOn = expiry }; + } + + public static void ConfigureOuter( + int participants, + string[] tokens, + DateTimeOffset[] expiries, + bool[] forceFlags) + { + CancelOuter(); + _ready.Dispose(); + _go.Dispose(); + _entered.Dispose(); + _release.Dispose(); + _cleanup.Dispose(); + _ready = new CountdownEvent(participants); + _go = new ManualResetEventSlim(false); + _entered = new ManualResetEventSlim(false); + _release = new ManualResetEventSlim(false); + _cleanup = new CancellationTokenSource(); + _tokens = new ConcurrentQueue(tokens); + _expiries = new ConcurrentQueue(expiries); + _outerForceFlags = new ConcurrentQueue(forceFlags); + _forceFlags = new ConcurrentQueue(); + _outerFailures = new ConcurrentQueue(); + Interlocked.Exchange(ref _applicationCount, 0); + Interlocked.Exchange(ref _acquisitionCount, 0); + Interlocked.Exchange(ref _outerAttempt, 0); + } + + public static bool WaitReady(int milliseconds) => _ready.Wait(milliseconds); + public static void Go() => _go.Set(); + public static bool WaitEntered(int milliseconds) => _entered.Wait(milliseconds); + public static void ReleaseOuter() => _release.Set(); + + public static void ParticipantReadyAndWait() + { + _ready.Signal(); + _go.Wait(_cleanup.Token); + } + + public static Task7LegacyAuthenticationResult AcquireOuter() + { + int attempt = Interlocked.Increment(ref _outerAttempt); + Interlocked.Increment(ref _acquisitionCount); + if (!_tokens.TryDequeue(out string token) || + !_expiries.TryDequeue(out DateTimeOffset expiry) || + !_outerForceFlags.TryDequeue(out bool forceRefresh)) + { + throw new InvalidOperationException("No Task 7 outer parity input remains."); + } + _forceFlags.Enqueue(forceRefresh); + if (attempt == 1) + { + _entered.Set(); + _release.Wait(_cleanup.Token); + throw new InvalidOperationException("task7-outer-acquisition-failure"); + } + return new Task7LegacyAuthenticationResult + { + AccessToken = token, + ExpiresOn = expiry + }; + } + + public static void CancelOuter() + { + try { _cleanup.Cancel(); } catch (ObjectDisposedException) { } + try { _go.Set(); } catch (ObjectDisposedException) { } + try { _release.Set(); } catch (ObjectDisposedException) { } + } + + public static void RecordOuterFailure(Exception failure) => _outerFailures.Enqueue(failure); + + public static void ResetAndDispose() + { + CancelOuter(); + TryDispose(_ready); + TryDispose(_go); + TryDispose(_entered); + TryDispose(_release); + TryDispose(_cleanup); + _tokens = new ConcurrentQueue(); + _expiries = new ConcurrentQueue(); + _outerForceFlags = new ConcurrentQueue(); + _forceFlags = new ConcurrentQueue(); + _outerFailures = new ConcurrentQueue(); + Interlocked.Exchange(ref _applicationCount, 0); + Interlocked.Exchange(ref _acquisitionCount, 0); + Interlocked.Exchange(ref _outerAttempt, 0); + } + + private static void TryDispose(IDisposable value) + { + try { value.Dispose(); } catch (ObjectDisposedException) { } + } +} + +public sealed class Task7LegacyApplication +{ + public Task7LegacyBuilder AcquireTokenForClient(string[] scopes) => new(); + public Task7LegacyBuilder AcquireTokenForManagedIdentity(string scope) => new(); +} + +public sealed class Task7LegacyBuilder +{ + private bool _forceRefresh; + + public Task7LegacyBuilder WithForceRefresh(bool forceRefresh) + { + _forceRefresh = forceRefresh; + return this; + } + + public Task ExecuteAsync(CancellationToken cancellation) => + Task.FromResult(Task7LegacyHarness.Acquire(_forceRefresh, cancellation)); +} + +public sealed class Task7LegacyAuthenticationResult +{ + public string AccessToken { get; set; } = string.Empty; + public DateTimeOffset ExpiresOn { get; set; } +} +'@ + } + $harnessType = 'GraphKit.Tests.Task7LegacyHarness' -as [type] + if ($null -eq $harnessType -or + [string] $harnessType.GetField('ContractMarker').GetRawConstantValue() -cne + 'GraphKit.Task7.LegacyHarness/2') { + throw 'The process-global Task 7 legacy harness has an incompatible identity or contract.' + } + + function New-Task7LegacySource { + param([Parameter(Mandatory)] $Row) + $mode = [string] $Row.authMode + $token = if ([string] $Row.scenario -ceq 'fingerprint') { + [string] $Row.input.fingerprintInput + } + elseif (@($Row.input.tokens).Count -gt 0) { + [string] $Row.input.tokens[0] + } + else { + 'task7-unused-bearer' + } + InModuleScope GraphKit -Parameters @{ Mode = $mode; Token = $token } { + param($Mode, $Token) + $factory = [scriptblock]::Create( + '[GraphKit.Tests.Task7LegacyHarness]::CreateApplication()' + ) + switch ($Mode) { + 'Certificate' { + [ConfidentialClientTokenSource]::new( + $factory, + 'Certificate', + 'https://graph.microsoft.com', + '00000000-0000-0000-0000-000000000002', + 'task7-generation' + ) + } + 'ClientSecret' { + [ConfidentialClientTokenSource]::new( + $factory, + 'ClientSecret', + 'https://graph.microsoft.com', + '00000000-0000-0000-0000-000000000002', + 'task7-generation' + ) + } + 'ManagedIdentity' { + [ManagedIdentityTokenSource]::new( + $factory, + 'https://graph.microsoft.com', + '00000000-0000-0000-0000-000000000003', + 'task7-generation' + ) + } + 'BearerToken' { + [FixedBearerTokenSource]::new( + $Token, + 'https://graph.microsoft.com', + 'task7-generation' + ) + } + } + } + } + + function New-Task7LegacyAdoptedResult { + param([Parameter(Mandatory)] $ParityInput) + InModuleScope GraphKit -Parameters @{ ParityInput = $ParityInput } { + param($ParityInput) + $result = [GraphTokenResult]::new() + $result.AccessToken = [string] $ParityInput.adoptToken + $result.ExpiresOnUtc = ConvertFrom-Task7TimestampLiteral ` + ([string] $ParityInput.adoptExpiresOnUtc) + $result.ReceivedOnUtc = ConvertFrom-Task7TimestampLiteral ` + ([string] $ParityInput.adoptReceivedOnUtc) + $result.TokenType = 'Bearer' + $result.Scopes = [string[]] @('https://graph.microsoft.com/.default') + $result.VerifiedTenantId = $ParityInput.adoptTenantProof + $result.TokenFingerprint = Get-GraphFingerprint -Value ([string] $ParityInput.adoptToken) + $result.CredentialGeneration = [string] $ParityInput.adoptGeneration + return $result + } + } + + function Get-Task7LegacyFailureKind { + param([Parameter(Mandatory)] [Exception] $Exception) + $candidate = $Exception + while ($null -ne $candidate) { + if ($candidate -is [OperationCanceledException]) { return 'Canceled' } + $candidate = $candidate.InnerException + } + if ($Exception.Message -match 'cannot be refreshed') { return 'RefreshRefused' } + if ($Exception.Message -match 'credential generation') { return 'GenerationMismatch' } + return 'AcquisitionFailure' + } + + function Get-Task7InnermostException { + param([Parameter(Mandatory)] [Exception] $Exception) + $candidate = $Exception + while ($null -ne $candidate.InnerException) { + $candidate = $candidate.InnerException + } + return $candidate + } + + function Get-Task7LegacyCacheState { + param([Parameter(Mandatory)] $Source) + $populated = InModuleScope GraphKit -Parameters @{ Source = $Source } { + param($Source) + return $null -ne $Source.GetCachedToken() + } + if ($populated) { return 'Populated' } + return 'Empty' + } + + function Get-Task7OuterFlightCount { + InModuleScope GraphKit { + return [GraphTokenFlightRegistry]::Flights.Count + } + } + + function Get-Task7OuterWaiterCount { + param([Parameter(Mandatory)] [string] $Key) + InModuleScope GraphKit -Parameters @{ Key = $Key } { + param($Key) + $flight = [GraphTokenFlight] $null + if (-not [GraphTokenFlightRegistry]::Flights.TryGetValue($Key, [ref] $flight)) { + return -1 + } + return [int] (Get-GraphTokenFlightWaiterCount -Flight $flight) + } + } + + function Invoke-Task7LegacyOuterFailure { + param( + [Parameter(Mandatory)] $Source, + [Parameter(Mandatory)] $Row + ) + $key = 'task7-parity-' + [guid]::NewGuid().ToString('N') + $workers = [Collections.Generic.List[object]]::new() + $outerTokens = [string[]] @($Row.input.tokens) + $outerExpiries = [DateTimeOffset[]] @($Row.input.expiresOnUtc | ForEach-Object { + ConvertFrom-Task7TimestampLiteral ([string] $_) + }) + $outerForceFlags = [bool[]] @($Row.input.forceFlags) + [GraphKit.Tests.Task7LegacyHarness]::ConfigureOuter( + 4, + $outerTokens, + $outerExpiries, + $outerForceFlags + ) + $waitersObserved = $false + try { + # Start-ThreadJob shares a process-global throttle. Prepare dedicated + # runspaces synchronously so this test measures token-flight fan-out, + # not ambient job-scheduler capacity. + 1..4 | ForEach-Object { + $runspace = [runspacefactory]::CreateRunspace() + $worker = [pscustomobject] @{ + PowerShell = $null + Runspace = $runspace + Async = $null + Received = $false + } + $workers.Add($worker) + $runspace.ThreadOptions = + [System.Management.Automation.Runspaces.PSThreadOptions]::UseNewThread + $runspace.Open() + + $initializer = [powershell]::Create() + try { + $initializer.Runspace = $runspace + $null = $initializer.AddCommand('Import-Module'). + AddParameter('Name', $script:BuiltManifest). + AddParameter('Force', $true). + AddParameter('ErrorAction', 'Stop').Invoke() + if ($initializer.HadErrors) { + $messages = @($initializer.Streams.Error | ForEach-Object { + $_.Exception.Message + }) -join '; ' + throw "Dedicated Task 7 worker failed to import GraphKit: $messages" + } + } + finally { + $initializer.Dispose() + } + + $pipeline = [powershell]::Create() + $worker.PowerShell = $pipeline + $pipeline.Runspace = $runspace + $null = $pipeline.AddScript({ + param($Key) + $module = Get-Module -Name GraphKit + $state = $null + $outcome = $null + try { + $state = & $module { $script:GraphKitModuleLifecycle } + [GraphKit.Tests.Task7LegacyHarness]::ParticipantReadyAndWait() + $result = & $module { + param($Key) + Invoke-GraphTokenSingleFlight -Key $Key -AcquireScript { + $auth = [GraphKit.Tests.Task7LegacyHarness]::AcquireOuter() + $token = [GraphTokenResult]::new() + $token.AccessToken = $auth.AccessToken + $token.ExpiresOnUtc = $auth.ExpiresOn + $token.ReceivedOnUtc = [DateTimeOffset]::UtcNow + $token.TokenType = 'Bearer' + $token.Scopes = [string[]] @('https://graph.microsoft.com/.default') + $token.VerifiedTenantId = $null + $token.TokenFingerprint = Get-GraphFingerprint -Value $auth.AccessToken + $token.CredentialGeneration = 'task7-generation' + return $token + } + } $Key + $outcome = [pscustomobject] @{ Failed = $false; Result = $result } + } + catch { + [GraphKit.Tests.Task7LegacyHarness]::RecordOuterFailure($_.Exception) + $outcome = [pscustomobject] @{ + Failed = $true + Message = $_.Exception.Message + ErrorText = ($_ | Out-String) + } + } + finally { + if ($null -ne $module) { + Remove-Module $module -Force -ErrorAction SilentlyContinue + } + $cleaned = $null -ne $state -and $state.WaitForCleanup(5000) + $module = $null + $state = $null + } + $outcome | Add-Member NoteProperty ChildCleanup $cleaned + return $outcome + }).AddArgument($key) + } + + foreach ($worker in $workers) { + $worker.Async = $worker.PowerShell.BeginInvoke() + } + [GraphKit.Tests.Task7LegacyHarness]::WaitReady(5000) | Should -BeTrue + [GraphKit.Tests.Task7LegacyHarness]::Go() + [GraphKit.Tests.Task7LegacyHarness]::WaitEntered(5000) | Should -BeTrue + $waitersObserved = [Threading.SpinWait]::SpinUntil( + [Func[bool]] { (Get-Task7OuterWaiterCount -Key $key) -eq 3 }, + 5000 + ) + [GraphKit.Tests.Task7LegacyHarness]::ReleaseOuter() + $outcomes = @( + foreach ($worker in $workers) { + $worker.Async.AsyncWaitHandle.WaitOne(10000) | + Should -BeTrue -Because 'each dedicated Task 7 worker must complete' + $worker.Received = $true + $worker.PowerShell.EndInvoke($worker.Async) + } + ) + $outcomes.Count | Should -Be 4 + @($outcomes | Where-Object Failed).Count | Should -Be 4 + $actualFailures = @([GraphKit.Tests.Task7LegacyHarness]::OuterFailures) + $actualFailures.Count | Should -Be 4 + $normalizedKinds = @($actualFailures | ForEach-Object { + $rootFailure = Get-Task7InnermostException -Exception $_ + $rootFailure.GetType().FullName | Should -BeExactly ` + 'System.InvalidOperationException' + $rootFailure.Message | Should -BeExactly 'task7-outer-acquisition-failure' + Get-Task7LegacyFailureKind -Exception $rootFailure + }) + @($normalizedKinds | Select-Object -Unique) | Should -Be @('AcquisitionFailure') + @($outcomes | Where-Object { -not $_.ChildCleanup }).Count | Should -Be 0 + + $recovered = InModuleScope GraphKit -Parameters @{ Key = $key } { + param($Key) + Invoke-GraphTokenSingleFlight -Key $Key -AcquireScript { + $auth = [GraphKit.Tests.Task7LegacyHarness]::AcquireOuter() + $token = [GraphTokenResult]::new() + $token.AccessToken = $auth.AccessToken + $token.ExpiresOnUtc = $auth.ExpiresOn + $token.ReceivedOnUtc = [DateTimeOffset]::UtcNow + $token.TokenType = 'Bearer' + $token.Scopes = [string[]] @('https://graph.microsoft.com/.default') + $token.VerifiedTenantId = $null + $token.TokenFingerprint = Get-GraphFingerprint -Value $auth.AccessToken + $token.CredentialGeneration = 'task7-generation' + return $token + } + } + $Source.AdoptSharedResult($recovered, [bool] $Row.input.forceFlags[1]) + return [pscustomobject] @{ + Result = $recovered + FailureKind = [string] $normalizedKinds[0] + WaitersObserved = $waitersObserved + } + } + finally { + [GraphKit.Tests.Task7LegacyHarness]::CancelOuter() + foreach ($worker in $workers) { + if ($null -ne $worker.Async -and -not $worker.Received) { + if ($worker.Async.AsyncWaitHandle.WaitOne(10000)) { + try { $null = $worker.PowerShell.EndInvoke($worker.Async) } catch { } + } + else { + try { $worker.PowerShell.Stop() } catch { } + } + } + if ($null -ne $worker.PowerShell) { $worker.PowerShell.Dispose() } + if ($null -ne $worker.Runspace) { + try { $worker.Runspace.Close() } catch { } + $worker.Runspace.Dispose() + } + } + } + } + + function Assert-Task7DeclarativeInputContract { + param([Parameter(Mandatory)] $Row) + + $rowId = [string] $Row.id + [bool] $Row.input.cancelCaller | + Should -Be ($rowId -ceq 'caller-cancellation-no-cache') + + $fingerprintScenario = [string] $Row.scenario -ceq 'fingerprint' + if ($fingerprintScenario) { + [string] $Row.input.fingerprintInput | Should -Not -BeNullOrEmpty + [string[]] @($Row.input.tokens) | + Should -Be @([string] $Row.input.fingerprintInput) + } + else { + ($null -eq $Row.input.fingerprintInput) | Should -BeTrue + } + + $expectedForceFlags = switch -Exact ($rowId) { + { $_ -in @( + 'construction-certificate', 'construction-client-secret', + 'construction-managed-identity', 'construction-bearer-token' + ) } { [bool[]] @(); break } + { $_ -in @( + 'ordinary-cache-hit', 'expired-result-refresh', + 'acquisition-failure-fanout-retry' + ) } { [bool[]] @($false, $false); break } + 'ordinary-forced-ordinary' { [bool[]] @($false, $true, $false); break } + { $_ -in @( + 'caller-cancellation-no-cache', 'fingerprint-certificate', + 'fingerprint-client-secret', 'fingerprint-managed-identity', + 'fingerprint-bearer-token', 'adoption-generation-mismatch', + 'adoption-valid' + ) } { [bool[]] @($false); break } + 'fixed-bearer-cache-force-refusal' { + [bool[]] @($false, $false, $true) + break + } + default { throw "Unhandled Task 7 input contract row '$rowId'." } + } + [bool[]] @($Row.input.forceFlags) | Should -Be $expectedForceFlags + + if ($rowId -ceq 'acquisition-failure-fanout-retry') { + [string[]] @($Row.input.tokens) | + Should -Be @('task7-failure', 'task7-recovered') + [string[]] @($Row.input.expiresOnUtc) | Should -Be @( + '2099-04-01T00:00:00+00:00', + '2099-04-01T00:00:00+00:00' + ) + } + } + + function Invoke-Task7LegacyRow { + param([Parameter(Mandatory)] $Row) + Assert-Task7DeclarativeInputContract -Row $Row + [string[]] $tokens = @($Row.input.tokens) + if ([string] $Row.scenario -ceq 'fingerprint') { + $tokens = [string[]] @([string] $Row.input.fingerprintInput) + } + $expiries = [DateTimeOffset[]] @($Row.input.expiresOnUtc | ForEach-Object { + ConvertFrom-Task7TimestampLiteral ([string] $_) + }) + [GraphKit.Tests.Task7LegacyHarness]::Configure($tokens, $expiries) + $source = New-Task7LegacySource -Row $Row + $results = [Collections.Generic.List[object]]::new() + $adopted = $null + $failureKind = $null + $waitersObserved = $null + + $rowId = [string] $Row.id + if ($rowId -in @( + 'construction-certificate', 'construction-client-secret', + 'construction-managed-identity', 'construction-bearer-token' + )) { + # Construction is deliberately acquisition-free. + } + elseif ($rowId -in @( + 'ordinary-cache-hit', 'expired-result-refresh', + 'ordinary-forced-ordinary', 'fingerprint-certificate', + 'fingerprint-client-secret', 'fingerprint-managed-identity', + 'fingerprint-bearer-token' + )) { + foreach ($force in @($Row.input.forceFlags)) { + $results.Add($source.Acquire( + [bool] $force, + [Threading.CancellationToken]::None + )) + } + } + elseif ($rowId -ceq 'acquisition-failure-fanout-retry') { + $outer = Invoke-Task7LegacyOuterFailure -Source $source -Row $Row + $results.Add($outer.Result) + $failureKind = $outer.FailureKind + $waitersObserved = $outer.WaitersObserved + } + elseif ($rowId -ceq 'caller-cancellation-no-cache') { + $cancellation = [Threading.CancellationTokenSource]::new() + try { + if ([bool] $Row.input.cancelCaller) { + $cancellation.Cancel() + } + try { + $null = $source.Acquire( + [bool] $Row.input.forceFlags[0], + $cancellation.Token + ) + throw 'Task 7 expected legacy caller cancellation.' + } + catch { + $failureKind = Get-Task7LegacyFailureKind -Exception $_.Exception + } + } + finally { $cancellation.Dispose() } + } + elseif ($rowId -ceq 'fixed-bearer-cache-force-refusal') { + $results.Add($source.Acquire( + [bool] $Row.input.forceFlags[0], + [Threading.CancellationToken]::None + )) + $results.Add($source.Acquire( + [bool] $Row.input.forceFlags[1], + [Threading.CancellationToken]::None + )) + try { + $null = $source.Acquire( + [bool] $Row.input.forceFlags[2], + [Threading.CancellationToken]::None + ) + throw 'Task 7 expected fixed-bearer force refusal.' + } + catch { + $failureKind = Get-Task7LegacyFailureKind -Exception $_.Exception + } + } + elseif ($rowId -ceq 'adoption-generation-mismatch') { + $adopted = New-Task7LegacyAdoptedResult -ParityInput $Row.input + try { + $source.AdoptSharedResult($adopted, [bool] $Row.input.forceFlags[0]) + throw 'Task 7 expected generation mismatch.' + } + catch { + $failureKind = Get-Task7LegacyFailureKind -Exception $_.Exception + } + } + elseif ($rowId -ceq 'adoption-valid') { + $adopted = New-Task7LegacyAdoptedResult -ParityInput $Row.input + $source.AdoptSharedResult($adopted, [bool] $Row.input.forceFlags[0]) + $results.Add($source.Acquire( + [bool] $Row.input.forceFlags[0], + [Threading.CancellationToken]::None + )) + } + else { + throw "Unhandled Task 7 legacy parity row '$rowId'." + } + + return [pscustomobject] @{ + Source = $source + Results = [object[]] $results.ToArray() + Adopted = $adopted + FailureKind = $failureKind + ApplicationConstructionCount = [GraphKit.Tests.Task7LegacyHarness]::ApplicationCount + ProviderAcquisitionCount = [GraphKit.Tests.Task7LegacyHarness]::AcquisitionCount + ForceFlags = [bool[]] [GraphKit.Tests.Task7LegacyHarness]::ForceFlags + CacheState = Get-Task7LegacyCacheState -Source $source + FinalFlightRegistryCount = Get-Task7OuterFlightCount + WaitersObserved = $waitersObserved + } + } + + function ConvertTo-Task7Signature { + param([AllowNull()] $Value) + return ConvertTo-Json -InputObject @($Value) -Compress -Depth 20 + } +} + +AfterAll { + if ($null -ne ('GraphKit.Tests.Task7LegacyHarness' -as [type])) { + [GraphKit.Tests.Task7LegacyHarness]::ResetAndDispose() + } + Remove-Module GraphKit -Force -ErrorAction SilentlyContinue + foreach ($name in @( + 'Get-Task7JsonProperty', + 'Assert-Task7NoDuplicateJsonProperties', + 'Assert-Task7ExactJsonFields', + 'Assert-Task7JsonKind', + 'Assert-Task7JsonArrayItems', + 'Assert-Task7StrictTimestamp', + 'ConvertTo-Task7TimestampLiteral', + 'ConvertFrom-Task7TimestampLiteral', + 'ConvertFrom-Task7JsonElement', + 'Read-Task7ParityMatrixJson', + 'Get-Task7MalformedParityJson' + )) { + Remove-Item -LiteralPath "Function:\global:$name" -Force -ErrorAction SilentlyContinue + } +} + +Describe 'GraphKit.Auth strict deterministic parity matrix' -Tag Unit { + It 'runs legacy semantic row exactly once' -ForEach $matrixRows { + $script:Matrix.Sha256 | Should -BeExactly $script:ExpectedMatrixSha + $script:Matrix.RowCount | Should -Be 16 + $runtimeIds = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal + ) + foreach ($runtimeRow in $script:Matrix.Rows) { + $runtimeIds.Add([string] $runtimeRow['id']) | Should -BeTrue + } + $runtimeIds.Count | Should -Be 16 + @($Row.runners) | Should -Be @('xunit-compiled', 'pester-legacy') + [string] $Row.callLayerByRunner.'pester-legacy' | Should -Not -BeNullOrEmpty + + $expected = $Row.expectedByRunner.'pester-legacy' + $actual = Invoke-Task7LegacyRow -Row $Row + $source = $actual.Source + + $source.CanRefresh | Should -Be ([bool] $expected.canRefresh) + [string] $source.AuthMode | Should -BeExactly ([string] $expected.authMode) + [string] $source.Audience | Should -BeExactly ([string] $expected.audience) + if ($null -eq $expected.clientId) { + $source.ClientId | Should -BeNullOrEmpty + } + else { + [string] $source.ClientId | Should -BeExactly ([string] $expected.clientId) + } + [string] $source.CredentialGeneration | Should -BeExactly ` + ([string] $expected.credentialGeneration) + ConvertTo-Task7TimestampLiteral ([DateTimeOffset] $source.ExpiresOn) | + Should -BeExactly ([string] $expected.sourceExpiresOnUtc) + if ($null -eq $expected.sourceVerifiedTenantId) { + $source.VerifiedTenantId | Should -BeNullOrEmpty + } + else { + [string] $source.VerifiedTenantId | Should -BeExactly ` + ([string] $expected.sourceVerifiedTenantId) + } + + $results = @($actual.Results) + ConvertTo-Task7Signature @($results | ForEach-Object AccessToken) | + Should -BeExactly (ConvertTo-Task7Signature @($expected.tokenSequence)) + ConvertTo-Task7Signature @($results | ForEach-Object { + ConvertTo-Task7TimestampLiteral ([DateTimeOffset] $_.ExpiresOnUtc) + }) | Should -BeExactly (ConvertTo-Task7Signature @($expected.expiriesOnUtc)) + ConvertTo-Task7Signature @($results | ForEach-Object TokenType) | + Should -BeExactly (ConvertTo-Task7Signature @($expected.tokenTypes)) + ConvertTo-Task7Signature @($results | ForEach-Object { + [string]::Join([char] 0x1f, [string[]] $_.Scopes) + }) | Should -BeExactly (ConvertTo-Task7Signature @( + $expected.orderedScopes | ForEach-Object { + [string]::Join([char] 0x1f, [string[]] $_) + } + )) + $actualTenantProofs = [Collections.Generic.List[object]]::new() + foreach ($result in $results) { + $proof = [string] $result.VerifiedTenantId + $actualTenantProofs.Add($(if ([string]::IsNullOrEmpty($proof)) { + $null + } + else { + $proof + })) + } + ConvertTo-Task7Signature $actualTenantProofs.ToArray() | + Should -BeExactly (ConvertTo-Task7Signature @($expected.tenantProofs)) + ConvertTo-Task7Signature @($results | ForEach-Object TokenFingerprint) | + Should -BeExactly (ConvertTo-Task7Signature @($expected.fingerprints)) + ConvertTo-Task7Signature @($results | ForEach-Object CredentialGeneration) | + Should -BeExactly (ConvertTo-Task7Signature @($expected.generations)) + + switch ([string] $expected.receivedTimeRule) { + 'None' { $results.Count | Should -Be 0 } + 'WallClock' { + $previous = [DateTimeOffset]::MinValue + foreach ($result in $results) { + $received = [DateTimeOffset] $result.ReceivedOnUtc + $received | Should -BeGreaterThan ([DateTimeOffset]::MinValue) + $received | Should -BeGreaterOrEqual $previous + if ([DateTimeOffset] $result.ExpiresOnUtc -gt [DateTimeOffset]::UtcNow) { + $received | Should -BeLessOrEqual ([DateTimeOffset] $result.ExpiresOnUtc) + } + $previous = $received + } + } + 'LiteralAdopted' { + foreach ($result in $results) { + ConvertTo-Task7TimestampLiteral ([DateTimeOffset] $result.ReceivedOnUtc) | + Should -BeExactly ([string] $Row.input.adoptReceivedOnUtc) + } + } + default { throw "Unexpected legacy received-time rule '$($expected.receivedTimeRule)'." } + } + + $actual.ApplicationConstructionCount | Should -Be ` + ([int] $expected.applicationConstructionCount) + $actual.ProviderAcquisitionCount | Should -Be ` + ([int] $expected.providerAcquisitionCount) + ConvertTo-Task7Signature @($actual.ForceFlags) | + Should -BeExactly (ConvertTo-Task7Signature @($expected.forceFlags)) + switch ([string] $expected.referenceIdentity) { + 'None' { $results.Count | Should -Be 0 } + 'Single' { $results.Count | Should -Be 1 } + 'AllSame' { + foreach ($result in $results) { + [object]::ReferenceEquals($results[0], $result) | Should -BeTrue + } + } + 'AllDistinct' { + [object]::ReferenceEquals($results[0], $results[1]) | Should -BeFalse + } + 'SecondAndThirdSame' { + [object]::ReferenceEquals($results[0], $results[1]) | Should -BeFalse + [object]::ReferenceEquals($results[1], $results[2]) | Should -BeTrue + } + 'AdoptedAndReturnedSame' { + [object]::ReferenceEquals($actual.Adopted, $results[0]) | Should -BeTrue + } + default { throw "Unexpected Task 7 reference rule '$($expected.referenceIdentity)'." } + } + if ($null -eq $expected.failureKind) { + $actual.FailureKind | Should -BeNullOrEmpty + } + else { + [string] $actual.FailureKind | Should -BeExactly ([string] $expected.failureKind) + } + [string] $actual.CacheState | Should -BeExactly ([string] $expected.cacheState) + $actual.FinalFlightRegistryCount | Should -Be ([int] $expected.finalFlightRegistryCount) + if ($CaseId -ceq 'acquisition-failure-fanout-retry') { + $actual.WaitersObserved | Should -BeTrue -Because ` + 'outer GraphTokenFlight must expose exactly three live followers before release' + } + } + + It 'rejects malformed matrix case independently' -ForEach $malformedCases { + $malformed = Get-Task7MalformedParityJson -ValidJson $script:FixtureJson ` + -MutationId $MutationId + $caught = $null + try { + $null = Read-Task7ParityMatrixJson -Json $malformed + } + catch { + $caught = $_.Exception + } + $caught | Should -Not -BeNullOrEmpty + $expectedDiagnostic = switch ($MutationId) { + 'duplicate-row-id' { 'duplicate row id' } + 'missing-required-property' { 'missing required property' } + 'invalid-runner-call-layer' { 'invalid runner call layer' } + 'missing-runner-expectation' { "missing required property 'pester-legacy'" } + default { $MutationId } + } + $caught.Message | Should -Match ([regex]::Escape($expectedDiagnostic)) + } +} diff --git a/tests/Unit/Auth/MsalGuard.Tests.ps1 b/tests/Unit/Auth/MsalGuard.Tests.ps1 index da23e6e..1445a92 100644 --- a/tests/Unit/Auth/MsalGuard.Tests.ps1 +++ b/tests/Unit/Auth/MsalGuard.Tests.ps1 @@ -62,6 +62,20 @@ Describe 'Get-GraphLoadedMsalVersion' { (Test-Path -LiteralPath $path) | Should -BeTrue -Because 'the guard must locate the SDK-delivered MSAL assembly' } } + + It ' exposes WithForceRefresh(Boolean)' -ForEach @( + @{ TypeName = 'AcquireTokenForClientParameterBuilder' } + @{ TypeName = 'AcquireTokenForManagedIdentityParameterBuilder' } + ) { + $assembly = [AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq 'Microsoft.Identity.Client' } | + Select-Object -First 1 + $type = $assembly.GetTypes() | Where-Object Name -eq $TypeName + $method = $type.GetMethod('WithForceRefresh', [type[]] @([bool])) + + $method | Should -Not -BeNullOrEmpty -Because 'GraphKit must propagate a 401 refresh through the exact loaded MSAL builder surface' + $method.ReturnType | Should -Be $type + } } Describe 'Import-time guard' { diff --git a/tests/Unit/Auth/New-GraphMsalApplication.Tests.ps1 b/tests/Unit/Auth/New-GraphMsalApplication.Tests.ps1 new file mode 100644 index 0000000..f0d1a52 --- /dev/null +++ b/tests/Unit/Auth/New-GraphMsalApplication.Tests.ps1 @@ -0,0 +1,404 @@ +BeforeAll { + $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent + $built = Get-ChildItem -Path (Join-Path $repoRoot 'output/module/GraphKit') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -eq $built) { + throw 'GraphKit is not built. Run ./build.ps1 -Tasks build first, then re-run the tests.' + } + Import-Module (Join-Path $built.FullName 'GraphKit.psd1') -Force + + function New-TestCertificate { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + try { + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=GraphKit lifecycle test', + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + return $request.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddMinutes(-1), + [System.DateTimeOffset]::UtcNow.AddMinutes(10) + ) + } + finally { + $rsa.Dispose() + } + } +} + +Describe 'New-GraphMsalApplicationFactory ownership and generation' { + + It 'registers an owned certificate only after a successful application build' { + $certificate = New-TestCertificate + $registrations = [System.Collections.Generic.List[object]]::new() + try { + $result = InModuleScope GraphKit -Parameters @{ + Certificate = $certificate + Registrations = $registrations + } { + param($Certificate, $Registrations) + + $application = [pscustomobject] @{ Name = 'built-application' } + $state = [pscustomobject] @{ + Certificate = $null + Authority = $null + Application = $application + } + $builder = [pscustomobject] @{ State = $state } + $builder | Add-Member ScriptMethod WithCertificate { + param($Value) + $this.State.Certificate = $Value + return $this + } + $builder | Add-Member ScriptMethod WithAuthority { + param($Value) + $this.State.Authority = $Value + return $this + } + $builder | Add-Member ScriptMethod Build { return $this.State.Application } + + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'Certificate' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'generation-1|context:0123456789abcdef0123456789abcdef' ` + -CredentialResolver { + param($Profile) + $null = $Profile + [pscustomobject] @{ + Material = $Certificate + OwnsMaterial = $true + CredentialGeneration = 'generation-1' + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { param($ClientId) $null = $ClientId; $builder }.GetNewClosure() ` + -OwnedResourceRegistrar { + param($Resource, [bool] $OwnedByGraphKit) + $Registrations.Add([pscustomobject] @{ + Resource = $Resource + Owned = $OwnedByGraphKit + }) + # Match the default registrar's convenience return so + # the factory proves that ownership-transfer output is + # never mixed with its application result. + return $Resource + }.GetNewClosure() + + [pscustomobject] @{ + Application = (& $factory) + BuilderState = $state + } + } + + @($result.Application).Count | Should -Be 1 + [object]::ReferenceEquals($result.Application, $result.BuilderState.Application) | Should -BeTrue + [object]::ReferenceEquals($result.BuilderState.Certificate, $certificate) | Should -BeTrue + $registrations.Count | Should -Be 1 + [object]::ReferenceEquals($registrations[0].Resource, $certificate) | Should -BeTrue + $registrations[0].Owned | Should -BeTrue + $certificate.Handle | Should -Not -Be ([IntPtr]::Zero) + } + finally { + $certificate.Dispose() + } + } + + It 'never registers or disposes caller-owned certificate material' { + $certificate = New-TestCertificate + $registrations = [System.Collections.Generic.List[object]]::new() + try { + $null = InModuleScope GraphKit -Parameters @{ + Certificate = $certificate + Registrations = $registrations + } { + param($Certificate, $Registrations) + + $builder = [pscustomobject] @{ Application = [pscustomobject] @{ Name = 'external' } } + $builder | Add-Member ScriptMethod WithCertificate { param($Value) $null = $Value; return $this } + $builder | Add-Member ScriptMethod WithAuthority { param($Value) $null = $Value; return $this } + $builder | Add-Member ScriptMethod Build { return $this.Application } + + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'Certificate' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'generation-1' ` + -CredentialResolver { + param($Profile) + $null = $Profile + [pscustomobject] @{ + Material = $Certificate + OwnsMaterial = $false + CredentialGeneration = 'generation-1' + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { param($ClientId) $null = $ClientId; $builder }.GetNewClosure() ` + -OwnedResourceRegistrar { + param($Resource, [bool] $OwnedByGraphKit) + $Registrations.Add([pscustomobject] @{ Resource = $Resource; Owned = $OwnedByGraphKit }) + }.GetNewClosure() + + & $factory + } + + $registrations.Count | Should -Be 0 + $certificate.Handle | Should -Not -Be ([IntPtr]::Zero) + $certificate.HasPrivateKey | Should -BeTrue + } + finally { + $certificate.Dispose() + } + } + + It 'disposes owned material and rejects a generation changed after context creation' { + $certificate = New-TestCertificate + $buildCalls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + + { + InModuleScope GraphKit -Parameters @{ + Certificate = $certificate + BuildCalls = $buildCalls + } { + param($Certificate, $BuildCalls) + + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'Certificate' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'old-generation' ` + -CredentialResolver { + param($Profile) + $null = $Profile + [pscustomobject] @{ + Material = $Certificate + OwnsMaterial = $true + CredentialGeneration = 'new-generation' + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { + param($ClientId) + $null = $ClientId + $BuildCalls.Enqueue('builder-created') + throw 'builder must not be reached' + }.GetNewClosure() ` + -OwnedResourceRegistrar { throw 'registration must not be reached' } + + & $factory + } + } | Should -Throw -ExpectedMessage '*changed after this context was created*Create a new GraphKit context*' + + $buildCalls.Count | Should -Be 0 + $certificate.Handle | Should -Be ([IntPtr]::Zero) + } + + It 'rejects missing material generation before builder creation and disposes the owned secret' { + $secret = [System.Security.SecureString]::new() + $secret.AppendChar('x') + $buildCalls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + + { + InModuleScope GraphKit -Parameters @{ Secret = $secret; BuildCalls = $buildCalls } { + param($Secret, $BuildCalls) + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'ClientSecret' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'expected-generation' ` + -CredentialResolver { + [pscustomobject] @{ + Material = $Secret + OwnsMaterial = $true + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { + $BuildCalls.Enqueue('builder-created') + throw 'builder must not be reached' + }.GetNewClosure() + + & $factory + } + } | Should -Throw -ExpectedMessage '*did not report the generation*identity cannot be verified*' + + $buildCalls.Count | Should -Be 0 + { + $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } | Should -Throw -ExpectedMessage '*disposed object*SecureString*' + } + + It 'accepts an isolated unversioned ClientSecret generation and disposes its owned copy after build' { + $secret = [System.Security.SecureString]::new() + foreach ($ch in 'client-secret'.ToCharArray()) { $secret.AppendChar($ch) } + + $result = InModuleScope GraphKit -Parameters @{ Secret = $secret } { + param($Secret) + $application = [pscustomobject] @{ Name = 'client-secret-application' } + $state = [pscustomobject] @{ Secret = $null; Authority = $null; Application = $application } + $builder = [pscustomobject] @{ State = $state } + $builder | Add-Member ScriptMethod WithClientSecret { + param($Value) + $this.State.Secret = $Value + return $this + } + $builder | Add-Member ScriptMethod WithAuthority { + param($Value) + $this.State.Authority = $Value + return $this + } + $builder | Add-Member ScriptMethod Build { return $this.State.Application } + + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'ClientSecret' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'base-generation|context:0123456789abcdef0123456789abcdef' ` + -CredentialResolver { + [pscustomobject] @{ + Material = $Secret + OwnsMaterial = $true + CredentialGeneration = 'base-generation' + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { $builder }.GetNewClosure() + + [pscustomobject] @{ + Application = (& $factory) + State = $state + } + } + + $result.Application.Name | Should -Be 'client-secret-application' + $result.State.Secret | Should -Be 'client-secret' + { + $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } | Should -Throw -ExpectedMessage '*disposed object*SecureString*' + } + + It 'disposes owned certificate material when the application builder factory throws' { + $certificate = New-TestCertificate + + { + InModuleScope GraphKit -Parameters @{ Certificate = $certificate } { + param($Certificate) + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'Certificate' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'generation-1' ` + -CredentialResolver { + [pscustomobject] @{ + Material = $Certificate + OwnsMaterial = $true + CredentialGeneration = 'generation-1' + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { throw 'builder-factory-certificate-sentinel' } + + & $factory + } + } | Should -Throw -ExpectedMessage '*builder-factory-certificate-sentinel*' + + $certificate.Handle | Should -Be ([IntPtr]::Zero) + } + + It 'disposes owned ClientSecret material when the application builder factory throws' { + $secret = [System.Security.SecureString]::new() + $secret.AppendChar('x') + + { + InModuleScope GraphKit -Parameters @{ Secret = $secret } { + param($Secret) + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'ClientSecret' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'generation-1' ` + -CredentialResolver { + [pscustomobject] @{ + Material = $Secret + OwnsMaterial = $true + CredentialGeneration = 'generation-1' + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { throw 'builder-factory-secret-sentinel' } + + & $factory + } + } | Should -Throw -ExpectedMessage '*builder-factory-secret-sentinel*' + + { + $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + } | Should -Throw -ExpectedMessage '*disposed object*SecureString*' + } + + It 'disposes owned certificate material when application construction fails' { + $certificate = New-TestCertificate + + { + InModuleScope GraphKit -Parameters @{ Certificate = $certificate } { + param($Certificate) + + $builder = [pscustomobject] @{} + $builder | Add-Member ScriptMethod WithCertificate { param($Value) $null = $Value; return $this } + $builder | Add-Member ScriptMethod WithAuthority { param($Value) $null = $Value; return $this } + $builder | Add-Member ScriptMethod Build { throw 'build-failure-sentinel' } + + $factory = New-GraphMsalApplicationFactory ` + -Profile @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'Certificate' + Credential = @{} + } ` + -Cloud @{ Authority = 'https://login.microsoftonline.com' } ` + -ExpectedCredentialGeneration 'generation-1' ` + -CredentialResolver { + param($Profile) + $null = $Profile + [pscustomobject] @{ + Material = $Certificate + OwnsMaterial = $true + CredentialGeneration = 'generation-1' + } + }.GetNewClosure() ` + -ApplicationBuilderFactory { param($ClientId) $null = $ClientId; $builder }.GetNewClosure() ` + -OwnedResourceRegistrar { throw 'registration must not be reached' } + + & $factory + } + } | Should -Throw -ExpectedMessage '*build-failure-sentinel*' + + $certificate.Handle | Should -Be ([IntPtr]::Zero) + } +} diff --git a/tests/Unit/Auth/SecretManagementBoundary.Tests.ps1 b/tests/Unit/Auth/SecretManagementBoundary.Tests.ps1 index 5fcfe1c..bd66285 100644 --- a/tests/Unit/Auth/SecretManagementBoundary.Tests.ps1 +++ b/tests/Unit/Auth/SecretManagementBoundary.Tests.ps1 @@ -10,18 +10,34 @@ BeforeAll { function New-TestSecretManagementModule { param( [Parameter(Mandatory)] [string] $Root, - [Parameter(Mandatory)] [version] $Version + [Parameter(Mandatory)] [version] $Version, + [switch] $NoVault ) $moduleRoot = Join-Path $Root "Microsoft.PowerShell.SecretManagement/$Version" $null = New-Item -ItemType Directory -Path $moduleRoot -Force $rootModule = Join-Path $moduleRoot 'Microsoft.PowerShell.SecretManagement.psm1' - @' + $vaultBody = if ($NoVault) { + @' +function Get-SecretVault { + [CmdletBinding()] + param([string] $Name) + $null = $Name + return $null +} +'@ + } + else { + @' function Get-SecretVault { [CmdletBinding()] param([string] $Name) [pscustomobject]@{ Name = $Name; ModuleName = 'Synthetic.SecretStore' } } +'@ + } + + $moduleBody = $vaultBody + @' function Get-Secret { [CmdletBinding()] @@ -32,7 +48,8 @@ function Get-Secret { } Export-ModuleMember -Function Get-SecretVault, Get-Secret -'@ | Set-Content -LiteralPath $rootModule -Encoding utf8 +'@ + $moduleBody | Set-Content -LiteralPath $rootModule -Encoding utf8 New-ModuleManifest -Path (Join-Path $moduleRoot 'Microsoft.PowerShell.SecretManagement.psd1') ` -RootModule 'Microsoft.PowerShell.SecretManagement.psm1' -ModuleVersion $Version ` @@ -117,4 +134,36 @@ Describe 'lazy SecretManagement boundary' { $script:foreignVaultCalls | Should -Be 0 $script:foreignSecretCalls | Should -Be 0 } + + It 'resolves managed identity when SecretManagement is not installed' { + $emptyModulePath = Join-Path $TestDrive 'empty-mi' + $null = New-Item -ItemType Directory -Path $emptyModulePath -Force + $env:PSModulePath = $emptyModulePath + + $result = InModuleScope GraphKit { + Get-GraphVaultCredential -Credential @{ ClientId = '7d6e5f44-9999-8888-7777-666655554444' } -AuthMethod ManagedIdentity + } + + $result.AuthMethod | Should -Be 'ManagedIdentity' + $result.ManagedIdentityClientId | Should -Be '7d6e5f44-9999-8888-7777-666655554444' + $script:foreignVaultCalls | Should -Be 0 + $script:foreignSecretCalls | Should -Be 0 + InModuleScope GraphKit { + @(Get-Module Microsoft.PowerShell.SecretManagement).Count | Should -Be 0 + } + } + + It 'distinguishes an unregistered vault from a missing SecretManagement module' { + $modulePath = New-TestSecretManagementModule -Root (Join-Path $TestDrive 'novault') -Version '9.9.9' -NoVault + $env:PSModulePath = $modulePath + + { + InModuleScope GraphKit { + Get-GraphVaultCredential -Credential @{ VaultName = 'missing'; SecretName = 'client-secret' } -AuthMethod ClientSecret + } + } | Should -Throw -ExpectedMessage "*vault 'missing' is not registered*Register-SecretVault*" + + $script:foreignVaultCalls | Should -Be 0 + $script:foreignSecretCalls | Should -Be 0 + } } diff --git a/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 b/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 index cffca47..cec4694 100644 --- a/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 +++ b/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 @@ -10,6 +10,16 @@ BeforeAll { Describe 'Descriptor invariants that fail silently if broken' { + It 'declares every Graph operation compatible with every persisted auth mode' { + # All catalogued operations attach a Graph bearer and the persisted source determines + # acquisition, not endpoint semantics. Keeping this exact list prevents a fixed bearer + # from being silently documented as unsupported while the transport still accepts it. + $expected = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') + foreach ($descriptor in $script:catalog) { + $descriptor.SupportedAuthModes | Should -Be $expected -Because "$($descriptor.Type)/$($descriptor.Operation) is GraphBearer" + } + } + It 'keeps the $select on Organization/GetMdmAuthority' { # mobileDeviceManagementAuthority is a workload-extension property: it is returned only # when named in $select, on the ENTITY url, and it is absent from /organization @@ -324,15 +334,13 @@ Describe 'The TenantPulse-unblocking reads keep their official paths' { $d.ApiVersion | Should -Be 'beta' } - It 'keeps the $select on Group/Get' { - # isAssignableToRole and isManagementRestricted are omitted unless selected. A Get - # without $select returns 200 and looks unprotected, which is a silent false Fail - # for TP.INT.0013. + It 'keeps the exact reporting and protection $select on Group/Get' { + # Description feeds normalized assignment reporting. isAssignableToRole and + # isManagementRestricted are omitted unless selected. A Get without $select returns + # 200 and looks unprotected, which is a silent false Fail for TP.INT.0013. $d = $script:catalog | Where-Object { $_.Type -eq 'Group' -and $_.Operation -eq 'Get' } $d | Should -Not -BeNullOrEmpty - $d.PathTemplate | Should -BeLike '/groups/{id}*$select=*' - $d.PathTemplate | Should -BeLike '*isAssignableToRole*' - $d.PathTemplate | Should -BeLike '*isManagementRestricted*' + $d.PathTemplate | Should -BeExactly '/groups/{id}?$select=id,displayName,description,isAssignableToRole,isManagementRestricted' $d.OperationKind | Should -Be 'Singleton' $d.PagingStrategy | Should -Be 'None' } @@ -353,5 +361,3 @@ Describe 'The TenantPulse-unblocking reads keep their official paths' { $d.ApiVersion | Should -Be 'v1.0' } } - - diff --git a/tests/Unit/Operations/Get-GraphObject.Tests.ps1 b/tests/Unit/Operations/Get-GraphObject.Tests.ps1 index c974e46..470d05d 100644 --- a/tests/Unit/Operations/Get-GraphObject.Tests.ps1 +++ b/tests/Unit/Operations/Get-GraphObject.Tests.ps1 @@ -15,7 +15,9 @@ BeforeAll { GraphBaseUri = [uri] 'https://graph.microsoft.com' ProfileId = 'ivy24' TenantId = [guid] '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000010' IdentityState = 'VerifiedForToken' + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' } } function New-TestEnvelope { @@ -39,7 +41,8 @@ BeforeAll { param( [string] $Type = 'MobileApp', [string] $Operation = 'List', - [string] $PagingStrategy = 'NextLink' + [string] $PagingStrategy = 'NextLink', + [string[]] $SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') ) @{ @@ -54,6 +57,7 @@ BeforeAll { PathTemplate = '/deviceAppManagement/mobileApps' RequiredPagingHeaders = @() DeduplicationKey = 'id' + SupportedAuthModes = $SupportedAuthModes } } @@ -139,6 +143,55 @@ Describe 'Get-GraphObject' { $result.Provenance.ResourceFamily | Should -Be 'Intune.MobileApps' } + It 'retains validated paged transport provenance when the context still says NotAcquired' { + $context = [PSCustomObject]@{} + foreach ($property in $script:Context.PSObject.Properties) { + $context | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value + } + $context.IdentityState = 'NotAcquired' + + $script:pagedTransportProvenance = @{ + ProfileId = 'ivy24' + TenantId = $context.TenantId + ActualTenantId = $context.TenantId + ApiVersion = 'v1.0' + ResourceFamily = 'Intune.MobileApps' + RetrievedUtc = [datetime] '2026-09-01T12:00:00Z' + IdentityState = 'VerifiedForToken' + TokenFingerprint = 'transport-fingerprint' + CredentialGeneration = 'transport-generation' + Cloud = 'Global' + } + + Mock Get-GraphOperation -ModuleName GraphKit { + $descriptor = New-TestDescriptor -Type 'MobileApp' -Operation 'List' + $descriptor.IdentityRequirement = 'Verified' + return $descriptor + } + Mock Resolve-GraphUri -ModuleName GraphKit { + [uri] 'https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps' + } + Mock Invoke-GraphPaging -ModuleName GraphKit { + $envelope = New-TestEnvelope -Data @(@{ id = 'a1'; displayName = 'App One' }) + $envelope.Provenance = $script:pagedTransportProvenance + return $envelope + } + + $result = Get-GraphObject -Context $context -Type MobileApp -PassThruResult + + [object]::ReferenceEquals($result.Provenance, $script:pagedTransportProvenance) | Should -BeTrue + $result.Provenance.IdentityState | Should -BeExactly 'VerifiedForToken' + $result.Provenance.TenantId | Should -Be $context.TenantId + $result.Provenance.ActualTenantId | Should -Be $context.TenantId + $result.Provenance.RetrievedUtc | Should -Be ([datetime] '2026-09-01T12:00:00Z') + $result.Provenance.TokenFingerprint | Should -BeExactly 'transport-fingerprint' + $result.Provenance.CredentialGeneration | Should -BeExactly 'transport-generation' + $result.Provenance.Cloud | Should -BeExactly 'Global' + $result.Provenance.Keys | Should -Not -Contain 'ClientId' + $result.Provenance.Keys | Should -Not -Contain 'ClientScopeFingerprint' + $context.IdentityState | Should -BeExactly 'NotAcquired' + } + It 'emits no rows for an empty result set' { Mock Get-GraphOperation -ModuleName GraphKit { New-TestDescriptor -Type 'MobileApp' -Operation 'List' } Mock Resolve-GraphUri -ModuleName GraphKit { [uri] 'https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps' } @@ -169,9 +222,84 @@ Describe 'Get-GraphObject' { Should-Invoke Invoke-GraphPaging -ModuleName GraphKit -Times 1 -Exactly -ParameterFilter { $MaxPages -eq 7 } Should-NotInvoke Invoke-GraphHandlerStrategy -ModuleName GraphKit } + + It 'forwards the pager inherited remaining deadline into the retry attempt' { + $script:retryDeadlineSeconds = $null + $script:retryBoundParameters = $null + $script:transportParameterNames = $null + Mock Get-GraphOperation -ModuleName GraphKit { New-TestDescriptor -Type 'MobileApp' -Operation 'List' } + Mock Resolve-GraphUri -ModuleName GraphKit { [uri] 'https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps' } + Mock Invoke-GraphRetry -ModuleName GraphKit { + param($DeadlineSeconds) + $script:retryBoundParameters = @{} + $PSBoundParameters + $script:retryDeadlineSeconds = [double] $DeadlineSeconds + New-TestEnvelope -Data @() + } + Mock Invoke-GraphPaging -ModuleName GraphKit { + param($Context, $Descriptor, $FirstPageUri, $RequestFactoryScript, $TransportScript) + $script:transportParameterNames = @( + $TransportScript.Ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath } + ) + & $TransportScript $FirstPageUri 'GET' @{} $null ` + ([System.Threading.CancellationToken]::None) 17.25 + } + + InModuleScope GraphKit -ArgumentList $script:Context { + param($Context) + Get-GraphObject -Context $Context -Type MobileApp -PassThruResult | Out-Null + } + + $script:transportParameterNames | Should -Contain 'DeadlineSeconds' + $script:retryBoundParameters.Keys | Should -Contain 'DeadlineSeconds' + $script:retryDeadlineSeconds | Should -Be 17.25 + Should-Invoke Invoke-GraphRetry -ModuleName GraphKit -Times 1 -Exactly -ParameterFilter { + [double] $DeadlineSeconds -eq 17.25 + } + } } Context 'Descriptor resolution' { + It 'rejects a descriptor that does not support the context auth mode before paging' { + $context = [PSCustomObject]@{} + foreach ($property in $script:Context.PSObject.Properties) { + $context | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value + } + $context.TokenSource = [PSCustomObject]@{ AuthMode = 'BearerToken' } + + Mock Get-GraphOperation -ModuleName GraphKit { + New-TestDescriptor -Type 'ManagedDevice' -Operation 'List' -SupportedAuthModes @('Certificate') + } + Mock Resolve-GraphUri -ModuleName GraphKit { throw 'URI resolution must not run' } + Mock Invoke-GraphPaging -ModuleName GraphKit { throw 'paging must not run' } + + { + Get-GraphObject -Context $context -Type ManagedDevice + } | Should -Throw -ExpectedMessage "*does not support auth mode 'BearerToken'*" + + Should-NotInvoke Resolve-GraphUri -ModuleName GraphKit + Should-NotInvoke Invoke-GraphPaging -ModuleName GraphKit + } + + It 'permits an injected Provider context outside persisted auth-mode policy' { + $context = [PSCustomObject]@{} + foreach ($property in $script:Context.PSObject.Properties) { + $context | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value + } + $context.TokenSource = [PSCustomObject]@{ AuthMode = 'Provider' } + + Mock Get-GraphOperation -ModuleName GraphKit { + New-TestDescriptor -Type 'ManagedDevice' -Operation 'List' -SupportedAuthModes @('Certificate') + } + Mock Resolve-GraphUri -ModuleName GraphKit { + [uri] 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' + } + Mock Invoke-GraphPaging -ModuleName GraphKit { New-TestEnvelope -Data @() } + + Get-GraphObject -Context $context -Type ManagedDevice | Out-Null + + Should-Invoke Invoke-GraphPaging -ModuleName GraphKit -Times 1 -Exactly + } + It 'defaults -Operation to List when only -Type is given' { Mock Get-GraphOperation -ModuleName GraphKit { New-TestDescriptor -Type 'ManagedDevice' -Operation 'List' } Mock Resolve-GraphUri -ModuleName GraphKit { [uri] 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' } diff --git a/tests/Unit/Operations/Import-GraphOperationDescriptor.Tests.ps1 b/tests/Unit/Operations/Import-GraphOperationDescriptor.Tests.ps1 index 9854b56..57743e3 100644 --- a/tests/Unit/Operations/Import-GraphOperationDescriptor.Tests.ps1 +++ b/tests/Unit/Operations/Import-GraphOperationDescriptor.Tests.ps1 @@ -161,6 +161,7 @@ Describe 'Import-GraphOperationDescriptor' { $d['ThrottleClass'] | Should -Be 'Read' $d['ResourceFamily'] | Should -Be 'Intune.ManagedDevices' $d['SupportedClouds'] | Should -Contain 'USGovDoD' + $d['SupportedAuthModes'] | Should -Be @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') } It 'loads DeviceReport.Export as a LongRunningJob' { @@ -233,6 +234,73 @@ Describe 'Import-GraphOperationDescriptor' { } Context 'Cross-field rules' { + It 'rejects a missing SupportedAuthModes declaration' { + $d = New-ValidDescriptor + $d.Remove('SupportedAuthModes') + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "Missing required field 'SupportedAuthModes'" + } + + It 'rejects a scalar SupportedAuthModes declaration' { + $d = New-ValidDescriptor + $d['SupportedAuthModes'] = 'Certificate' + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "Field 'SupportedAuthModes' must be an array" + } + + It 'rejects an empty SupportedAuthModes declaration' { + $d = New-ValidDescriptor + $d['SupportedAuthModes'] = @() + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "SupportedAuthModes.*non-empty" + } + + It 'rejects a non-string SupportedAuthModes element' { + $d = New-ValidDescriptor + $d['SupportedAuthModes'] = @('Certificate', 7) + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "SupportedAuthModes.*only non-empty auth-mode names" + } + + It 'rejects an empty or whitespace-only SupportedAuthModes element' -ForEach @( + @{ Value = '' } + @{ Value = ' ' } + ) { + $d = New-ValidDescriptor + $d['SupportedAuthModes'] = @('Certificate', $Value) + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "SupportedAuthModes.*only non-empty auth-mode names" + } + + It 'rejects an unknown SupportedAuthModes value' { + $d = New-ValidDescriptor + $d['SupportedAuthModes'] = @('Certificate', 'Bogus') + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "SupportedAuthModes.*Bogus" + } + + It 'rejects duplicate SupportedAuthModes values' { + $d = New-ValidDescriptor + $d['SupportedAuthModes'] = @('Certificate', 'Certificate') + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "SupportedAuthModes.*duplicate" + } + + It 'rejects case-variant duplicate SupportedAuthModes values' { + $d = New-ValidDescriptor + $d['SupportedAuthModes'] = @('Certificate', 'certificate') + $path = New-TestDescriptorFile $d + + Get-DescriptorError $path | Should -Match "SupportedAuthModes.*duplicate" + } + It 'rejects CredentialPolicy None with an empty AllowedHosts' { $d = New-ValidDescriptor $d['CredentialPolicy'] = 'None' diff --git a/tests/Unit/Pipeline/Invoke-GraphBatch.Tests.ps1 b/tests/Unit/Pipeline/Invoke-GraphBatch.Tests.ps1 index af52966..6dc715a 100644 --- a/tests/Unit/Pipeline/Invoke-GraphBatch.Tests.ps1 +++ b/tests/Unit/Pipeline/Invoke-GraphBatch.Tests.ps1 @@ -16,6 +16,7 @@ BeforeAll { ProfileId = 'ivy24' TenantId = [guid] '00000000-0000-0000-0000-000000000001' IdentityState = 'VerifiedForToken' + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' } } $script:NoopDelay = { param([int] $Seconds) } @@ -115,7 +116,7 @@ Describe 'Invoke-GraphBatch' { } It 'rejects a write whose descriptor is not Safe' { - Mock Get-GraphOperation -ModuleName GraphKit { return @{ ReplayPolicy = 'NeverReplay'; Method = 'POST'; PathTemplate = '/write' } } + Mock Get-GraphOperation -ModuleName GraphKit { return @{ ReplayPolicy = 'NeverReplay'; Method = 'POST'; PathTemplate = '/write'; SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') } } { Invoke-GraphBatch -Context $script:Context -Requests @( @@ -126,7 +127,7 @@ Describe 'Invoke-GraphBatch' { It 'allows a write subrequest proven Safe by its descriptor' { Reset-BatchState - Mock Get-GraphOperation -ModuleName GraphKit { return @{ ReplayPolicy = 'Safe'; Method = 'POST'; PathTemplate = '/write' } } + Mock Get-GraphOperation -ModuleName GraphKit { return @{ ReplayPolicy = 'Safe'; Method = 'POST'; PathTemplate = '/write'; SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') } } $script:BatchQueue.Enqueue((New-BatchEnvelope @((New-BatchResponse '1' 204)))) Mock Invoke-GraphRetry -ModuleName GraphKit { return $script:BatchQueue.Dequeue() } @@ -140,10 +141,96 @@ Describe 'Invoke-GraphBatch' { } } + Context 'Descriptor auth-mode enforcement' { + It 'rejects a descriptor-backed write excluded by the persisted auth-mode policy before sending the batch' { + $context = [PSCustomObject]@{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com' + ProfileId = 'batch-auth-mode-rejection' + TenantId = [guid] '00000000-0000-0000-0000-000000000001' + TokenSource = [PSCustomObject]@{ AuthMode = 'BearerToken' } + } + Mock Get-GraphOperation -ModuleName GraphKit { + return @{ Type = 'Thing'; Operation = 'Write'; ReplayPolicy = 'Safe'; Method = 'POST'; PathTemplate = '/write'; SupportedAuthModes = @('Certificate') } + } + Mock Invoke-GraphRetry -ModuleName GraphKit { + return New-BatchEnvelope @((New-BatchResponse '1' 204)) + } + + { + Invoke-GraphBatch -Context $context -Requests @( + @{ Id = '1'; Method = 'POST'; Uri = 'https://graph.microsoft.com/v1.0/write'; Type = 'Thing'; Operation = 'Write' } + ) -DelayScript $script:NoopDelay + } | Should -Throw -ExpectedMessage "*does not support auth mode 'BearerToken'*" + + Should-NotInvoke Invoke-GraphRetry -ModuleName GraphKit + } + + It 'rejects an excluded auth mode before reading a hostile write Uri' { + $context = [PSCustomObject]@{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com' + ProfileId = 'batch-auth-mode-before-uri' + TenantId = [guid] '00000000-0000-0000-0000-000000000001' + TokenSource = [PSCustomObject]@{ AuthMode = 'BearerToken' } + } + Mock Get-GraphOperation -ModuleName GraphKit { + return @{ Type = 'Thing'; Operation = 'Write'; ReplayPolicy = 'Safe'; Method = 'POST'; PathTemplate = '/write'; SupportedAuthModes = @('Certificate') } + } + Mock Invoke-GraphRetry -ModuleName GraphKit { + return New-BatchEnvelope @((New-BatchResponse '1' 204)) + } + + $script:HostileBatchUriReadCount = 0 + $request = [PSCustomObject]@{ + Id = '1' + Method = 'POST' + Type = 'Thing' + Operation = 'Write' + } + $request | Add-Member -MemberType ScriptProperty -Name Uri -Value { + $script:HostileBatchUriReadCount++ + throw 'The hostile Uri property must not be read before auth-mode rejection.' + } + + { + Invoke-GraphBatch -Context $context -Requests @($request) -DelayScript $script:NoopDelay + } | Should -Throw -ExpectedMessage "*does not support auth mode 'BearerToken'*" + + $script:HostileBatchUriReadCount | Should -BeExactly 0 + Should-Invoke Get-GraphOperation -ModuleName GraphKit -Exactly 1 + Should-NotInvoke Invoke-GraphRetry -ModuleName GraphKit + } + + It 'allows a descriptor-backed write from an injected Provider despite a persisted profile-mode exclusion' { + $context = [PSCustomObject]@{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com' + ProfileId = 'batch-auth-mode-provider' + TenantId = [guid] '00000000-0000-0000-0000-000000000001' + TokenSource = [PSCustomObject]@{ AuthMode = 'Provider' } + } + Reset-BatchState + $script:BatchQueue.Enqueue((New-BatchEnvelope @((New-BatchResponse '1' 204)))) + + Mock Get-GraphOperation -ModuleName GraphKit { + return @{ Type = 'Thing'; Operation = 'Write'; ReplayPolicy = 'Safe'; Method = 'POST'; PathTemplate = '/write'; SupportedAuthModes = @('Certificate') } + } + Mock Invoke-GraphRetry -ModuleName GraphKit { return $script:BatchQueue.Dequeue() } + + $result = Invoke-GraphBatch -Context $context -Requests @( + @{ Id = '1'; Method = 'POST'; Uri = 'https://graph.microsoft.com/v1.0/write'; Type = 'Thing'; Operation = 'Write' } + ) -DelayScript $script:NoopDelay + + @($result) | Should -HaveCount 1 + $result[0].Outcome | Should -BeExactly 'Succeeded' + } + } + Context 'Write replay safety' { It 'never replays a successful write subrequest when retrying failed reads' { Reset-BatchState - Mock Get-GraphOperation -ModuleName GraphKit { return @{ ReplayPolicy = 'Safe'; Method = 'POST'; PathTemplate = '/write' } } + Mock Get-GraphOperation -ModuleName GraphKit { return @{ ReplayPolicy = 'Safe'; Method = 'POST'; PathTemplate = '/write'; SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') } } $script:BatchQueue.Enqueue((New-BatchEnvelope @( (New-BatchResponse '1' 204), @@ -231,7 +318,7 @@ Describe 'Batch refuses to carry a mutating subrequest' { $script:ctx = [PSCustomObject]@{ ProfileId = 'batch-guard-probe' GraphBaseUri = [uri] 'https://graph.microsoft.com' - TokenSource = $null + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' } TenantId = [guid]::Empty } } @@ -286,7 +373,7 @@ Describe 'The batch guard cannot be forged' { Import-Module (Join-Path $built.FullName 'GraphKit.psd1') -Force $script:ctx = [PSCustomObject]@{ ProfileId = 'forge-probe'; GraphBaseUri = [uri] 'https://graph.microsoft.com' - TokenSource = $null; TenantId = [guid]::Empty + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' }; TenantId = [guid]::Empty } } @@ -322,8 +409,12 @@ Describe 'The batch guard cannot be forged' { # carries no token source, so the call must fail at credential policy. Asserting # -Not -Throw here would be testing the fixture, not the guard. $err = $null + $rawContext = [PSCustomObject]@{ + ProfileId = 'raw-forge-probe'; GraphBaseUri = [uri] 'https://graph.microsoft.com' + TokenSource = $null; TenantId = [guid]::Empty + } try { - Invoke-GraphBatch -Context $script:ctx -Requests @( + Invoke-GraphBatch -Context $rawContext -Requests @( @{ Id = '1'; Method = 'GET' Uri = 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/any-id-here' }) } catch { $err = $_.Exception.Message } @@ -333,4 +424,3 @@ Describe 'The batch guard cannot be forged' { $err | Should -BeLike '*token source*' -Because 'it should reach credential policy, which is past the guard' } } - diff --git a/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 b/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 index 7fb64ab..b18b687 100644 --- a/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 +++ b/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 @@ -16,6 +16,7 @@ BeforeAll { ProfileId = 'ivy24' TenantId = [guid] '00000000-0000-0000-0000-000000000001' IdentityState = 'VerifiedForToken' + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' } } function New-FakeEnvelope { @@ -44,6 +45,7 @@ Describe 'Invoke-GraphOperation' { Type = 'Thing'; Operation = 'Read'; Stability = 'BetaPreferred' BetaReason = 'v1.0 missing a field'; ApiVersion = 'beta' ResourceFamily = 'F'; CredentialPolicy = 'GraphBearer'; AllowedHosts = @() + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') } } Mock Resolve-GraphUri -ModuleName GraphKit { return [uri] 'https://graph.microsoft.com/beta/thing' } @@ -62,6 +64,7 @@ Describe 'Invoke-GraphOperation' { Type = 'Thing'; Operation = 'Read'; Stability = 'Stable' ApiVersion = 'v1.0'; ResourceFamily = 'F' CredentialPolicy = 'GraphBearer'; AllowedHosts = @() + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') } } Mock Resolve-GraphUri -ModuleName GraphKit { return [uri] 'https://graph.microsoft.com/v1.0/thing' } @@ -93,6 +96,120 @@ Describe 'Invoke-GraphOperation' { } } + Context 'Descriptor auth-mode policy' { + It 'rejects an unsupported context auth mode before URI resolution or handler execution' { + $context = [PSCustomObject]@{} + foreach ($property in $script:Context.PSObject.Properties) { + $context | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value + } + $context.TokenSource = [PSCustomObject]@{ AuthMode = 'BearerToken' } + + Mock Get-GraphOperation -ModuleName GraphKit { + return @{ + Type = 'Thing'; Operation = 'Read'; Stability = 'Stable' + ApiVersion = 'v1.0'; ResourceFamily = 'F' + CredentialPolicy = 'GraphBearer'; AllowedHosts = @() + SupportedAuthModes = @('Certificate') + } + } + Mock Resolve-GraphUri -ModuleName GraphKit { throw 'URI resolution must not run' } + Mock Invoke-GraphHandlerStrategy -ModuleName GraphKit { throw 'handler must not run' } + + { + Invoke-GraphOperation -Context $context -Type Thing -Operation Read + } | Should -Throw -ExpectedMessage "*does not support auth mode 'BearerToken'*" + + Should-NotInvoke Resolve-GraphUri -ModuleName GraphKit + Should-NotInvoke Invoke-GraphHandlerStrategy -ModuleName GraphKit + } + + It 'does not apply descriptor auth-mode policy to a raw request' { + $rawOnlyContext = [PSCustomObject]@{} + foreach ($property in $script:Context.PSObject.Properties) { + $rawOnlyContext | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value + } + $rawOnlyContext.TokenSource = [PSCustomObject]@{ AuthMode = 'RawOnlyTestMode' } + + Mock Get-GraphOperation -ModuleName GraphKit { + throw 'raw mode must not resolve a descriptor' + } + Mock Assert-GraphOperationAuthMode -ModuleName GraphKit { + throw 'raw mode must not apply descriptor auth-mode policy' + } + Mock Invoke-GraphRetry -ModuleName GraphKit { return (New-FakeEnvelope) } + + $result = Invoke-GraphOperation -Context $rawOnlyContext ` + -Uri 'https://graph.microsoft.com/v1.0/me' -Method GET + + $result.Outcome | Should -Be 'Succeeded' + Should-NotInvoke Get-GraphOperation -ModuleName GraphKit + Should-NotInvoke Assert-GraphOperationAuthMode -ModuleName GraphKit + Should-Invoke Invoke-GraphRetry -ModuleName GraphKit -Times 1 -Exactly + } + } + + Context 'Collection paging deadline composition' { + It 'forwards the pager inherited remaining deadline through Collection.Default into retry' { + $script:retryDeadlineSeconds = $null + $script:retryBoundParameters = $null + $script:transportParameterNames = $null + + Mock Get-GraphOperation -ModuleName GraphKit { + return @{ + Type = 'Thing' + Operation = 'List' + OperationKind = 'Collection' + HandlerStrategyId = 'Collection.Default' + Method = 'GET' + PathTemplate = '/things' + PagingStrategy = 'NextLink' + DeduplicationKey = 'id' + RequiredPagingHeaders = @() + AdvancedQuery = @{ Supported = $false } + Concurrency = @{ Mode = 'None'; Header = $null; Required = $false; AllowWildcard = $false } + ReplayPolicy = 'Safe' + ResponseKind = 'Json' + Stability = 'Stable' + ApiVersion = 'v1.0' + ResourceFamily = 'F' + CredentialPolicy = 'GraphBearer' + AllowedHosts = @() + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') + } + } + Mock Resolve-GraphUri -ModuleName GraphKit { + return [uri] 'https://graph.microsoft.com/v1.0/things' + } + Mock Invoke-GraphRetry -ModuleName GraphKit { + param($DeadlineSeconds) + $script:retryBoundParameters = @{} + $PSBoundParameters + $script:retryDeadlineSeconds = [double] $DeadlineSeconds + return (New-FakeEnvelope) + } + Mock Invoke-GraphPaging -ModuleName GraphKit { + param($Context, $Descriptor, $FirstPageUri, $RequestFactoryScript, $TransportScript) + $script:transportParameterNames = @( + $TransportScript.Ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath } + ) + & $TransportScript $FirstPageUri 'GET' @{} $null ` + ([System.Threading.CancellationToken]::None) 17.25 + } + + $result = InModuleScope GraphKit -ArgumentList $script:Context { + param($Context) + Invoke-GraphOperation -Context $Context -Type Thing -Operation List + } + + $result.Outcome | Should -BeExactly 'Succeeded' + $script:transportParameterNames | Should -Contain 'DeadlineSeconds' + $script:retryBoundParameters.Keys | Should -Contain 'DeadlineSeconds' + $script:retryDeadlineSeconds | Should -Be 17.25 + Should-Invoke Invoke-GraphRetry -ModuleName GraphKit -Times 1 -Exactly -ParameterFilter { + [double] $DeadlineSeconds -eq 17.25 + } + } + } + Context 'Provenance stamping' { It 'stamps provenance onto the returned envelope' { Mock Get-GraphOperation -ModuleName GraphKit { @@ -100,6 +217,7 @@ Describe 'Invoke-GraphOperation' { Type = 'Thing'; Operation = 'Read'; Stability = 'Stable' ApiVersion = 'v1.0'; ResourceFamily = 'F' CredentialPolicy = 'GraphBearer'; AllowedHosts = @() + SupportedAuthModes = @('Certificate', 'ClientSecret', 'BearerToken', 'ManagedIdentity') } } Mock Resolve-GraphUri -ModuleName GraphKit { return [uri] 'https://graph.microsoft.com/v1.0/thing' } diff --git a/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 b/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 index b5b029d..0d830be 100644 --- a/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 +++ b/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 @@ -27,14 +27,23 @@ BeforeAll { $script:PageQueue = [System.Collections.Generic.Queue[object]]::new() $script:RecordedHeaders = [System.Collections.Generic.List[object]]::new() $script:RecordedUris = [System.Collections.Generic.List[string]]::new() + $script:RecordedDeadlineSeconds = [System.Collections.Generic.List[double]]::new() # Closures bind to this test file's session state, so the $script: references below resolve # here even when the module invokes the scriptblocks. $script:FakeTransport = { - param([uri] $Uri, [string] $Method, [hashtable] $Headers, $Body) + param( + [uri] $Uri, + [string] $Method, + [hashtable] $Headers, + $Body, + [System.Threading.CancellationToken] $CancellationToken, + [double] $DeadlineSeconds + ) $script:RecordedHeaders.Add($Headers) $script:RecordedUris.Add($Uri.AbsoluteUri) + $script:RecordedDeadlineSeconds.Add($DeadlineSeconds) if ($script:PageQueue.Count -eq 0) { throw 'FakeTransport: no scripted page remains' @@ -55,7 +64,11 @@ BeforeAll { } function New-GraphPage { - param([object[]] $Rows, [AllowNull()] [string] $NextLink) + param( + [object[]] $Rows, + [AllowNull()] [string] $NextLink, + [hashtable] $Provenance = @{} + ) [PSCustomObject]@{ PSTypeName = 'GraphKit.OperationResult' @@ -63,14 +76,44 @@ BeforeAll { Outcome = 'Succeeded' Certainty = 'Known' Telemetry = @() - Provenance = @{} + Provenance = $Provenance + } + } + + function New-VerifiedPageProvenance { + param( + [guid] $TenantId = [guid] '00000000-0000-0000-0000-000000000001', + [guid] $ActualTenantId = [guid] '00000000-0000-0000-0000-000000000001', + [string] $IdentityState = 'VerifiedForToken', + [AllowNull()] [object] $TokenFingerprint = 'paging-token-fingerprint', + [AllowNull()] [object] $CredentialGeneration = 'paging-credential-generation', + [string] $Cloud = 'Global' + ) + + $provenance = @{ + ProfileId = 'paging-verified' + TenantId = $TenantId + ActualTenantId = $ActualTenantId + IdentityState = $IdentityState + TokenFingerprint = $TokenFingerprint + CredentialGeneration = $CredentialGeneration + Cloud = $Cloud + ApiVersion = 'v1.0' + ResourceFamily = 'Intune.ManagedDevices' + } + foreach ($name in @('TokenFingerprint', 'CredentialGeneration')) { + if ($null -eq $provenance[$name]) { + $null = $provenance.Remove($name) + } } + return $provenance } function Reset-PagingState { $script:PageQueue.Clear() $script:RecordedHeaders.Clear() $script:RecordedUris.Clear() + $script:RecordedDeadlineSeconds.Clear() } } @@ -95,6 +138,334 @@ Describe 'Invoke-GraphPaging' { $result.Outcome | Should -Be 'Succeeded' } + It 'requires every successful page of a Verified operation and carries the final verified provenance' { + Reset-PagingState + $tenantId = [guid] '00000000-0000-0000-0000-000000000001' + $context = [pscustomobject] @{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com/v1.0' + TenantId = $tenantId + ClientId = '00000000-0000-0000-0000-000000000010' + } + $descriptor = $script:Descriptor.Clone() + $descriptor.IdentityRequirement = 'Verified' + $firstProvenance = New-VerifiedPageProvenance + $finalProvenance = New-VerifiedPageProvenance + $finalProvenance.RetrievedUtc = [datetime] '2026-09-01T12:00:00Z' + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'a' }) 'https://graph.microsoft.com/v1.0/page2' $firstProvenance)) + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'b' }) $null $finalProvenance)) + + $result = InModuleScope GraphKit -ArgumentList $context, $descriptor, $script:Factory, $script:FakeTransport { + param($Context, $Descriptor, $Factory, $Transport) + Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport + } + + @($result.Data) | Should -HaveCount 2 + $result.Outcome | Should -BeExactly 'Succeeded' + $result.Provenance.IdentityState | Should -BeExactly 'VerifiedForToken' + $result.Provenance.TenantId | Should -Be $tenantId + $result.Provenance.ActualTenantId | Should -Be $tenantId + $result.Provenance.RetrievedUtc | Should -Be ([datetime] '2026-09-01T12:00:00Z') + $result.Provenance.TokenFingerprint | Should -BeExactly 'paging-token-fingerprint' + $result.Provenance.CredentialGeneration | Should -BeExactly 'paging-credential-generation' + $result.Provenance.Cloud | Should -BeExactly 'Global' + } + + It 'fails closed before collecting rows when any successful Verified page has provenance' -ForEach @( + @{ Case = 'missing' } + @{ Case = 'unverified' } + @{ Case = 'wrong target' } + @{ Case = 'wrong actual' } + ) { + Reset-PagingState + $context = [pscustomobject] @{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com/v1.0' + TenantId = [guid] '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000010' + } + $descriptor = $script:Descriptor.Clone() + $descriptor.IdentityRequirement = 'Verified' + $pageProvenance = switch ($Case) { + 'missing' { $null } + 'unverified' { New-VerifiedPageProvenance -IdentityState NotAcquired } + 'wrong target' { New-VerifiedPageProvenance -TenantId ([guid] '00000000-0000-0000-0000-000000000002') } + 'wrong actual' { New-VerifiedPageProvenance -ActualTenantId ([guid] '00000000-0000-0000-0000-000000000002') } + } + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'must-not-escape' }) 'https://graph.microsoft.com/v1.0/page2' $pageProvenance)) + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'later' }) $null (New-VerifiedPageProvenance))) + + { + InModuleScope GraphKit -ArgumentList $context, $descriptor, $script:Factory, $script:FakeTransport { + param($Context, $Descriptor, $Factory, $Transport) + Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport + } + } | Should -Throw -ExpectedMessage '*VerifiedForToken tenant provenance*' + + $script:RecordedUris | Should -HaveCount 1 + } + + It 'rejects first-page Verified provenance with before retaining its rows' -ForEach @( + @{ Case = 'missing fingerprint'; Override = @{ TokenFingerprint = $null } } + @{ Case = 'blank fingerprint'; Override = @{ TokenFingerprint = ' ' } } + @{ Case = 'missing generation'; Override = @{ CredentialGeneration = $null } } + @{ Case = 'blank generation'; Override = @{ CredentialGeneration = "`t" } } + ) { + Reset-PagingState + $context = [pscustomobject] @{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com/v1.0' + TenantId = [guid] '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000010' + } + $descriptor = $script:Descriptor.Clone() + $descriptor.IdentityRequirement = 'Verified' + $pageProvenance = New-VerifiedPageProvenance @Override + $identityField = if ($Case -like '*fingerprint') { 'TokenFingerprint' } else { 'CredentialGeneration' } + $pageProvenance.ContainsKey($identityField) | Should -Be ($Case -like 'blank *') ` + -Because 'missing and blank exact-token provenance are separate test inputs' + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'must-not-escape' }) $null $pageProvenance)) + + $capture = InModuleScope GraphKit -ArgumentList $context, $descriptor, $script:Factory, $script:FakeTransport { + param($Context, $Descriptor, $Factory, $Transport) + $output = @() + $failure = $null + try { + $output = @(Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport) + } + catch { + $failure = $_.Exception + } + [pscustomobject] @{ Output = $output; Failure = $failure } + } + + $capture.Failure | Should -Not -BeNullOrEmpty + $capture.Failure.Message | Should -BeLike '*exact-token provenance*' + @($capture.Output) | Should -HaveCount 0 + $script:RecordedUris | Should -HaveCount 1 + } + + It 'rejects missing or cross-page exact-token provenance before returning any aggregate' -ForEach @( + @{ Case = 'missing fingerprint'; Second = @{ TokenFingerprint = $null } } + @{ Case = 'blank fingerprint'; Second = @{ TokenFingerprint = ' ' } } + @{ Case = 'missing generation'; Second = @{ CredentialGeneration = $null } } + @{ Case = 'blank generation'; Second = @{ CredentialGeneration = "`t" } } + @{ Case = 'different fingerprint'; Second = @{ TokenFingerprint = 'paging-token-fingerprint-2' } } + @{ Case = 'different generation'; Second = @{ CredentialGeneration = 'paging-credential-generation-2' } } + @{ Case = 'different cloud'; Second = @{ Cloud = 'USGov' } } + ) { + Reset-PagingState + $context = [pscustomobject] @{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com/v1.0' + TenantId = [guid] '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000010' + } + $descriptor = $script:Descriptor.Clone() + $descriptor.IdentityRequirement = 'Verified' + $secondProvenance = New-VerifiedPageProvenance @Second + if ($Case -like 'missing *') { + $identityField = if ($Case -like '*fingerprint') { 'TokenFingerprint' } else { 'CredentialGeneration' } + $secondProvenance.ContainsKey($identityField) | Should -BeFalse ` + -Because 'missing and blank cross-page provenance are separate test inputs' + } + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'must-not-escape' }) 'https://graph.microsoft.com/v1.0/page2' (New-VerifiedPageProvenance))) + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'also-must-not-escape' }) $null $secondProvenance)) + + $capture = InModuleScope GraphKit -ArgumentList $context, $descriptor, $script:Factory, $script:FakeTransport { + param($Context, $Descriptor, $Factory, $Transport) + $output = @() + $failure = $null + try { + $output = @(Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport) + } + catch { + $failure = $_.Exception + } + [pscustomobject] @{ Output = $output; Failure = $failure } + } + + $capture.Failure | Should -Not -BeNullOrEmpty + $capture.Failure.Message | Should -BeLike '*exact-token provenance*' + @($capture.Output) | Should -HaveCount 0 + $script:RecordedUris | Should -HaveCount 2 + } + + It 'uses one inherited deadline across pages and sends nothing after the budget is exhausted' { + Reset-PagingState + $clock = [pscustomobject] @{ Value = [datetime] '2026-09-01T12:00:00Z' } + $recorded = [System.Collections.Generic.List[double]]::new() + $calls = [pscustomobject] @{ Count = 0 } + $transport = { + param($Uri, $Method, $Headers, $Body, $CancellationToken, [double] $DeadlineSeconds) + $calls.Count++ + $recorded.Add($DeadlineSeconds) + $clock.Value = $clock.Value.AddSeconds(5) + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @{ value = @(@{ id = 'must-not-escape' }); '@odata.nextLink' = 'https://graph.microsoft.com/v1.0/page2' } + Outcome = 'Succeeded' + Certainty = 'Known' + Telemetry = @() + Provenance = @{} + } + }.GetNewClosure() + $utcNow = { $clock.Value }.GetNewClosure() + + $result = InModuleScope GraphKit -ArgumentList $script:Context, $script:Descriptor, $script:Factory, $transport, $utcNow { + param($Context, $Descriptor, $Factory, $Transport, $UtcNow) + Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport ` + -DeadlineSeconds 5 -UtcNow $UtcNow + } + + $result.Outcome | Should -BeExactly 'DeadlineExpired' + $result.Certainty | Should -BeExactly 'Indeterminate' + @($result.Data) | Should -HaveCount 0 + $calls.Count | Should -Be 1 + $recorded | Should -HaveCount 1 + $recorded[0] | Should -BeGreaterThan 0 + $recorded[0] | Should -BeLessOrEqual 5 + } + + It 'sends nothing when request construction consumes the remaining collection deadline' { + Reset-PagingState + $clock = [pscustomobject] @{ Value = [datetime] '2026-09-01T12:00:00Z' } + $calls = [pscustomobject] @{ Count = 0 } + $factory = { + param([uri] $Uri, [hashtable] $Descriptor) + $clock.Value = $clock.Value.AddSeconds(5) + return @{ Uri = $Uri; Method = 'GET'; Headers = @{}; Body = $null } + }.GetNewClosure() + $transport = { + param($Uri, $Method, $Headers, $Body, $CancellationToken, [double] $DeadlineSeconds) + $calls.Count++ + throw 'transport must not start after request construction exhausts the deadline' + }.GetNewClosure() + $utcNow = { $clock.Value }.GetNewClosure() + + $result = InModuleScope GraphKit -ArgumentList $script:Context, $script:Descriptor, $factory, $transport, $utcNow { + param($Context, $Descriptor, $Factory, $Transport, $UtcNow) + Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport ` + -DeadlineSeconds 5 -UtcNow $UtcNow + } + + $result.Outcome | Should -BeExactly 'DeadlineExpired' + $result.Certainty | Should -BeExactly 'Indeterminate' + @($result.Data) | Should -HaveCount 0 + $calls.Count | Should -Be 0 + } + + It 'does not start page two when the inherited remainder is below retry minimum resolution' { + Reset-PagingState + $clock = [pscustomobject] @{ Value = [datetime] '2026-09-01T12:00:00Z' } + $calls = [pscustomobject] @{ Count = 0 } + $transport = { + param($Uri, $Method, $Headers, $Body, $CancellationToken, [double] $DeadlineSeconds) + $calls.Count++ + if ($calls.Count -eq 1) { + # Leave exactly 0.0005 seconds on the virtual collection clock. + $clock.Value = $clock.Value.AddTicks(49995000) + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @{ value = @(@{ id = 'must-not-escape' }); '@odata.nextLink' = 'https://graph.microsoft.com/v1.0/page2' } + Outcome = 'Succeeded' + Certainty = 'Known' + Telemetry = @() + Provenance = @{} + } + } + throw 'page two transport must not start below retry deadline resolution' + }.GetNewClosure() + $utcNow = { $clock.Value }.GetNewClosure() + + $result = InModuleScope GraphKit -ArgumentList $script:Context, $script:Descriptor, $script:Factory, $transport, $utcNow { + param($Context, $Descriptor, $Factory, $Transport, $UtcNow) + Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport ` + -DeadlineSeconds 5 -UtcNow $UtcNow + } + + $result.Outcome | Should -BeExactly 'DeadlineExpired' + $result.Certainty | Should -BeExactly 'Indeterminate' + @($result.Data) | Should -HaveCount 0 + $calls.Count | Should -Be 1 + } + + It 'discards a terminal successful page that completes after the collection deadline' { + Reset-PagingState + $clock = [pscustomobject] @{ Value = [datetime] '2026-09-01T12:00:00Z' } + $calls = [pscustomobject] @{ Count = 0 } + $transport = { + param($Uri, $Method, $Headers, $Body, $CancellationToken, [double] $DeadlineSeconds) + $calls.Count++ + $clock.Value = $clock.Value.AddSeconds(6) + return [pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @{ value = @(@{ id = 'late-row-must-not-escape' }); '@odata.nextLink' = $null } + Outcome = 'Succeeded' + Certainty = 'Known' + Telemetry = @() + Provenance = @{} + } + }.GetNewClosure() + $utcNow = { $clock.Value }.GetNewClosure() + + $result = InModuleScope GraphKit -ArgumentList $script:Context, $script:Descriptor, $script:Factory, $transport, $utcNow { + param($Context, $Descriptor, $Factory, $Transport, $UtcNow) + Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport ` + -DeadlineSeconds 5 -UtcNow $UtcNow + } + + $result.Outcome | Should -BeExactly 'DeadlineExpired' + $result.Certainty | Should -BeExactly 'Indeterminate' + @($result.Data) | Should -HaveCount 0 + $calls.Count | Should -Be 1 + } + + It 'discards earlier rows when a later page loses certainty' -ForEach @( + @{ Outcome = 'Failed'; Certainty = 'Indeterminate' } + @{ Outcome = 'Cancelled'; Certainty = 'Indeterminate' } + @{ Outcome = 'DeadlineExpired'; Certainty = 'Indeterminate' } + ) { + Reset-PagingState + $script:PageQueue.Enqueue((New-GraphPage @(@{ id = 'must-not-escape' }) 'https://graph.microsoft.com/v1.0/page2')) + $script:PageQueue.Enqueue([pscustomobject] @{ + PSTypeName = 'GraphKit.OperationResult' + Data = @(@{ id = 'failed-page-row' }) + Outcome = $Outcome + Certainty = $Certainty + Telemetry = @() + Provenance = @{ IdentityState = 'NotAcquired' } + }) + + $result = InModuleScope GraphKit -ArgumentList $script:Context, $script:Descriptor, $script:Factory, $script:FakeTransport { + param($Context, $Descriptor, $Factory, $Transport) + Invoke-GraphPaging -Context $Context -Descriptor $Descriptor ` + -FirstPageUri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices' ` + -RequestFactoryScript $Factory -TransportScript $Transport + } + + $result.Outcome | Should -BeExactly $Outcome + $result.Certainty | Should -BeExactly $Certainty + @($result.Data) | Should -HaveCount 0 + $script:RecordedUris | Should -HaveCount 2 + } + It 'continues on an empty page that still carries a nextLink' { Reset-PagingState $script:PageQueue.Enqueue((New-GraphPage @() 'https://graph.microsoft.com/v1.0/page2')) @@ -187,6 +558,9 @@ Describe 'Invoke-GraphPaging' { $result = $captured.Result @($result.Data) | Should -HaveCount 1 ($captured.Warnings -join ';') | Should -Match 'page cap' + $result.Outcome | Should -BeExactly 'Succeeded' + $result.Certainty | Should -BeExactly 'Indeterminate' + $result.Truncated | Should -BeTrue } It 'blocks a hostile nextLink authority before the next hop' { diff --git a/tests/Unit/Pipeline/WriteGate.Tests.ps1 b/tests/Unit/Pipeline/WriteGate.Tests.ps1 index 83ad17d..a713310 100644 --- a/tests/Unit/Pipeline/WriteGate.Tests.ps1 +++ b/tests/Unit/Pipeline/WriteGate.Tests.ps1 @@ -68,7 +68,7 @@ Describe 'The mutating-operation dry-run gate' { [PSCustomObject]@{ ProfileId = 'gate-probe' GraphBaseUri = [uri] 'https://graph.microsoft.com' - TokenSource = $null + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' } TenantId = [guid]::Empty } } @@ -161,7 +161,7 @@ Describe 'Bodyless actions' { $script:ctx = InModuleScope GraphKit { [PSCustomObject]@{ ProfileId = 'bodyless-probe'; GraphBaseUri = [uri] 'https://graph.microsoft.com' - TokenSource = $null; TenantId = [guid]::Empty + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' }; TenantId = [guid]::Empty } } } @@ -236,7 +236,7 @@ Describe 'High-impact confirmation' { $script:hiCtx = InModuleScope GraphKit { [PSCustomObject]@{ ProfileId = 'impact-probe'; GraphBaseUri = [uri] 'https://graph.microsoft.com' - TokenSource = $null; TenantId = [guid]::Empty + TokenSource = [PSCustomObject]@{ AuthMode = 'Certificate' }; TenantId = [guid]::Empty } } $script:catalog = @(Get-GraphOperation -List) @@ -340,4 +340,3 @@ Describe 'High-impact confirmation' { } } } - diff --git a/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 index 24715a1..fc3e128 100644 --- a/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 +++ b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 @@ -7,6 +7,50 @@ BeforeAll { } Import-Module (Join-Path $built.FullName 'GraphKit.psd1') -Force + if ($null -eq ('GraphKit.Tests.Task6CredentialFixture' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace GraphKit.Tests; + +public static class Task6CredentialFixture +{ + public static X509Certificate2 CreateCertificate() + { + using RSA rsa = RSA.Create(2048); + CertificateRequest request = new( + "CN=GraphKit-Task6-Test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using X509Certificate2 source = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddHours(1)); +#pragma warning disable SYSLIB0057 + return new X509Certificate2( + source.Export(X509ContentType.Pkcs12), + (string)null, + X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 + } + + public static SecureString CreateSecret() + { + SecureString secret = new(); + foreach (char value in "task6-secret") + { + secret.AppendChar(value); + } + secret.MakeReadOnly(); + return secret; + } +} +'@ + } + $script:storePath = Join-Path $TestDrive 'profiles.json' InModuleScope GraphKit -Parameters @{ StorePath = $script:storePath } { Save-GraphProfileStore -Store @{ @@ -21,6 +65,37 @@ BeforeAll { AuthMethod = 'ClientSecret' Environment = 'Global' Credential = @{ VaultName = 'GraphKit'; SecretName = 'acme-secret'; Version = $null } + }, + @{ + ProfileId = 'cert'; Name = 'Certificate'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'Certificate'; Environment = 'Global' + Credential = @{ VaultName = 'GraphKit'; CertificateName = 'cert'; Version = 'v1' } + }, + @{ + ProfileId = 'mi-system'; Name = 'MI system'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'ManagedIdentity'; Environment = 'Global' + Credential = @{} + }, + @{ + ProfileId = 'mi-user'; Name = 'MI user'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'ManagedIdentity'; Environment = 'Global' + Credential = @{ ClientId = '11111111-2222-3333-4444-555555555555' } + }, + @{ + ProfileId = 'mi-user-alt'; Name = 'MI user alternate format'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'ManagedIdentity'; Environment = 'Global' + Credential = @{ ClientId = ' {11111111-2222-3333-4444-555555555555} ' } + }, + @{ + ProfileId = 'bearer'; Name = 'Bearer'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'BearerToken'; Environment = 'Global' + Credential = @{ VaultName = 'GraphKit'; SecretName = 'bearer'; Version = 'v1' } } ) } -StorePath $StorePath @@ -29,6 +104,49 @@ BeforeAll { Describe 'Get-GraphContext' { + BeforeEach { + Mock Get-GraphVaultCredential -ModuleName GraphKit { + param($Credential, $VaultName, $AuthMethod) + $null = $Credential + $null = $VaultName + switch ($AuthMethod) { + Certificate { + [pscustomobject]@{ + AuthMethod = 'Certificate' + Material = [GraphKit.Tests.Task6CredentialFixture]::CreateCertificate() + OwnsMaterial = $true + CredentialGeneration = 'g1|Certificate|fixture' + } + } + ClientSecret { + [pscustomobject]@{ + AuthMethod = 'ClientSecret' + Material = [GraphKit.Tests.Task6CredentialFixture]::CreateSecret() + OwnsMaterial = $true + CredentialGeneration = 'g1|ClientSecret|fixture' + } + } + BearerToken { + [pscustomobject]@{ + AuthMethod = 'BearerToken' + Material = 'fixed-bearer-fixture' + OwnsMaterial = $false + CredentialGeneration = 'g1|BearerToken|fixture' + } + } + ManagedIdentity { + [pscustomobject]@{ + AuthMethod = 'ManagedIdentity' + Material = $null + ManagedIdentityClientId = $Credential.ClientId + OwnsMaterial = $false + CredentialGeneration = 'g1|ManagedIdentity|fixture' + } + } + } + } + } + It 'resolves a context with zero acquisitions (MsalFactory that throws if invoked)' { $factory = { throw 'MSAL must not be invoked during context resolution' } $context = Get-GraphContext -ProfileId 'acme' -StorePath $script:storePath -MsalFactory $factory @@ -66,12 +184,317 @@ Describe 'Get-GraphContext' { } It 'supports an injected certificate for context-only use' { - $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() + $cert = [GraphKit.Tests.Task6CredentialFixture]::CreateCertificate() $context = Get-GraphContext -ProfileId 'acme' -StorePath $script:storePath -Certificate $cert -MsalFactory { throw 'not invoked' } $context.TokenSource.AuthMode | Should -Be 'Certificate' $context.TokenSource.CanRefresh | Should -BeTrue $context.IdentityState | Should -Be 'NotAcquired' + $cert.Dispose() + } + + It 'routes every persisted built-in to the exact compiled ABI without acquiring a token' -ForEach @( + @{ ProfileId = 'acme'; Mode = 'ClientSecret'; ExpectedClientId = '7d6e5f44-9999-8888-7777-666655554444'; RequestClientId = '7d6e5f44-9999-8888-7777-666655554444'; ManagedIdentityClientId = $null } + @{ ProfileId = 'cert'; Mode = 'Certificate'; ExpectedClientId = '7d6e5f44-9999-8888-7777-666655554444'; RequestClientId = '7d6e5f44-9999-8888-7777-666655554444'; ManagedIdentityClientId = $null } + @{ ProfileId = 'mi-system'; Mode = 'ManagedIdentity'; ExpectedClientId = $null; RequestClientId = $null; ManagedIdentityClientId = $null } + @{ ProfileId = 'mi-user'; Mode = 'ManagedIdentity'; ExpectedClientId = '11111111-2222-3333-4444-555555555555'; RequestClientId = $null; ManagedIdentityClientId = '11111111-2222-3333-4444-555555555555' } + @{ ProfileId = 'bearer'; Mode = 'BearerToken'; ExpectedClientId = $null; RequestClientId = $null; ManagedIdentityClientId = $null } + ) { + $context = Get-GraphContext -ProfileId $ProfileId -StorePath $script:storePath + + $context.TokenSource -is [GraphKit.Auth.IGraphTokenSource] | Should -BeTrue + $context.TokenSource.AuthMode | Should -BeExactly $Mode + [string]$context.ClientId | Should -BeExactly ([string]$ExpectedClientId) + $context.IdentityState | Should -BeExactly 'NotAcquired' + $context.TokenSource.ExpiresOn | Should -Be ([datetimeoffset]::MinValue) + + $inner = [GraphKit.Auth.IGraphTokenSource].Assembly.GetType( + 'GraphKit.Auth.GraphTokenSourceProxy', $true, $false + ).GetField('_inner', [Reflection.BindingFlags]'Instance,NonPublic').GetValue($context.TokenSource) + $providerClientId = $inner.GetType().GetField('_clientId', [Reflection.BindingFlags]'Instance,NonPublic').GetValue($inner) + $credential = $inner.GetType().GetField('_credentialReference', [Reflection.BindingFlags]'Instance,NonPublic').GetValue($inner) + if ($Mode -eq 'ManagedIdentity') { + # A successful ABI request proves its application ClientId was null: + # the frozen constructor rejects any ClientId for ManagedIdentity. + $credential.GetType().FullName | Should -BeExactly 'GraphKit.Auth.ManagedIdentityCredential' + [string]$credential.UserAssignedClientId | Should -BeExactly ([string]$ManagedIdentityClientId) + [string]$providerClientId | Should -BeExactly ([string]$ManagedIdentityClientId) + } + elseif ($Mode -eq 'BearerToken') { + # The same frozen request constructor rejects a bearer application + # ClientId, while the provider retains no source client identity. + $credential.GetType().FullName | Should -BeExactly 'GraphKit.Auth.FixedBearerCredential' + $providerClientId | Should -BeNullOrEmpty + } + else { + [string]$providerClientId | Should -BeExactly ([string]$RequestClientId) + } + } + + It 'keeps every -MsalFactory built-in on the same-runspace legacy path, including bearer' -ForEach @( + @{ ProfileId = 'acme'; ExpectedType = 'ConfidentialClientTokenSource' } + @{ ProfileId = 'cert'; ExpectedType = 'ConfidentialClientTokenSource' } + @{ ProfileId = 'mi-system'; ExpectedType = 'ManagedIdentityTokenSource' } + @{ ProfileId = 'mi-user'; ExpectedType = 'ManagedIdentityTokenSource' } + @{ ProfileId = 'bearer'; ExpectedType = 'FixedBearerTokenSource' } + ) { + $context = Get-GraphContext -ProfileId $ProfileId -StorePath $script:storePath ` + -MsalFactory { throw 'construction must not invoke the compatibility factory' } + + $context.TokenSource.GetType().Name | Should -BeExactly $ExpectedType + $context.TokenSource -is [GraphKit.Auth.IGraphTokenSource] | Should -BeFalse + } + + It 'uses a compiled caller-owned source for an injected certificate unless a compatibility factory is supplied' { + $compiledCertificate = [GraphKit.Tests.Task6CredentialFixture]::CreateCertificate() + $legacyCertificate = [GraphKit.Tests.Task6CredentialFixture]::CreateCertificate() + try { + $compiled = Get-GraphContext -ProfileId 'acme' -StorePath $script:storePath -Certificate $compiledCertificate + $legacy = Get-GraphContext -ProfileId 'acme' -StorePath $script:storePath ` + -Certificate $legacyCertificate -MsalFactory { throw 'not invoked' } + + $compiled.TokenSource -is [GraphKit.Auth.IGraphTokenSource] | Should -BeTrue + $legacy.TokenSource.GetType().Name | Should -BeExactly 'ConfidentialClientTokenSource' + $compiled.TokenSource.Dispose() + { $null = $compiledCertificate.GetCertHash() } | Should -Not -Throw -Because 'caller-owned injected material survives source disposal' + + foreach ($nonApplicationProfile in @('mi-system', 'bearer')) { + { + Get-GraphContext -ProfileId $nonApplicationProfile -StorePath $script:storePath ` + -Certificate $compiledCertificate + } | Should -Throw -ExpectedMessage '*injected certificate*application ClientId*' + { + Get-GraphContext -ProfileId $nonApplicationProfile -StorePath $script:storePath ` + -Certificate $compiledCertificate -MsalFactory { throw 'must not be invoked' } + } | Should -Throw -ExpectedMessage '*injected certificate*application ClientId*' + } + } + finally { + $compiledCertificate.Dispose() + $legacyCertificate.Dispose() + } + } + + It 'rejects an invalid persisted mode identity before any credential or vault access' { + $invalidPath = Join-Path $TestDrive 'invalid-bearer-identity.json' + InModuleScope GraphKit -Parameters @{ StorePath = $invalidPath } { + Save-GraphProfileStore -Store @{ + SchemaVersion = 1 + Profiles = @(@{ + ProfileId = 'invalid'; Name = 'Invalid'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'BearerToken'; Environment = 'Global' + Credential = @{ VaultName = 'GraphKit'; SecretName = 'bearer' } + }) + } -StorePath $StorePath + } + + { Get-GraphContext -ProfileId invalid -StorePath $invalidPath } | + Should -Throw -ExpectedMessage '*BearerToken*re-register*' + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 0 -Exactly + } + + It 'rejects persisted managed-identity selector aliases before material or source work' -ForEach @( + @{ + Case = 'top-level selector only' + TopLevelSelector = '11111111-2222-3333-4444-555555555555' + Credential = @{} + } + @{ + Case = 'top-level selector alongside canonical nested selector' + TopLevelSelector = '11111111-2222-3333-4444-555555555555' + Credential = @{ ClientId = '22222222-3333-4444-5555-666666666666' } + } + @{ + Case = 'alternate nested selector spelling' + TopLevelSelector = $null + Credential = @{ ManagedIdentityClientId = '11111111-2222-3333-4444-555555555555' } + } + ) { + $invalidPath = Join-Path $TestDrive ("invalid-mi-{0}.json" -f [guid]::NewGuid()) + $profile = @{ + ProfileId = 'invalid-mi'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'ManagedIdentity'; Environment = 'Global' + Credential = $Credential + } + if ($null -ne $TopLevelSelector) { + $profile.ManagedIdentityClientId = $TopLevelSelector + } + InModuleScope GraphKit -Parameters @{ StorePath = $invalidPath; Profile = $profile } { + Save-GraphProfileStore -Store @{ SchemaVersion = 1; Profiles = @($Profile) } -StorePath $StorePath + } + $ownedBefore = InModuleScope GraphKit { $script:GraphKitModuleLifecycle.OwnedResources.Count } + + { Get-GraphContext -ProfileId invalid-mi -StorePath $invalidPath } | + Should -Throw -ExpectedMessage '*ManagedIdentity*re-register*' + + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 0 -Exactly + (InModuleScope GraphKit { $script:GraphKitModuleLifecycle.OwnedResources.Count }) | + Should -Be $ownedBefore -Because 'invalid persisted selectors must fail before source construction' + } + + It 'rejects a present canonical nested selector with no value before vault or source work' -ForEach @( + @{ Case = 'certificate null'; Mode = 'Certificate'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'c'; ClientId = $null } } + @{ Case = 'certificate empty'; Mode = 'Certificate'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'c'; ClientId = '' } } + @{ Case = 'certificate whitespace'; Mode = 'Certificate'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'c'; ClientId = ' ' } } + @{ Case = 'client secret null'; Mode = 'ClientSecret'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = $null } } + @{ Case = 'client secret empty'; Mode = 'ClientSecret'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = '' } } + @{ Case = 'client secret whitespace'; Mode = 'ClientSecret'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = ' ' } } + @{ Case = 'managed identity null'; Mode = 'ManagedIdentity'; TopClientId = $null; Credential = @{ ClientId = $null } } + @{ Case = 'managed identity empty'; Mode = 'ManagedIdentity'; TopClientId = $null; Credential = @{ ClientId = '' } } + @{ Case = 'managed identity whitespace'; Mode = 'ManagedIdentity'; TopClientId = $null; Credential = @{ ClientId = ' ' } } + @{ Case = 'bearer null'; Mode = 'BearerToken'; TopClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = $null } } + @{ Case = 'bearer empty'; Mode = 'BearerToken'; TopClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = '' } } + @{ Case = 'bearer whitespace'; Mode = 'BearerToken'; TopClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = ' ' } } + ) { + $invalidPath = Join-Path $TestDrive ("invalid-present-selector-{0}.json" -f [guid]::NewGuid()) + $profile = @{ + ProfileId = 'invalid-present'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $TopClientId; AuthMethod = $Mode; Environment = 'Global' + Credential = $Credential + } + InModuleScope GraphKit -Parameters @{ StorePath = $invalidPath; Profile = $profile } { + Save-GraphProfileStore -Store @{ SchemaVersion = 1; Profiles = @($Profile) } -StorePath $StorePath + } + Mock New-GraphTokenSource -ModuleName GraphKit { throw 'invalid selector reached source construction' } + + { Get-GraphContext -ProfileId invalid-present -StorePath $invalidPath } | + Should -Throw -ExpectedMessage '*re-register*' + + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 0 -Exactly + Should -Invoke New-GraphTokenSource -ModuleName GraphKit -Times 0 -Exactly + } + + It 'rejects a non-null blank top-level ClientId for non-application modes before source work' -ForEach @( + @{ Case = 'managed identity empty'; Mode = 'ManagedIdentity'; TopClientId = ''; Credential = @{} } + @{ Case = 'managed identity whitespace'; Mode = 'ManagedIdentity'; TopClientId = ' '; Credential = @{} } + @{ Case = 'bearer empty'; Mode = 'BearerToken'; TopClientId = ''; Credential = @{ VaultName = 'v'; SecretName = 's' } } + @{ Case = 'bearer whitespace'; Mode = 'BearerToken'; TopClientId = ' '; Credential = @{ VaultName = 'v'; SecretName = 's' } } + ) { + $invalidPath = Join-Path $TestDrive ("invalid-top-level-blank-{0}.json" -f [guid]::NewGuid()) + $profile = @{ + ProfileId = 'invalid-top'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $TopClientId; AuthMethod = $Mode; Environment = 'Global' + Credential = $Credential + } + InModuleScope GraphKit -Parameters @{ StorePath = $invalidPath; Profile = $profile } { + Save-GraphProfileStore -Store @{ SchemaVersion = 1; Profiles = @($Profile) } -StorePath $StorePath + } + Mock New-GraphTokenSource -ModuleName GraphKit { throw 'blank top-level selector reached source construction' } + + { Get-GraphContext -ProfileId invalid-top -StorePath $invalidPath } | + Should -Throw -ExpectedMessage '*re-register*' + Should -Invoke New-GraphTokenSource -ModuleName GraphKit -Times 0 -Exactly + } + + It 'rejects object and resource selector aliases by key presence before vault or source work' -ForEach @( + foreach ($selectorName in @('ObjectId', 'ResourceId', 'ManagedIdentityObjectId', 'ManagedIdentityResourceId')) { + foreach ($location in @('Profile', 'Credential')) { + @{ Case = "$location.$selectorName"; SelectorName = $selectorName; Location = $location } + } + } + ) { + $invalidPath = Join-Path $TestDrive ("invalid-selector-alias-{0}.json" -f [guid]::NewGuid()) + $credential = @{} + $profile = @{ + ProfileId = 'invalid-alias'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'ManagedIdentity'; Environment = 'Global' + Credential = $credential + } + if ($Location -eq 'Profile') { + $profile[$SelectorName] = $null + } + else { + $credential[$SelectorName] = $null + } + InModuleScope GraphKit -Parameters @{ StorePath = $invalidPath; Profile = $profile } { + Save-GraphProfileStore -Store @{ SchemaVersion = 1; Profiles = @($Profile) } -StorePath $StorePath + } + Mock New-GraphTokenSource -ModuleName GraphKit { throw 'invalid selector alias reached source construction' } + + { Get-GraphContext -ProfileId invalid-alias -StorePath $invalidPath } | + Should -Throw -ExpectedMessage '*unsupported identity selector*re-register*' + + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 0 -Exactly + Should -Invoke New-GraphTokenSource -ModuleName GraphKit -Times 0 -Exactly + } + + It 'does not collapse distinct invalid top-level managed-identity selectors into system identity' { + $invalidPath = Join-Path $TestDrive 'invalid-mi-collision.json' + $profiles = @( + @{ + ProfileId = 'invalid-mi-a'; Name = 'Invalid A'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; ManagedIdentityClientId = '11111111-2222-3333-4444-555555555555' + AuthMethod = 'ManagedIdentity'; Environment = 'Global'; Credential = @{} + } + @{ + ProfileId = 'invalid-mi-b'; Name = 'Invalid B'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; ManagedIdentityClientId = '22222222-3333-4444-5555-666666666666' + AuthMethod = 'ManagedIdentity'; Environment = 'Global'; Credential = @{} + } + ) + InModuleScope GraphKit -Parameters @{ StorePath = $invalidPath; Profiles = $profiles } { + Save-GraphProfileStore -Store @{ SchemaVersion = 1; Profiles = $Profiles } -StorePath $StorePath + } + + foreach ($profileId in @('invalid-mi-a', 'invalid-mi-b')) { + { Get-GraphContext -ProfileId $profileId -StorePath $invalidPath } | + Should -Throw -ExpectedMessage '*ManagedIdentityClientId*re-register*' + } + + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 0 -Exactly + } + + It 'canonicalizes one managed-identity selector for compiled and compatibility paths' { + $script:Task6ManagedIdentityResolverSelectors = [System.Collections.Generic.List[string]]::new() + Mock Get-GraphVaultCredential -ModuleName GraphKit -ParameterFilter { $AuthMethod -eq 'ManagedIdentity' } { + $selector = [string] $Credential.ClientId + $script:Task6ManagedIdentityResolverSelectors.Add($selector) + [pscustomobject]@{ + AuthMethod = 'ManagedIdentity' + Material = $null + ManagedIdentityClientId = $selector + OwnsMaterial = $false + CredentialGeneration = "g1|ManagedIdentity|$selector" + } + } + + $compiledCanonical = Get-GraphContext -ProfileId mi-user -StorePath $script:storePath + $compiledAlternate = Get-GraphContext -ProfileId mi-user-alt -StorePath $script:storePath + $legacyCanonical = Get-GraphContext -ProfileId mi-user -StorePath $script:storePath ` + -MsalFactory { throw 'canonicalization test must not acquire' } + $legacyAlternate = Get-GraphContext -ProfileId mi-user-alt -StorePath $script:storePath ` + -MsalFactory { throw 'canonicalization test must not acquire' } + + @($script:Task6ManagedIdentityResolverSelectors) | Should -Be @( + '11111111-2222-3333-4444-555555555555', + '11111111-2222-3333-4444-555555555555' + ) + [string]$compiledAlternate.ClientId | Should -BeExactly ([string]$compiledCanonical.ClientId) + $compiledAlternate.TokenSource.ClientId | Should -BeExactly $compiledCanonical.TokenSource.ClientId + $compiledAlternate.TokenSource.CredentialGeneration | Should -BeExactly $compiledCanonical.TokenSource.CredentialGeneration + $compiledAlternate.AcquisitionCacheKey | Should -BeExactly $compiledCanonical.AcquisitionCacheKey + $legacyAlternate.TokenSource.ClientId | Should -BeExactly $legacyCanonical.TokenSource.ClientId + $legacyAlternate.TokenSource.CredentialGeneration | Should -BeExactly $legacyCanonical.TokenSource.CredentialGeneration + $legacyAlternate.AcquisitionCacheKey | Should -BeExactly $legacyCanonical.AcquisitionCacheKey + $legacyAlternate.TokenSource.ClientId | Should -BeExactly '11111111-2222-3333-4444-555555555555' + } + + It 'preserves the literal public parameter signature' { + $command = Get-Command Get-GraphContext + @($command.Parameters.Keys | Where-Object { $_ -notin [System.Management.Automation.PSCmdlet]::CommonParameters -and $_ -notin [System.Management.Automation.PSCmdlet]::OptionalCommonParameters } | Sort-Object) | + Should -Be @('Certificate', 'MsalFactory', 'ProfileId', 'StorePath', 'TokenProvider') + $command.Parameters.ProfileId.ParameterType.FullName | Should -BeExactly 'System.String' + $command.Parameters.Certificate.ParameterType.FullName | Should -BeExactly 'System.Security.Cryptography.X509Certificates.X509Certificate2' + $command.Parameters.TokenProvider.ParameterType.FullName | Should -BeExactly 'System.Management.Automation.ScriptBlock' + $command.Parameters.MsalFactory.ParameterType.FullName | Should -BeExactly 'System.Management.Automation.ScriptBlock' } It 'rejects an unknown profile' { diff --git a/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 b/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 index 3337442..6f25e23 100644 --- a/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 +++ b/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 @@ -147,7 +147,8 @@ Describe 'Import-GraphLegacyProfile' { It 'refuses an entry whose ProfileId already exists in the store' { $store = Join-Path $TestDrive 'existing.json' Register-GraphTenant -ProfileId 'acme-corp' -Name 'Acme Corp' -Kind customer -TenantId $script:tenantB ` - -Environment Global -AuthMethod ClientSecret -VaultName v -SecretName s -StorePath $store + -Environment Global -AuthMethod ClientSecret -ClientId '11111111-2222-3333-4444-555555555555' ` + -VaultName v -SecretName s -StorePath $store $path = New-LegacyFile -Root $TestDrive -Content @{ tenants = @(@{ name = 'Acme Corp'; tenantId = $script:tenantA; authMethod = 'ClientSecret'; environment = 'Global' }) @@ -216,7 +217,7 @@ Describe 'Import-GraphLegacyProfile' { # reports zero skips - a skipped test turns the whole NUnit result to 'Ignored'. $store = Join-Path $TestDrive 'platform.json' $path = New-LegacyFile -Root $TestDrive -Content @{ - tenants = @(@{ name = 'Winonly'; tenantId = $script:tenantA; authMethod = 'Certificate'; certificateThumbprint = ('A' * 40); certificateStore = 'CurrentUser'; environment = 'Global' }) + tenants = @(@{ name = 'Winonly'; tenantId = $script:tenantA; clientId = '7d6e5f44-9999-8888-7777-666655554444'; authMethod = 'Certificate'; certificateThumbprint = ('A' * 40); certificateStore = 'CurrentUser'; environment = 'Global' }) } $report = Import-GraphLegacyProfile -Path $path -StorePath $store diff --git a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 index 1260c92..6e5d9d5 100644 --- a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 @@ -14,7 +14,7 @@ Describe 'Register-GraphTenant' { It 'persists a client-secret profile and reads it back' { $script:storePath = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) - Register-GraphTenant -ProfileId 'acme' -Name 'Acme' -Kind 'customer' -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'GraphKit' -SecretName 'acme-secret' -StorePath $script:storePath + Register-GraphTenant -ProfileId 'acme' -Name 'Acme' -Kind 'customer' -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'GraphKit' -SecretName 'acme-secret' -StorePath $script:storePath $store = InModuleScope GraphKit -Parameters @{ StorePath = $script:storePath } { Get-GraphProfileStore -StorePath $StorePath @@ -24,6 +24,61 @@ Describe 'Register-GraphTenant' { $store.Profiles[0].Credential.SecretName | Should -Be 'acme-secret' } + It 'persists the exact PFX password secret version' { + $script:storePath = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) + $pfxPath = Join-Path $TestDrive 'registration.pfx' + [System.IO.File]::WriteAllBytes($pfxPath, [byte[]] @(1, 2, 3)) + + Register-GraphTenant -ProfileId 'pfx-versioned' -Name 'PFX' -Kind 'lab' ` + -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment 'Global' -AuthMethod 'Certificate' ` + -PfxPath $pfxPath -PfxVaultName 'GraphKit' -PfxSecretName 'pfx-password' ` + -PfxSecretVersion 'version-2' -StorePath $script:storePath + + $store = InModuleScope GraphKit -Parameters @{ StorePath = $script:storePath } { + Get-GraphProfileStore -StorePath $StorePath + } + $store.Profiles[0].Credential.Password.VaultName | Should -Be 'GraphKit' + $store.Profiles[0].Credential.Password.SecretName | Should -Be 'pfx-password' + $store.Profiles[0].Credential.Password.Version | Should -Be 'version-2' + } + + It 'persists an encrypted vault-certificate password reference and requires a complete pair' { + $script:storePath = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) + + Register-GraphTenant -ProfileId 'vault-cert' -Name 'Vault cert' -Kind 'lab' ` + -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment 'Global' -AuthMethod 'Certificate' ` + -VaultName 'GraphKit' -CertificateName 'certificate-pfx' -CertificateVersion 'cert-v2' ` + -CertificatePasswordVaultName 'GraphKit' -CertificatePasswordSecretName 'certificate-password' ` + -CertificatePasswordVersion 'password-v3' -StorePath $script:storePath + + $store = InModuleScope GraphKit -Parameters @{ StorePath = $script:storePath } { + Get-GraphProfileStore -StorePath $StorePath + } + $store.Profiles[0].Credential.Version | Should -Be 'cert-v2' + $store.Profiles[0].Credential.Password.VaultName | Should -Be 'GraphKit' + $store.Profiles[0].Credential.Password.SecretName | Should -Be 'certificate-password' + $store.Profiles[0].Credential.Password.Version | Should -Be 'password-v3' + + $invalidStore = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) + { + Register-GraphTenant -ProfileId 'invalid-vault-cert' -Name 'Invalid' -Kind 'lab' ` + -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' ` + -Environment 'Global' -AuthMethod 'Certificate' ` + -VaultName 'GraphKit' -CertificateName 'certificate-pfx' ` + -CertificatePasswordVaultName 'GraphKit' -StorePath $invalidStore + } | Should -Throw -ExpectedMessage '*must include both*' + + $versionOnlyStore = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) + { + Register-GraphTenant -ProfileId 'invalid-vault-cert-version' -Name 'Invalid' -Kind 'lab' ` + -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' ` + -Environment 'Global' -AuthMethod 'Certificate' ` + -VaultName 'GraphKit' -CertificateName 'certificate-pfx' ` + -CertificatePasswordVersion 'password-v3' -StorePath $versionOnlyStore + } | Should -Throw -ExpectedMessage '*must include both*' + Test-Path -LiteralPath $versionOnlyStore | Should -BeFalse + } + It 'rejects an injected certificate object' { $script:storePath = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() @@ -65,11 +120,11 @@ Describe 'Register-GraphTenant' { $adapter = { param($Name) if ($Name -ne 'KnownCustomer') { throw "unknown customer tag '$Name'" } } { - Register-GraphTenant -ProfileId 'acme' -Name 'UnknownCustomer' -Kind 'customer' -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -TaxonomyAdapter $adapter -StorePath $script:storePath + Register-GraphTenant -ProfileId 'acme' -Name 'UnknownCustomer' -Kind 'customer' -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -TaxonomyAdapter $adapter -StorePath $script:storePath } | Should -Throw -ExpectedMessage '*unknown customer tag*' { - Register-GraphTenant -ProfileId 'acme' -Name 'KnownCustomer' -Kind 'customer' -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -TaxonomyAdapter $adapter -StorePath $script:storePath + Register-GraphTenant -ProfileId 'acme' -Name 'KnownCustomer' -Kind 'customer' -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -TaxonomyAdapter $adapter -StorePath $script:storePath } | Should -Not -Throw } @@ -82,10 +137,155 @@ Describe 'Register-GraphTenant' { It 'rejects a duplicate ProfileId' { $script:storePath = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) - Register-GraphTenant -ProfileId 'dup' -Name 'Dup' -Kind 'lab' -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -StorePath $script:storePath + Register-GraphTenant -ProfileId 'dup' -Name 'Dup' -Kind 'lab' -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -StorePath $script:storePath { - Register-GraphTenant -ProfileId 'dup' -Name 'Dup2' -Kind 'lab' -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -StorePath $script:storePath + Register-GraphTenant -ProfileId 'dup' -Name 'Dup2' -Kind 'lab' -TenantId $script:tenantId -ClientId '7d6e5f44-9999-8888-7777-666655554444' -Environment 'Global' -AuthMethod 'ClientSecret' -VaultName 'v' -SecretName 's' -StorePath $script:storePath } | Should -Throw -ExpectedMessage '*already exists*' } + + It 'enforces the literal mode-discriminated identity matrix at registration' -ForEach @( + @{ Case = 'certificate missing application client'; Mode = 'Certificate'; Extra = @{ VaultName = 'v'; CertificateName = 'cert' }; Expected = '*Certificate*ClientId*re-register*' } + @{ Case = 'client secret missing application client'; Mode = 'ClientSecret'; Extra = @{ VaultName = 'v'; SecretName = 's' }; Expected = '*ClientSecret*ClientId*re-register*' } + @{ Case = 'zero application client'; Mode = 'ClientSecret'; ClientId = '00000000-0000-0000-0000-000000000000'; Extra = @{ VaultName = 'v'; SecretName = 's' }; Expected = '*non-zero*ClientId*re-register*' } + @{ Case = 'managed identity top-level application client'; Mode = 'ManagedIdentity'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Extra = @{}; Expected = '*ManagedIdentity*must not*ClientId*re-register*' } + @{ Case = 'managed identity invalid nested selector'; Mode = 'ManagedIdentity'; Extra = @{ ManagedIdentityClientId = 'not-a-guid' }; Expected = '*ManagedIdentityClientId*GUID*re-register*' } + @{ Case = 'managed identity zero nested selector'; Mode = 'ManagedIdentity'; Extra = @{ ManagedIdentityClientId = '00000000-0000-0000-0000-000000000000' }; Expected = '*non-zero*ManagedIdentityClientId*re-register*' } + @{ Case = 'bearer application client'; Mode = 'BearerToken'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Extra = @{ VaultName = 'v'; SecretName = 's' }; Expected = '*BearerToken*must not*ClientId*re-register*' } + @{ Case = 'bearer managed identity selector'; Mode = 'BearerToken'; Extra = @{ VaultName = 'v'; SecretName = 's'; ManagedIdentityClientId = '11111111-2222-3333-4444-555555555555' }; Expected = '*BearerToken*ManagedIdentityClientId*re-register*' } + ) { + $storePath = Join-Path $TestDrive ("strict-{0}.json" -f [guid]::NewGuid()) + $arguments = @{ + ProfileId = 'strict'; Name = $Case; Kind = 'lab'; TenantId = $script:tenantId + Environment = 'Global'; AuthMethod = $Mode; StorePath = $storePath + } + if ($null -ne $ClientId) { $arguments.ClientId = $ClientId } + foreach ($entry in $Extra.GetEnumerator()) { $arguments[$entry.Key] = $entry.Value } + + { Register-GraphTenant @arguments } | Should -Throw -ExpectedMessage $Expected + $storePath | Should -Not -Exist -Because 'invalid mode metadata must fail before profile-store mutation' + } + + It 'persists managed-identity selectors only at Credential.ClientId and omits every bearer identity' { + $miStore = Join-Path $TestDrive 'mi-user.json' + $bearerStore = Join-Path $TestDrive 'bearer-no-identity.json' + $selector = '11111111-2222-3333-4444-555555555555' + + $mi = Register-GraphTenant -ProfileId mi-user -Name 'MI user' -Kind lab ` + -TenantId $script:tenantId -Environment Global -AuthMethod ManagedIdentity ` + -ManagedIdentityClientId $selector -StorePath $miStore + $bearer = Register-GraphTenant -ProfileId bearer -Name Bearer -Kind lab ` + -TenantId $script:tenantId -Environment Global -AuthMethod BearerToken ` + -VaultName v -SecretName s -StorePath $bearerStore + + $mi.ClientId | Should -BeNullOrEmpty + $mi.Keys | Should -Not -Contain 'ManagedIdentityClientId' + $mi.Credential.ClientId | Should -BeExactly $selector + $bearer.ClientId | Should -BeNullOrEmpty + $bearer.Credential.Keys | Should -Not -Contain 'ClientId' + } + + It 'preserves valid omission of ManagedIdentityClientId for every authentication mode' -ForEach @( + @{ Case = 'certificate'; Mode = 'Certificate'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Extra = @{ VaultName = 'v'; CertificateName = 'c' } } + @{ Case = 'client secret'; Mode = 'ClientSecret'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Extra = @{ VaultName = 'v'; SecretName = 's' } } + @{ Case = 'managed identity system'; Mode = 'ManagedIdentity'; ClientId = $null; Extra = @{} } + @{ Case = 'bearer'; Mode = 'BearerToken'; ClientId = $null; Extra = @{ VaultName = 'v'; SecretName = 's' } } + ) { + $storePath = Join-Path $TestDrive ("omit-mi-selector-{0}.json" -f [guid]::NewGuid()) + $arguments = @{ + ProfileId = 'omit-mi-selector'; Name = $Case; Kind = 'lab'; TenantId = $script:tenantId + Environment = 'Global'; AuthMethod = $Mode; StorePath = $storePath + } + if ($null -ne $ClientId) { $arguments.ClientId = $ClientId } + foreach ($entry in $Extra.GetEnumerator()) { $arguments[$entry.Key] = $entry.Value } + + $profile = Register-GraphTenant @arguments + + $profile.Keys | Should -Not -Contain 'ManagedIdentityClientId' + $profile.Credential.Keys | Should -Not -Contain 'ManagedIdentityClientId' + if ($Mode -eq 'ManagedIdentity') { + $profile.ClientId | Should -BeNullOrEmpty + $profile.Credential.Keys | Should -Not -Contain 'ClientId' + } + } + + It 'rejects an explicitly bound blank ManagedIdentityClientId before profile-store locking' -ForEach @( + foreach ($valueCase in @( + @{ Label = 'null'; Value = $null } + @{ Label = 'empty'; Value = '' } + @{ Label = 'whitespace'; Value = ' ' } + )) { + @{ + Case = "managed identity $($valueCase.Label)"; Mode = 'ManagedIdentity' + Value = $valueCase.Value; ClientId = $null; Extra = @{} + Expected = '*Credential.ClientId*non-empty*re-register*' + } + @{ + Case = "certificate $($valueCase.Label)"; Mode = 'Certificate' + Value = $valueCase.Value; ClientId = '7d6e5f44-9999-8888-7777-666655554444' + Extra = @{ VaultName = 'v'; CertificateName = 'c' } + Expected = '*unsupported identity selector*Credential.ManagedIdentityClientId*re-register*' + } + @{ + Case = "client secret $($valueCase.Label)"; Mode = 'ClientSecret' + Value = $valueCase.Value; ClientId = '7d6e5f44-9999-8888-7777-666655554444' + Extra = @{ VaultName = 'v'; SecretName = 's' } + Expected = '*unsupported identity selector*Credential.ManagedIdentityClientId*re-register*' + } + @{ + Case = "bearer $($valueCase.Label)"; Mode = 'BearerToken' + Value = $valueCase.Value; ClientId = $null + Extra = @{ VaultName = 'v'; SecretName = 's' } + Expected = '*unsupported identity selector*Credential.ManagedIdentityClientId*re-register*' + } + } + ) { + $storePath = Join-Path $TestDrive ("bound-blank-mi-selector-{0}.json" -f [guid]::NewGuid()) + $arguments = @{ + ProfileId = 'bound-blank'; Name = $Case; Kind = 'lab'; TenantId = $script:tenantId + Environment = 'Global'; AuthMethod = $Mode; StorePath = $storePath + ManagedIdentityClientId = $Value + } + if ($null -ne $ClientId) { $arguments.ClientId = $ClientId } + foreach ($entry in $Extra.GetEnumerator()) { $arguments[$entry.Key] = $entry.Value } + Mock Enter-GraphProfileStoreLock -ModuleName GraphKit { throw 'invalid metadata reached profile-store locking' } + + { Register-GraphTenant @arguments } | Should -Throw -ExpectedMessage $Expected + Should -Invoke Enter-GraphProfileStoreLock -ModuleName GraphKit -Times 0 -Exactly + $storePath | Should -Not -Exist + } + + It 'documents the exact identity selector matrix and ships valid application examples' { + $help = Get-Help Register-GraphTenant -Full + $clientIdHelp = $help.Parameters.Parameter | Where-Object Name -eq 'ClientId' + $managedIdentityHelp = $help.Parameters.Parameter | Where-Object Name -eq 'ManagedIdentityClientId' + $clientIdText = @($clientIdHelp.Description.Text) -join ' ' + $managedIdentityText = @($managedIdentityHelp.Description.Text) -join ' ' + $examples = @($help.Examples.Example | ForEach-Object { [string]$_.Code }) + $clientSecretExample = $examples | Where-Object { $_ -match '-AuthMethod\s+ClientSecret' } | Select-Object -First 1 + $certificateExample = $examples | Where-Object { $_ -match '-AuthMethod\s+Certificate' } | Select-Object -First 1 + + $clientIdText | Should -Match '(?i)required.*Certificate.*ClientSecret' + $clientIdText | Should -Match '(?i)(forbidden|must not).*ManagedIdentity.*BearerToken' + $managedIdentityText | Should -Match '(?i)registration.*Credential\.ClientId' + $managedIdentityText | Should -Match '(?i)system-assigned.*omit' + $clientSecretExample | Should -Match '-ClientId\s+' + $certificateExample | Should -Match '-ClientId\s+' + } + + It 'preserves the literal public parameter signature' { + $command = Get-Command Register-GraphTenant + $expected = @( + 'AuthMethod', 'Certificate', 'CertificateName', 'CertificatePasswordSecretName', + 'CertificatePasswordVaultName', 'CertificatePasswordVersion', 'CertificateVersion', + 'ClientId', 'Environment', 'Kind', 'ManagedIdentityClientId', 'Name', 'PfxPath', + 'PfxSecretName', 'PfxSecretVersion', 'PfxVaultName', 'ProfileId', 'SecretName', + 'SecretVersion', 'StoreLocation', 'StoreName', 'StorePath', 'Subject', 'TaxonomyAdapter', + 'TenantId', 'Thumbprint', 'TokenProvider', 'VaultName' + ) + @($command.Parameters.Keys | Where-Object { $_ -notin [System.Management.Automation.PSCmdlet]::CommonParameters -and $_ -notin [System.Management.Automation.PSCmdlet]::OptionalCommonParameters } | Sort-Object) | + Should -Be ($expected | Sort-Object) + $command.Parameters.ClientId.ParameterType.FullName | Should -BeExactly 'System.String' + $command.Parameters.ManagedIdentityClientId.ParameterType.FullName | Should -BeExactly 'System.String' + } } diff --git a/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 index 880b27d..b670184 100644 --- a/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 @@ -22,7 +22,7 @@ Describe 'Test-GraphTenant' { It 'accepts a valid profile' { Test-GraphTenant -TenantProfile @{ - ProfileId = 'acme'; Name = 'Acme'; Kind = 'lab'; TenantId = '3a4b5c6d-1111-2222-3333-444455556666'; AuthMethod = 'ClientSecret'; Environment = 'Global' + ProfileId = 'acme'; Name = 'Acme'; Kind = 'lab'; TenantId = '3a4b5c6d-1111-2222-3333-444455556666'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; AuthMethod = 'ClientSecret'; Environment = 'Global'; Credential = @{ VaultName = 'v'; SecretName = 's' } } | Should -BeTrue } @@ -60,4 +60,158 @@ Describe 'Test-GraphTenant' { Test-GraphTenant -ProfileId 'acme' -StorePath $script:storePath | Should -BeTrue Test-GraphTenant -ProfileId 'missing' -StorePath $script:storePath | Should -BeFalse } + + It 'accepts the exact valid identity shape for each built-in auth mode' -ForEach @( + @{ Mode = 'Certificate'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'cert' } } + @{ Mode = 'ClientSecret'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's' } } + @{ Mode = 'ManagedIdentity'; ClientId = $null; Credential = @{} } + @{ Mode = 'ManagedIdentity'; ClientId = $null; Credential = @{ ClientId = '11111111-2222-3333-4444-555555555555' } } + @{ Mode = 'BearerToken'; ClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's' } } + ) { + Test-GraphTenant -TenantProfile @{ + ProfileId = 'valid'; Name = 'Valid'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $ClientId; AuthMethod = $Mode; Environment = 'Global'; Credential = $Credential + } | Should -BeTrue + } + + It 'rejects the exact same contradictory identity matrix as registration and context construction' -ForEach @( + @{ Case = 'certificate missing app id'; Mode = 'Certificate'; ClientId = $null; Credential = @{ VaultName = 'v'; CertificateName = 'cert' } } + @{ Case = 'certificate nested MI id'; Mode = 'Certificate'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'cert'; ClientId = '11111111-2222-3333-4444-555555555555' } } + @{ Case = 'secret missing app id'; Mode = 'ClientSecret'; ClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's' } } + @{ Case = 'secret zero app id'; Mode = 'ClientSecret'; ClientId = '00000000-0000-0000-0000-000000000000'; Credential = @{ VaultName = 'v'; SecretName = 's' } } + @{ Case = 'MI top-level app id'; Mode = 'ManagedIdentity'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{} } + @{ Case = 'MI invalid selector'; Mode = 'ManagedIdentity'; ClientId = $null; Credential = @{ ClientId = 'nope' } } + @{ Case = 'MI zero selector'; Mode = 'ManagedIdentity'; ClientId = $null; Credential = @{ ClientId = '00000000-0000-0000-0000-000000000000' } } + @{ Case = 'bearer app id'; Mode = 'BearerToken'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's' } } + @{ Case = 'bearer nested id'; Mode = 'BearerToken'; ClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = '11111111-2222-3333-4444-555555555555' } } + ) { + Test-GraphTenant -TenantProfile @{ + ProfileId = 'invalid'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $ClientId; AuthMethod = $Mode; Environment = 'Global'; Credential = $Credential + } | Should -BeFalse + } + + It 'rejects persisted managed-identity selector aliases identically' -ForEach @( + @{ + Case = 'top-level selector only' + TopLevelSelector = '11111111-2222-3333-4444-555555555555' + Credential = @{} + } + @{ + Case = 'top-level and canonical nested selectors' + TopLevelSelector = '11111111-2222-3333-4444-555555555555' + Credential = @{ ClientId = '22222222-3333-4444-5555-666666666666' } + } + @{ + Case = 'alternate nested selector spelling' + TopLevelSelector = $null + Credential = @{ ManagedIdentityClientId = '11111111-2222-3333-4444-555555555555' } + } + ) { + $tenantProfileUnderTest = @{ + ProfileId = 'invalid-mi'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'ManagedIdentity'; Environment = 'Global' + Credential = $Credential + } + if ($null -ne $TopLevelSelector) { + $tenantProfileUnderTest.ManagedIdentityClientId = $TopLevelSelector + } + + Test-GraphTenant -TenantProfile $tenantProfileUnderTest | Should -BeFalse + } + + It 'rejects a present canonical nested selector with a null, empty, or whitespace value' -ForEach @( + @{ Case = 'certificate null'; Mode = 'Certificate'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'c'; ClientId = $null } } + @{ Case = 'certificate empty'; Mode = 'Certificate'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'c'; ClientId = '' } } + @{ Case = 'certificate whitespace'; Mode = 'Certificate'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'c'; ClientId = ' ' } } + @{ Case = 'client secret null'; Mode = 'ClientSecret'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = $null } } + @{ Case = 'client secret empty'; Mode = 'ClientSecret'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = '' } } + @{ Case = 'client secret whitespace'; Mode = 'ClientSecret'; TopClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = ' ' } } + @{ Case = 'managed identity null'; Mode = 'ManagedIdentity'; TopClientId = $null; Credential = @{ ClientId = $null } } + @{ Case = 'managed identity empty'; Mode = 'ManagedIdentity'; TopClientId = $null; Credential = @{ ClientId = '' } } + @{ Case = 'managed identity whitespace'; Mode = 'ManagedIdentity'; TopClientId = $null; Credential = @{ ClientId = ' ' } } + @{ Case = 'bearer null'; Mode = 'BearerToken'; TopClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = $null } } + @{ Case = 'bearer empty'; Mode = 'BearerToken'; TopClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = '' } } + @{ Case = 'bearer whitespace'; Mode = 'BearerToken'; TopClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 's'; ClientId = ' ' } } + ) { + Test-GraphTenant -TenantProfile @{ + ProfileId = 'invalid-present'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $TopClientId; AuthMethod = $Mode; Environment = 'Global' + Credential = $Credential + } | Should -BeFalse + } + + It 'rejects a non-null blank top-level ClientId for ManagedIdentity and BearerToken' -ForEach @( + @{ Case = 'managed identity empty'; Mode = 'ManagedIdentity'; TopClientId = ''; Credential = @{} } + @{ Case = 'managed identity whitespace'; Mode = 'ManagedIdentity'; TopClientId = ' '; Credential = @{} } + @{ Case = 'bearer empty'; Mode = 'BearerToken'; TopClientId = ''; Credential = @{ VaultName = 'v'; SecretName = 's' } } + @{ Case = 'bearer whitespace'; Mode = 'BearerToken'; TopClientId = ' '; Credential = @{ VaultName = 'v'; SecretName = 's' } } + ) { + Test-GraphTenant -TenantProfile @{ + ProfileId = 'invalid-top'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $TopClientId; AuthMethod = $Mode; Environment = 'Global' + Credential = $Credential + } | Should -BeFalse + } + + It 'rejects object and resource identity-selector aliases by key presence' -ForEach @( + foreach ($selectorName in @('ObjectId', 'ResourceId', 'ManagedIdentityObjectId', 'ManagedIdentityResourceId')) { + foreach ($location in @('Profile', 'Credential')) { + @{ Case = "$location.$selectorName"; SelectorName = $selectorName; Location = $location } + } + } + ) { + $credential = @{} + $tenantProfileUnderTest = @{ + ProfileId = 'invalid-alias'; Name = $Case; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null; AuthMethod = 'ManagedIdentity'; Environment = 'Global' + Credential = $credential + } + if ($Location -eq 'Profile') { + $tenantProfileUnderTest[$SelectorName] = $null + } + else { + $credential[$SelectorName] = $null + } + + Test-GraphTenant -TenantProfile $tenantProfileUnderTest | Should -BeFalse + } + + It 'documents false plus corrective re-registration for invalid successor metadata' { + $help = Get-Help Test-GraphTenant -Full + $description = @($help.Description.Text) -join ' ' + + $description | Should -Match '(?i)returns? false' + $description | Should -Match '(?i)re-register' + + InModuleScope GraphKit { + Mock Assert-GraphTenantProfileAuthSchema { + throw [System.InvalidOperationException]::new('schema implementation failure') + } + { + Test-GraphTenant -TenantProfile @{ + ProfileId = 'valid'; Name = 'Valid'; Kind = 'lab' + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'ClientSecret'; Environment = 'Global' + Credential = @{ VaultName = 'v'; SecretName = 's' } + } + } | Should -Throw -ExceptionType ([System.InvalidOperationException]) ` + -ExpectedMessage '*schema implementation failure*' + } + } + + It 'preserves the literal public parameter signature' { + $command = Get-Command Test-GraphTenant + @($command.Parameters.Keys | Where-Object { $_ -notin [System.Management.Automation.PSCmdlet]::CommonParameters -and $_ -notin [System.Management.Automation.PSCmdlet]::OptionalCommonParameters } | Sort-Object) | + Should -Be @('ProfileId', 'StorePath', 'TenantProfile') + $command.Parameters.ProfileId.ParameterType.FullName | Should -BeExactly 'System.String' + $command.Parameters.TenantProfile.ParameterType.FullName | Should -BeExactly 'System.Collections.Hashtable' + } } diff --git a/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 index 388384f..78dac32 100644 --- a/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 @@ -12,7 +12,7 @@ BeforeAll { Save-GraphProfileStore -Store @{ SchemaVersion = 1 Profiles = @( - @{ ProfileId = 'acme'; Name = 'Acme'; Kind = 'customer'; TenantId = '3a4b5c6d-1111-2222-3333-444455556666'; AuthMethod = 'ClientSecret'; Environment = 'Global'; Credential = @{ VaultName = 'v'; SecretName = 's' } } + @{ ProfileId = 'acme'; Name = 'Acme'; Kind = 'customer'; TenantId = '3a4b5c6d-1111-2222-3333-444455556666'; ClientId = '11111111-2222-3333-4444-555555555555'; AuthMethod = 'ClientSecret'; Environment = 'Global'; Credential = @{ VaultName = 'v'; SecretName = 's' } } ) } -StorePath $StorePath } @@ -20,6 +20,20 @@ BeforeAll { Describe 'Use-GraphTenant' { + BeforeEach { + Mock Get-GraphVaultCredential -ModuleName GraphKit { + $material = [Security.SecureString]::new() + $material.AppendChar('x') + $material.MakeReadOnly() + [pscustomobject]@{ + AuthMethod = 'ClientSecret' + Material = $material + OwnsMaterial = $true + CredentialGeneration = 'g1|ClientSecret|fixture' + } + } + } + It 'sets the script-scoped current context and returns it' { $context = Use-GraphTenant -ProfileId 'acme' -StorePath $script:storePath @@ -33,4 +47,3 @@ Describe 'Use-GraphTenant' { { Use-GraphTenant -ProfileId 'missing' -StorePath $script:storePath } | Should -Throw -ExpectedMessage '*No profile with ProfileId*' } } - diff --git a/tests/Unit/Throttle/ThrottleGate.Tests.ps1 b/tests/Unit/Throttle/ThrottleGate.Tests.ps1 index a7898c7..9fe0432 100644 --- a/tests/Unit/Throttle/ThrottleGate.Tests.ps1 +++ b/tests/Unit/Throttle/ThrottleGate.Tests.ps1 @@ -133,6 +133,37 @@ Describe 'Wait-GraphThrottleGate' { } } + It 'supports an advanced one-parameter delay seam without requiring cancellation support' { + InModuleScope GraphKit -Parameters @{ + UtcNow = $script:utcNow + Context = $script:context + Descriptor = $script:descriptor + } { + param($UtcNow, $Context, $Descriptor) + + $coordinator = [GraphThrottleCoordinator]::new() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $coordinator.ApplyCooldown($scope.CoarseKey, 1, $UtcNow) + $delays = [System.Collections.Generic.List[long]]::new() + $delay = { + [CmdletBinding()] + param([long] $Milliseconds) + + $delays.Add($Milliseconds) + }.GetNewClosure() + + $admission = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -UtcNow $UtcNow -Delay $delay + + try { + $delays | Should -Be @(1000) + } + finally { + Complete-GraphThrottleGate -Admission $admission + } + } + } + It 'skips the wait and only acquires admission when no cooldown is active' { InModuleScope GraphKit -Parameters @{ UtcNow = $script:utcNow @@ -155,6 +186,383 @@ Describe 'Wait-GraphThrottleGate' { $coordinator.GetInFlight($scope.LeafKey) | Should -Be 1 } } + + It 'does not delay or acquire when cancellation is already requested before cooldown' { + $capture = InModuleScope GraphKit -Parameters @{ + UtcNow = $script:utcNow + Context = $script:context + Descriptor = $script:descriptor + } { + param($UtcNow, $Context, $Descriptor) + + $coordinator = [GraphThrottleCoordinator]::new() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $coordinator.ApplyCooldown($scope.CoarseKey, 60, $UtcNow) + $cts = [System.Threading.CancellationTokenSource]::new() + $cts.Cancel() + $delayCalls = [System.Collections.Generic.List[int]]::new() + $delay = { + param($Milliseconds) + $delayCalls.Add([int] $Milliseconds) + }.GetNewClosure() + + try { + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -UtcNow $UtcNow -CancellationToken $cts.Token ` + -Delay $delay + } + catch { + $failure = $_.Exception + } + + $isCancellation = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + break + } + $candidate = $candidate.InnerException + } + + [pscustomobject] @{ + IsCancellation = $isCancellation + DelayCalls = $delayCalls.Count + InFlight = $coordinator.GetInFlight($scope.LeafKey) + } + } + finally { + $cts.Dispose() + } + } + + $capture.IsCancellation | Should -BeTrue + $capture.DelayCalls | Should -Be 0 + $capture.InFlight | Should -Be 0 + } + + It 'does not acquire a new slot when cancellation is raised by an admission poll' { + $capture = InModuleScope GraphKit -Parameters @{ + Context = $script:context + Descriptor = $script:descriptor + } { + param($Context, $Descriptor) + + $coordinator = [GraphThrottleCoordinator]::new() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $coordinator.SetMaxConcurrent($scope.LeafKey, 1) + $first = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -Delay { param($Milliseconds) } + $cts = [System.Threading.CancellationTokenSource]::new() + $delayCalls = [System.Collections.Generic.List[long]]::new() + $delay = { + param($Milliseconds, $CancellationToken) + $delayCalls.Add([long] $Milliseconds) + $cts.Cancel() + }.GetNewClosure() + + try { + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -CancellationToken $cts.Token -Delay $delay + } + catch { + $failure = $_.Exception + } + + $isCancellation = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + break + } + $candidate = $candidate.InnerException + } + + [pscustomobject] @{ + IsCancellation = $isCancellation + DelayCalls = @($delayCalls) + InFlightBeforeCleanup = $coordinator.GetInFlight($scope.LeafKey) + } + } + finally { + Complete-GraphThrottleGate -Admission $first + $cts.Dispose() + } + } + + $capture.IsCancellation | Should -BeTrue + $capture.DelayCalls | Should -Be @(50) + $capture.InFlightBeforeCleanup | Should -Be 1 -Because 'only the original holder may remain admitted' + } + + It 'clamps a cooldown to the inherited deadline and acquires no admission after expiry' { + $capture = InModuleScope GraphKit -Parameters @{ + UtcNow = $script:utcNow + Context = $script:context + Descriptor = $script:descriptor + } { + param($UtcNow, $Context, $Descriptor) + + $clock = [pscustomobject] @{ Value = $UtcNow } + $utcNowScript = { $clock.Value }.GetNewClosure() + $coordinator = [GraphThrottleCoordinator]::new() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $coordinator.ApplyCooldown($scope.CoarseKey, 60, $UtcNow) + $delays = [System.Collections.Generic.List[long]]::new() + $delay = { + param([long] $Milliseconds, [System.Threading.CancellationToken] $CancellationToken) + $delays.Add($Milliseconds) + $clock.Value = $clock.Value.AddMilliseconds($Milliseconds) + }.GetNewClosure() + + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -UtcNow $UtcNow -UtcNowScript $utcNowScript ` + -DeadlineUtc $UtcNow.AddSeconds(5) ` + -RemainingDeadline ([TimeSpan]::FromSeconds(5)) -Delay $delay + } + catch { + $failure = $_.Exception + } + + [pscustomobject] @{ + Failure = $failure + Delays = @($delays) + InFlight = $coordinator.GetInFlight($scope.LeafKey) + } + } + + $capture.Failure | Should -BeOfType [System.TimeoutException] + $capture.Failure.Data['GraphKit.OperationDeadlineExpired'] | Should -BeTrue + $capture.Delays | Should -HaveCount 1 + $capture.Delays[0] | Should -BeGreaterThan 0 + $capture.Delays[0] | Should -BeLessOrEqual 5000 + $capture.InFlight | Should -Be 0 + } + + It 'clamps admission polling to the inherited deadline without leaking a slot' { + $capture = InModuleScope GraphKit -Parameters @{ + UtcNow = $script:utcNow + Context = $script:context + Descriptor = $script:descriptor + } { + param($UtcNow, $Context, $Descriptor) + + $clock = [pscustomobject] @{ Value = $UtcNow } + $utcNowScript = { $clock.Value }.GetNewClosure() + $coordinator = [GraphThrottleCoordinator]::new() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $coordinator.SetMaxConcurrent($scope.LeafKey, 1) + $holder = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator -Delay { param($Milliseconds) } + $delays = [System.Collections.Generic.List[long]]::new() + $delay = { + param([long] $Milliseconds, [System.Threading.CancellationToken] $CancellationToken) + $delays.Add($Milliseconds) + $clock.Value = $clock.Value.AddMilliseconds($Milliseconds) + }.GetNewClosure() + + try { + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -UtcNow $UtcNow -UtcNowScript $utcNowScript ` + -DeadlineUtc $UtcNow.AddMilliseconds(75) ` + -RemainingDeadline ([TimeSpan]::FromMilliseconds(75)) -Delay $delay + } + catch { + $failure = $_.Exception + } + + [pscustomobject] @{ + Failure = $failure + Delays = @($delays) + InFlight = $coordinator.GetInFlight($scope.LeafKey) + } + } + finally { + Complete-GraphThrottleGate -Admission $holder + } + } + + $capture.Failure | Should -BeOfType [System.TimeoutException] + $capture.Failure.Data['GraphKit.OperationDeadlineExpired'] | Should -BeTrue + $capture.Delays | Should -Be @(50, 25) + $capture.InFlight | Should -Be 1 -Because 'only the pre-existing holder may remain admitted' + } + + It 'gives caller cancellation precedence at the exact cooldown deadline boundary' { + $capture = InModuleScope GraphKit -Parameters @{ + UtcNow = $script:utcNow + Context = $script:context + Descriptor = $script:descriptor + } { + param($UtcNow, $Context, $Descriptor) + + $clock = [pscustomobject] @{ Value = $UtcNow } + $utcNowScript = { $clock.Value }.GetNewClosure() + $coordinator = [GraphThrottleCoordinator]::new() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $coordinator.ApplyCooldown($scope.CoarseKey, 60, $UtcNow) + $cts = [System.Threading.CancellationTokenSource]::new() + $delay = { + param([long] $Milliseconds, [System.Threading.CancellationToken] $CancellationToken) + $clock.Value = $clock.Value.AddMilliseconds($Milliseconds) + $cts.Cancel() + }.GetNewClosure() + + try { + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -UtcNow $UtcNow -UtcNowScript $utcNowScript ` + -DeadlineUtc $UtcNow.AddSeconds(5) ` + -RemainingDeadline ([TimeSpan]::FromSeconds(5)) ` + -CancellationToken $cts.Token -Delay $delay + } + catch { + $failure = $_.Exception + } + + [pscustomobject] @{ + Failure = $failure + InFlight = $coordinator.GetInFlight($scope.LeafKey) + } + } + finally { + $cts.Dispose() + } + } + + $isCancellation = $false + $candidate = $capture.Failure + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + break + } + $candidate = $candidate.InnerException + } + $isCancellation | Should -BeTrue + $capture.InFlight | Should -Be 0 + } + + It 'preserves the admission back-pressure timeout when it expires before the operation deadline' { + $capture = InModuleScope GraphKit -Parameters @{ + UtcNow = $script:utcNow + Context = $script:context + Descriptor = $script:descriptor + } { + param($UtcNow, $Context, $Descriptor) + + $clock = [pscustomobject] @{ Value = $UtcNow } + $utcNowScript = { $clock.Value }.GetNewClosure() + $coordinator = [GraphThrottleCoordinator]::new() + $scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + $coordinator.SetMaxConcurrent($scope.LeafKey, 1) + $holder = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator -Delay { param($Milliseconds) } + $delay = { + param([long] $Milliseconds, [System.Threading.CancellationToken] $CancellationToken) + $clock.Value = $clock.Value.AddMilliseconds($Milliseconds) + }.GetNewClosure() + + try { + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -UtcNow $UtcNow -UtcNowScript $utcNowScript ` + -DeadlineUtc $UtcNow.AddSeconds(5) ` + -RemainingDeadline ([TimeSpan]::FromSeconds(5)) ` + -AdmissionTimeoutSeconds 1 -Delay $delay + } + catch { + $failure = $_.Exception + } + + [pscustomobject] @{ + Failure = $failure + InFlight = $coordinator.GetInFlight($scope.LeafKey) + } + } + finally { + Complete-GraphThrottleGate -Admission $holder + } + } + + $capture.Failure | Should -Not -BeNullOrEmpty + $capture.Failure.Message | Should -BeLike '*Throttle admission timed out after 1s*back-pressure*' + $capture.Failure.Data['GraphKit.OperationDeadlineExpired'] | Should -Not -BeTrue + $capture.InFlight | Should -Be 1 -Because 'only the pre-existing holder may remain admitted' + } + + It 'gives caller cancellation precedence when the final admission attempt reaches the back-pressure timeout' { + $capture = InModuleScope GraphKit { + $cts = [System.Threading.CancellationTokenSource]::new() + $coordinator = [pscustomobject] @{ + Attempts = 0 + Releases = 0 + Cts = $cts + } + $coordinator | Add-Member -MemberType ScriptMethod -Name GetWaitMilliseconds -Value { + param($Key, $UtcNow) + return 0L + } + $coordinator | Add-Member -MemberType ScriptMethod -Name TryAcquireAdmission -Value { + param($Key) + $this.Attempts++ + if ($this.Attempts -eq 21) { + $this.Cts.Cancel() + } + return $false + } + $coordinator | Add-Member -MemberType ScriptMethod -Name ReleaseAdmission -Value { + param($Key, $Success) + $this.Releases++ + } + + try { + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope @{ CoarseKey = 'coarse'; LeafKey = 'leaf' } ` + -Coordinator $coordinator -CancellationToken $cts.Token ` + -AdmissionTimeoutSeconds 1 -Delay { param($Milliseconds, $CancellationToken) } + } + catch { + $failure = $_.Exception + } + + $isCancellation = $false + $candidate = $failure + while ($null -ne $candidate) { + if ($candidate -is [System.OperationCanceledException]) { + $isCancellation = $true + break + } + $candidate = $candidate.InnerException + } + + [pscustomobject] @{ + Failure = $failure + IsCancellation = $isCancellation + Attempts = $coordinator.Attempts + Releases = $coordinator.Releases + } + } + finally { + $cts.Dispose() + } + } + + $capture.IsCancellation | Should -BeTrue + $capture.Failure.Message | Should -Not -BeLike '*back-pressure*' + $capture.Attempts | Should -Be 21 + $capture.Releases | Should -Be 0 -Because 'no slot was acquired in the cancellation race' + } } Describe 'Complete-GraphThrottleGate' { diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index fb6af29..a07a53d 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -7,6 +7,365 @@ BeforeAll { } $script:BuiltManifest = Join-Path $built.FullName 'GraphKit.psd1' Import-Module $script:BuiltManifest -Force + + if ($null -eq ('GraphKit.Tests.Task6CredentialFixture' -as [type])) { + Add-Type -CompilerOptions '/nowarn:SYSLIB0057' -TypeDefinition @' +using System; +using System.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace GraphKit.Tests; + +public static class Task6CredentialFixture +{ + public static X509Certificate2 CreateCertificate() + { + using RSA rsa = RSA.Create(2048); + CertificateRequest request = new( + "CN=GraphKit-Task6-Test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using X509Certificate2 source = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddHours(1)); +#pragma warning disable SYSLIB0057 + return new X509Certificate2( + source.Export(X509ContentType.Pkcs12), + (string)null, + X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 + } + + public static SecureString CreateSecret() + { + SecureString secret = new(); + foreach (char character in "task6-secret") secret.AppendChar(character); + secret.MakeReadOnly(); + return secret; + } +} + +'@ + } + + if ($null -eq ('GraphKit.Tests.Task6CountingCertificate' -as [type])) { + Add-Type -CompilerOptions '/nowarn:SYSLIB0057' -TypeDefinition @' +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace GraphKit.Tests; + +public sealed class Task6CountingCertificate : X509Certificate2, IDisposable +{ + private int _disposeCount; + + private Task6CountingCertificate(byte[] pfx) : base(pfx) { } + + public int DisposeCount => System.Threading.Volatile.Read(ref _disposeCount); + + public static Task6CountingCertificate Create() + { + using RSA rsa = RSA.Create(2048); + CertificateRequest request = new( + "CN=GraphKit-Task6-Counting", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using X509Certificate2 source = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddHours(1)); + return new Task6CountingCertificate(source.Export(X509ContentType.Pkcs12)); + } + + public new void Dispose() + { + System.Threading.Interlocked.Increment(ref _disposeCount); + base.Dispose(); + } + + public void DisposeWithoutCounting() => base.Dispose(); +} +'@ + } + + if ($null -eq ('GraphKit.Tests.Task6CleanupProbe' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace GraphKit.Tests; + +public sealed class Task6CleanupProbe : IDisposable +{ + public const string SensitiveDetail = "task6-sensitive-bridge-cleanup-detail"; + private int _disposeCount; + + public Task6CleanupProbe(bool throwOnDispose) + { + ThrowOnDispose = throwOnDispose; + } + + public int DisposeCount => System.Threading.Volatile.Read(ref _disposeCount); + public bool ThrowOnDispose { get; } + + public void Dispose() + { + System.Threading.Interlocked.Increment(ref _disposeCount); + if (ThrowOnDispose) + { + throw new InvalidOperationException(SensitiveDetail); + } + } +} + +public static class Task6PfxFixture +{ + public static byte[] CreatePfxBytes(string password) + { + using RSA rsa = RSA.Create(2048); + CertificateRequest request = new( + "CN=GraphKit-Task6-PFX", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using X509Certificate2 source = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddHours(1)); + return source.Export(X509ContentType.Pkcs12, password); + } + + public static string GetThumbprint(byte[] pfx, string password) + { +#pragma warning disable SYSLIB0057 + using X509Certificate2 certificate = new( + pfx, + password, + X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 + return certificate.Thumbprint; + } +} +'@ + } + + function Test-Task6SecretDisposed { + param([Parameter(Mandatory)] [Security.SecureString] $Secret) + try { + $copy = $Secret.Copy() + $copy.Dispose() + return $false + } + catch [ObjectDisposedException] { + return $true + } + } + + function Test-Task6CertificateDisposed { + param([Parameter(Mandatory)] [Security.Cryptography.X509Certificates.X509Certificate2] $Certificate) + try { + $null = $Certificate.GetCertHash() + return $false + } + catch [ObjectDisposedException] { + return $true + } + catch [Security.Cryptography.CryptographicException] { + return $true + } + } + + if ($null -eq ('GraphKit.Tests.ConcurrentApplicationHarness' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphKit.Tests +{ + public sealed class ConcurrentApplicationHarness + { + public const string ContractMarker = "GraphKit.Task7.ConcurrentApplicationHarness/1"; + private static int _factoryCalls; + private static int _disposed; + private static ManualResetEventSlim _entered = new(false); + private static ManualResetEventSlim _release = new(false); + + public static int FactoryCalls { get { return Volatile.Read(ref _factoryCalls); } } + public static bool WaitUntilEntered(int millisecondsTimeout) => _entered.Wait(millisecondsTimeout); + public static void Release() => _release.Set(); + + public static void Reset() + { + Interlocked.Exchange(ref _factoryCalls, 0); + Interlocked.Exchange(ref _disposed, 0); + ManualResetEventSlim oldEntered = Interlocked.Exchange( + ref _entered, new ManualResetEventSlim(false)); + ManualResetEventSlim oldRelease = Interlocked.Exchange( + ref _release, new ManualResetEventSlim(false)); + oldEntered.Dispose(); + oldRelease.Dispose(); + } + + public static void ResetAndDispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _release.Set(); + _entered.Dispose(); + _release.Dispose(); + Interlocked.Exchange(ref _factoryCalls, 0); + } + + public static ConcurrentConfidentialApplication Create() + { + Interlocked.Increment(ref _factoryCalls); + _entered.Set(); + _release.Wait(); + return new ConcurrentConfidentialApplication(); + } + } + + public sealed class ConcurrentConfidentialApplication + { + public ConcurrentConfidentialBuilder AcquireTokenForClient(string[] scopes) + { + return new ConcurrentConfidentialBuilder(); + } + } + + public sealed class ConcurrentConfidentialBuilder + { + public ConcurrentConfidentialBuilder WithForceRefresh(bool forceRefresh) + { + return this; + } + + public Task ExecuteAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ConcurrentAuthenticationResult + { + AccessToken = "single-confidential-app-token", + ExpiresOn = DateTimeOffset.UtcNow.AddHours(1) + }); + } + } + + public sealed class ConcurrentAuthenticationResult + { + public string AccessToken { get; set; } + public DateTimeOffset ExpiresOn { get; set; } + } +} +'@ + } + + $concurrentHarnessType = 'GraphKit.Tests.ConcurrentApplicationHarness' -as [type] + $concurrentHarnessMarker = if ($null -ne $concurrentHarnessType) { + $concurrentHarnessType.GetField('ContractMarker') + } + else { + $null + } + if ($null -eq $concurrentHarnessMarker -or + [string] $concurrentHarnessMarker.GetRawConstantValue() -cne + 'GraphKit.Task7.ConcurrentApplicationHarness/1') { + throw ( + 'The process-global ConcurrentApplicationHarness contract is stale. ' + + 'Run this test file in a fresh PowerShell process.' + ) + } + + function New-ConcurrentHarnessSource { + InModuleScope GraphKit { + # ScriptBlock.Create keeps the fake itself runspace-neutral; the + # legacy source is intentionally created in this parent runspace. + $factory = [scriptblock]::Create( + '[GraphKit.Tests.ConcurrentApplicationHarness]::Create()' + ) + + [ConfidentialClientTokenSource]::new( + $factory, + 'Certificate', + 'https://graph.microsoft.com', + 'client-id', + 'generation' + ) + } + } + + function Get-Task7OuterFlightState { + param([Parameter(Mandatory)] [string] $Key) + + return InModuleScope GraphKit -Parameters @{ Key = $Key } { + param($Key) + $flight = [GraphTokenFlight] $null + $exists = [GraphTokenFlightRegistry]::Flights.TryGetValue($Key, [ref] $flight) + [pscustomobject] @{ + Exists = $exists + Flight = [object] $flight + WaiterCount = if ($exists) { + [int] (Get-GraphTokenFlightWaiterCount -Flight $flight) + } + else { + -1 + } + RegistryCount = [GraphTokenFlightRegistry]::Flights.Count + IsCompleted = $exists -and $flight.Completion.Task.IsCompleted + } + } + } + + function Wait-Task7OuterFollowerCount { + param( + [Parameter(Mandatory)] [string] $Key, + [Parameter(Mandatory)] [int] $ExpectedCount + ) + + $deadline = [Environment]::TickCount64 + 5000 + $spin = [Threading.SpinWait]::new() + while ([Environment]::TickCount64 -lt $deadline) { + $state = Get-Task7OuterFlightState -Key $Key + if ($state.Exists -and $state.WaiterCount -eq $ExpectedCount) { + return $true + } + $spin.SpinOnce() + } + return $false + } + + function Get-Task7ExactFlightWaiterCount { + param([Parameter(Mandatory)] [object] $Flight) + + return InModuleScope GraphKit -Parameters @{ Flight = $Flight } { + param($Flight) + [int] (Get-GraphTokenFlightWaiterCount -Flight $Flight) + } + } + + function Receive-Task7BoundedJobs { + param( + [Parameter(Mandatory)] [object[]] $Jobs, + [Parameter(Mandatory)] [int] $ExpectedCount + ) + + $completed = @($Jobs | Wait-Job -Timeout 10) + if ($completed.Count -ne $ExpectedCount) { + throw "Task 7 expected $ExpectedCount completed jobs but observed $($completed.Count)." + } + return @($Jobs | Receive-Job -ErrorAction Stop) + } +} + +AfterAll { + if ($null -ne ('GraphKit.Tests.ConcurrentApplicationHarness' -as [type])) { + [GraphKit.Tests.ConcurrentApplicationHarness]::ResetAndDispose() + } + Remove-Module GraphKit -Force -ErrorAction SilentlyContinue } Describe 'GraphTokenSource' { @@ -74,10 +433,218 @@ Describe 'GraphTokenSource' { { $source.Acquire($true, [System.Threading.CancellationToken]::None) } | Should -Throw } } + + It 'fails legacy cross-runspace acquisition quickly instead of hanging before GraphKit.Auth cutover' { + [GraphKit.Tests.ConcurrentApplicationHarness]::Reset() + $ready = [System.Threading.CountdownEvent]::new(2) + $go = [System.Threading.ManualResetEventSlim]::new($false) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ApplicationReady', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ApplicationGo', $go) + + $source = New-ConcurrentHarnessSource + $jobs = $null + try { + $jobs = @($false, $true) | ForEach-Object { + Start-ThreadJob -ThrottleLimit 2 -ScriptBlock { + param($ForceRefresh, $Manifest, $Source) + Import-Module $Manifest + $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ApplicationReady') + $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ApplicationGo') + $null = $ready.Signal() + $null = $go.Wait() + try { + & (Get-Module GraphKit) { + param($TokenSource, [bool] $Refresh) + $null = Send-GraphHttpRequest ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` + -Method GET ` + -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $TokenSource ` + -ForceRefresh:$Refresh + } $Source $ForceRefresh + [pscustomobject] @{ Succeeded = $true; Message = $null } + } + catch { + [pscustomobject] @{ Succeeded = $false; Message = $_.Exception.Message } + } + } -ArgumentList $_, $script:BuiltManifest, $source + } + + $ready.Wait(15000) | Should -BeTrue + $go.Set() + $completed = @(Wait-Job -Job $jobs -Timeout 10) + $completed.Count | Should -Be 2 -Because 'cross-runspace containment must fail, never hang' + $results = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 2 + + [GraphKit.Tests.ConcurrentApplicationHarness]::FactoryCalls | Should -Be 0 + $results.Count | Should -Be 2 + @($results | Where-Object Succeeded).Count | Should -Be 0 + @($results | Where-Object { $_.Message -notmatch 'bound to the runspace.*GraphKit\.Auth' }).Count | + Should -Be 0 + } + finally { + [GraphKit.Tests.ConcurrentApplicationHarness]::Release() + $go.Set() + if ($null -ne $jobs) { + $null = @($jobs | Wait-Job -Timeout 10) + $jobs | Remove-Job -Force -ErrorAction SilentlyContinue + } + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ApplicationReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ApplicationGo', $null) + $ready.Dispose() + $go.Dispose() + } + } + + It 'rejects a wrong-runspace sender before it can wait on an existing token flight' { + [GraphKit.Tests.ConcurrentApplicationHarness]::Reset() + $source = New-ConcurrentHarnessSource + $acquisitionKey = 'wrong-runspace-flight-' + [guid]::NewGuid().ToString('N') + + $seed = InModuleScope GraphKit -Parameters @{ AcquisitionKey = $acquisitionKey } { + param($AcquisitionKey) + $flightKey = Get-GraphTokenFlightKey -AcquisitionKey $AcquisitionKey -ForceRefresh:$false + $flight = [GraphTokenFlight]::new() + [GraphTokenFlightRegistry]::Flights[$flightKey] = $flight + [pscustomobject] @{ Key = $flightKey; Flight = $flight } + } + + $job = $null + try { + $job = Start-ThreadJob -ScriptBlock { + param($Manifest, $Source, $AcquisitionKey) + Import-Module $Manifest + try { + & (Get-Module GraphKit) { + param($TokenSource, $Key) + $null = Send-GraphHttpRequest ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` + -Method GET ` + -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $TokenSource ` + -TokenAcquisitionKey $Key + } $Source $AcquisitionKey + 'unexpected-success' + } + catch { + $_.Exception.Message + } + } -ArgumentList $script:BuiltManifest, $source, $acquisitionKey + + $completed = Wait-Job -Job $job -Timeout 5 + $completed | Should -Not -BeNullOrEmpty -Because 'preflight must run before waiting on a shared flight' + $message = $job | Receive-Job -ErrorAction Stop + + $message | Should -Match 'bound to the runspace.*GraphKit\.Auth' + [GraphKit.Tests.ConcurrentApplicationHarness]::FactoryCalls | Should -Be 0 + $seed.Flight.Completion.Task.IsCompleted | Should -BeFalse + $seed.Flight.PSObject.Properties['WaiterCount'] | + Should -Not -BeNullOrEmpty + Get-Task7ExactFlightWaiterCount -Flight $seed.Flight | Should -Be 0 + + InModuleScope GraphKit -Parameters @{ FlightKey = $seed.Key; Flight = $seed.Flight } { + param($FlightKey, $Flight) + [GraphTokenFlightRegistry]::Flights.ContainsKey($FlightKey) | Should -BeTrue + [object]::ReferenceEquals([GraphTokenFlightRegistry]::Flights[$FlightKey], $Flight) | + Should -BeTrue + } + } + finally { + [GraphKit.Tests.ConcurrentApplicationHarness]::Release() + $null = $seed.Flight.Completion.TrySetResult($null) + InModuleScope GraphKit -Parameters @{ FlightKey = $seed.Key; Flight = $seed.Flight } { + param($FlightKey, $Flight) + $current = [GraphTokenFlight] $null + if ([GraphTokenFlightRegistry]::Flights.TryGetValue($FlightKey, [ref] $current) -and + [object]::ReferenceEquals($current, $Flight)) { + $removed = [GraphTokenFlight] $null + $null = [GraphTokenFlightRegistry]::Flights.TryRemove($FlightKey, [ref] $removed) + } + } + if ($null -ne $job) { + $null = @($job | Wait-Job -Timeout 10) + $job | Remove-Job -Force -ErrorAction SilentlyContinue + } + } + } + + It 'does not poison application initialization when the first factory call fails' { + InModuleScope GraphKit { + $state = @{ Calls = 0 } + $factory = { + $state.Calls++ + if ($state.Calls -eq 1) { + throw 'first-application-build-failed' + } + + $app = [pscustomobject] @{} + $app | Add-Member ScriptMethod AcquireTokenForClient { + param($Scopes) + $null = $Scopes + $builder = [pscustomobject] @{} + $builder | Add-Member ScriptMethod WithForceRefresh { param($Value); $null = $Value; return $this } + $builder | Add-Member ScriptMethod ExecuteAsync { + param($Cancellation) + $null = $Cancellation + $auth = [pscustomobject] @{ + AccessToken = 'retry-application-token' + ExpiresOn = [System.DateTimeOffset]::UtcNow.AddHours(1) + } + $task = [pscustomobject] @{ Auth = $auth } + $task | Add-Member ScriptMethod GetAwaiter { + $awaiter = [pscustomobject] @{ Auth = $this.Auth } + $awaiter | Add-Member ScriptMethod GetResult { return $this.Auth } + return $awaiter + } + return $task + } + return $builder + } + return $app + }.GetNewClosure() + + $source = [ConfidentialClientTokenSource]::new( + $factory, + 'Certificate', + 'https://graph.microsoft.com', + 'client-id', + 'generation' + ) + + { $null = $source.Acquire($false, [System.Threading.CancellationToken]::None) } | + Should -Throw -ExpectedMessage '*first-application-build-failed*' + $source.Acquire($false, [System.Threading.CancellationToken]::None).AccessToken | + Should -Be 'retry-application-token' + $state.Calls | Should -Be 2 + } + } } Context 'New-GraphTokenSource factory' { + BeforeEach { + Mock Get-GraphVaultCredential -ModuleName GraphKit { + param($Credential, $VaultName, $AuthMethod) + $null = $VaultName + switch ($AuthMethod) { + Certificate { + [pscustomobject]@{ Material = [GraphKit.Tests.Task6CredentialFixture]::CreateCertificate(); OwnsMaterial = $true; CredentialGeneration = 'cert-generation' } + } + ClientSecret { + [pscustomobject]@{ Material = [GraphKit.Tests.Task6CredentialFixture]::CreateSecret(); OwnsMaterial = $true; CredentialGeneration = 'secret-generation' } + } + BearerToken { + [pscustomobject]@{ Material = 'fixed-value'; OwnsMaterial = $false; CredentialGeneration = 'bearer-generation' } + } + ManagedIdentity { + [pscustomobject]@{ Material = $null; ManagedIdentityClientId = $Credential.ClientId; OwnsMaterial = $false; CredentialGeneration = 'mi-generation' } + } + } + } + } + It 'builds the correct source per AuthMethod with the right CanRefresh' { InModuleScope GraphKit { $cloud = @{ GraphBaseUri = 'https://graph.microsoft.com'; Authority = 'https://login.microsoftonline.com'; Resource = 'https://graph.microsoft.com' } @@ -90,23 +657,326 @@ Describe 'GraphTokenSource' { $secret.AuthMode | Should -Be 'ClientSecret' $cert = New-GraphTokenSource -Profile @{ - AuthMethod = 'Certificate'; ClientId = $null - Credential = @{ PfxPath = '/tmp/x.pfx'; Password = @{ VaultName = 'v'; SecretName = 'p' } } + AuthMethod = 'Certificate'; ClientId = '7d6e5f44-9999-8888-7777-666655554444' + Credential = @{ VaultName = 'v'; CertificateName = 'cert'; Version = '1' } } -Cloud $cloud -MsalFactory { throw 'not invoked' } $cert.CanRefresh | Should -BeTrue $cert.AuthMode | Should -Be 'Certificate' - $mi = New-GraphTokenSource -Profile @{ AuthMethod = 'ManagedIdentity'; Credential = @{} } -Cloud $cloud + $mi = New-GraphTokenSource -Profile @{ AuthMethod = 'ManagedIdentity'; Credential = @{} } -Cloud $cloud -MsalFactory { throw 'not invoked' } $mi.CanRefresh | Should -BeTrue $mi.AuthMode | Should -Be 'ManagedIdentity' $bearer = New-GraphTokenSource -Profile @{ AuthMethod = 'BearerToken'; Credential = @{ Token = 'fixed-value' } - } -Cloud $cloud + } -Cloud $cloud -MsalFactory { throw 'not invoked' } $bearer.CanRefresh | Should -BeFalse $bearer.AuthMode | Should -Be 'BearerToken' } } + + It 'routes every no-factory built-in through the exact compiled contract' -ForEach @( + @{ Mode = 'Certificate'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; CertificateName = 'cert'; Version = 'v1' } } + @{ Mode = 'ClientSecret'; ClientId = '7d6e5f44-9999-8888-7777-666655554444'; Credential = @{ VaultName = 'v'; SecretName = 'secret'; Version = 'v1' } } + @{ Mode = 'ManagedIdentity'; ClientId = $null; Credential = @{} } + @{ Mode = 'ManagedIdentity'; ClientId = $null; Credential = @{ ClientId = '11111111-2222-3333-4444-555555555555' } } + @{ Mode = 'BearerToken'; ClientId = $null; Credential = @{ VaultName = 'v'; SecretName = 'bearer'; Version = 'v1' } } + ) { + $source = InModuleScope GraphKit -Parameters @{ + Mode = $Mode; ClientId = $ClientId; Credential = $Credential + } { + param($Mode, $ClientId, $Credential) + New-GraphTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $ClientId + AuthMethod = $Mode + Environment = 'Global' + Credential = $Credential + } -Cloud @{ + Name = 'Global' + Authority = [uri]'https://login.microsoftonline.com' + Resource = [uri]'https://graph.microsoft.com' + } + } + + $source -is [GraphKit.Auth.IGraphTokenSource] | Should -BeTrue + $source.AuthMode | Should -BeExactly $Mode + $source.ExpiresOn | Should -Be ([datetimeoffset]::MinValue) -Because 'construction must perform zero acquisition' + } + + } + + Context 'Unsupported persisted credential versions' { + + It 'checks unsupported PFX version metadata before any PFX bytes or vault are touched' { + Mock Get-GraphPfxSnapshot -ModuleName GraphKit { throw 'PFX_BYTES_WERE_TOUCHED' } + Mock Assert-GraphVaultRegistered -ModuleName GraphKit { throw 'VAULT_WAS_TOUCHED' } + + { + InModuleScope GraphKit { + New-GraphTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'Certificate'; Environment = 'Global' + Credential = @{ + PfxPath = 'must-not-open.pfx' + Password = @{ VaultName = 'v'; SecretName = 'password'; Version = 'unsupported-v1' } + } + } -Cloud @{ + Name = 'Global'; Authority = [uri]'https://login.microsoftonline.com'; Resource = [uri]'https://graph.microsoft.com' + } + } + } | Should -Throw -ExpectedMessage '*does not support per-secret versions*' + Should -Invoke Get-GraphPfxSnapshot -ModuleName GraphKit -Times 0 -Exactly + Should -Invoke Assert-GraphVaultRegistered -ModuleName GraphKit -Times 0 -Exactly + } + } + + Context 'Compiled persisted PFX bridge' { + + It 'reads one hashed snapshot and imports the exact same PFX bytes' { + $passwordText = 'task6-pfx-password' + $snapshotBytes = [GraphKit.Tests.Task6PfxFixture]::CreatePfxBytes($passwordText) + $expectedThumbprint = [GraphKit.Tests.Task6PfxFixture]::GetThumbprint( + $snapshotBytes, + $passwordText) + $expectedSha = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($snapshotBytes)).ToLowerInvariant() + $script:Task6CompiledPfxSnapshot = [byte[]]$snapshotBytes.Clone() + + Mock Get-GraphPfxSnapshot -ModuleName GraphKit { + [pscustomobject]@{ + Path = '/task6/credential.pfx' + Bytes = $script:Task6CompiledPfxSnapshot + Sha256 = $expectedSha + } + } + Mock Resolve-GraphVaultPassword -ModuleName GraphKit { + $secret = [Security.SecureString]::new() + foreach ($character in $passwordText.ToCharArray()) { + $secret.AppendChar($character) + } + $secret.MakeReadOnly() + $secret + } + + $source = $null + try { + $source = InModuleScope GraphKit { + New-GraphTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'Certificate'; Environment = 'Global' + Credential = @{ + PfxPath = 'must-not-be-opened-directly.pfx' + Password = @{ VaultName = 'v'; SecretName = 'password' } + } + } -Cloud @{ + Name = 'Global' + Authority = [uri]'https://login.microsoftonline.com' + Resource = [uri]'https://graph.microsoft.com' + } + } + + $inner = [GraphKit.Auth.IGraphTokenSource].Assembly.GetType( + 'GraphKit.Auth.GraphTokenSourceProxy', $true, $false + ).GetField('_inner', [Reflection.BindingFlags]'Instance,NonPublic').GetValue($source) + $credential = $inner.GetType().GetField( + '_credentialReference', [Reflection.BindingFlags]'Instance,NonPublic').GetValue($inner) + + $credential.GetType().FullName | Should -BeExactly 'GraphKit.Auth.CertificateCredential' + $credential.Certificate.Thumbprint | Should -BeExactly $expectedThumbprint + $source.CredentialGeneration | Should -Match ([regex]::Escape("sha256:$expectedSha")) + Should -Invoke Get-GraphPfxSnapshot -ModuleName GraphKit -Times 1 -Exactly + @($script:Task6CompiledPfxSnapshot | Where-Object { $_ -ne 0 }).Count | + Should -Be 0 -Because 'the exact imported snapshot is zeroed after the transfer' + } + finally { + if ($null -ne $source) { $source.Dispose() } + } + } + } + + Context 'Compiled bridge credential ownership failures' { + + It 'cleans an owned client secret exactly once when request construction fails before host entry' { + $script:Task6RequestFailureSecret = [GraphKit.Tests.Task6CredentialFixture]::CreateSecret() + Mock Get-GraphVaultCredential -ModuleName GraphKit { + [pscustomobject]@{ + Material = $script:Task6RequestFailureSecret + OwnsMaterial = $true + CredentialGeneration = 'task6-request-failure-secret' + } + } + + { + InModuleScope GraphKit { + New-GraphAuthTokenSource -Profile @{ + TenantId = 'not-a-guid' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'ClientSecret'; Environment = 'Global' + Credential = @{ VaultName = 'v'; SecretName = 's'; Version = 'v1' } + } -Cloud @{ + Name = 'Global'; Authority = [uri]'https://login.microsoftonline.com'; Resource = [uri]'https://graph.microsoft.com' + } + } + } | Should -Throw + + Test-Task6SecretDisposed -Secret $script:Task6RequestFailureSecret | Should -BeTrue + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 1 -Exactly + } + + It 'cleans an owned certificate exactly once when request construction fails before host entry' { + $script:Task6RequestFailureCertificate = [GraphKit.Tests.Task6CountingCertificate]::Create() + Mock Get-GraphVaultCredential -ModuleName GraphKit { + [pscustomobject]@{ + Material = $script:Task6RequestFailureCertificate + OwnsMaterial = $true + CredentialGeneration = 'task6-request-failure-certificate' + } + } + + { + InModuleScope GraphKit { + New-GraphAuthTokenSource -Profile @{ + TenantId = 'not-a-guid' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'Certificate'; Environment = 'Global' + Credential = @{ VaultName = 'v'; CertificateName = 'c'; Version = 'v1' } + } -Cloud @{ + Name = 'Global'; Authority = [uri]'https://login.microsoftonline.com'; Resource = [uri]'https://graph.microsoft.com' + } + } + } | Should -Throw + + Test-Task6CertificateDisposed -Certificate $script:Task6RequestFailureCertificate | Should -BeTrue + $script:Task6RequestFailureCertificate.DisposeCount | Should -Be 1 + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 1 -Exactly + } + + It 'sanitizes a cleanup failure after credential construction is rejected before host entry' { + $script:Task6BridgeCleanupProbe = [GraphKit.Tests.Task6CleanupProbe]::new($true) + Mock Get-GraphVaultCredential -ModuleName GraphKit { + [pscustomobject]@{ + Material = $script:Task6BridgeCleanupProbe + OwnsMaterial = $true + CredentialGeneration = 'task6-cleanup-failure' + } + } + + $failure = $null + try { + InModuleScope GraphKit { + New-GraphAuthTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'ClientSecret'; Environment = 'Global' + Credential = @{ VaultName = 'v'; SecretName = 's'; Version = 'v1' } + } -Cloud @{ + Name = 'Global'; Authority = [uri]'https://login.microsoftonline.com'; Resource = [uri]'https://graph.microsoft.com' + } + } + } + catch { + $failure = $_.Exception + } + + $failure | Should -Not -BeNullOrEmpty + $failure.GetType().FullName | Should -BeExactly 'GraphKit.Auth.GraphAuthException' + $failure.Code | Should -BeExactly 'credential_material_cleanup_failed' + $failure.Category | Should -BeExactly 'CredentialOwnership' + $failure.Message | Should -BeExactly 'GraphKit.Auth could not clean up credential material after request construction failed before host entry.' + $failure.ToString() | Should -Not -Match ([regex]::Escape([GraphKit.Tests.Task6CleanupProbe]::SensitiveDetail)) + $failure.InnerException | Should -BeNullOrEmpty + $failure.Data.Count | Should -Be 0 + $script:Task6BridgeCleanupProbe.DisposeCount | Should -Be 1 + } + + It 'disposes an owned client secret exactly once when lifecycle registration refuses the returned source' { + $script:Task6RegistrationSecret = [GraphKit.Tests.Task6CredentialFixture]::CreateSecret() + Mock Get-GraphVaultCredential -ModuleName GraphKit { + [pscustomobject]@{ + Material = $script:Task6RegistrationSecret + OwnsMaterial = $true + CredentialGeneration = 'task6-registration-secret' + } + } + Mock Register-GraphModuleOwnedResource -ModuleName GraphKit { throw 'task6-registration-refused' } + + { + InModuleScope GraphKit { + New-GraphAuthTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'ClientSecret'; Environment = 'Global' + Credential = @{ VaultName = 'v'; SecretName = 's'; Version = 'v1' } + } -Cloud @{ + Name = 'Global'; Authority = [uri]'https://login.microsoftonline.com'; Resource = [uri]'https://graph.microsoft.com' + } + } + } | Should -Throw -ExpectedMessage '*task6-registration-refused*' + + Test-Task6SecretDisposed -Secret $script:Task6RegistrationSecret | Should -BeTrue + Should -Invoke Register-GraphModuleOwnedResource -ModuleName GraphKit -Times 1 -Exactly + } + + It 'disposes an owned certificate exactly once when lifecycle registration refuses the returned source' { + $script:Task6RegistrationCertificate = [GraphKit.Tests.Task6CountingCertificate]::Create() + Mock Get-GraphVaultCredential -ModuleName GraphKit { + [pscustomobject]@{ + Material = $script:Task6RegistrationCertificate + OwnsMaterial = $true + CredentialGeneration = 'task6-registration-certificate' + } + } + Mock Register-GraphModuleOwnedResource -ModuleName GraphKit { throw 'task6-registration-refused' } + + { + InModuleScope GraphKit { + New-GraphAuthTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'Certificate'; Environment = 'Global' + Credential = @{ VaultName = 'v'; CertificateName = 'c'; Version = 'v1' } + } -Cloud @{ + Name = 'Global'; Authority = [uri]'https://login.microsoftonline.com'; Resource = [uri]'https://graph.microsoft.com' + } + } + } | Should -Throw -ExpectedMessage '*task6-registration-refused*' + + Test-Task6CertificateDisposed -Certificate $script:Task6RegistrationCertificate | Should -BeTrue + $script:Task6RegistrationCertificate.DisposeCount | Should -Be 1 + Should -Invoke Register-GraphModuleOwnedResource -ModuleName GraphKit -Times 1 -Exactly + } + + It 'never disposes an injected caller-owned certificate when lifecycle registration refuses the returned source' { + $certificate = [GraphKit.Tests.Task6CredentialFixture]::CreateCertificate() + Mock Get-GraphVaultCredential -ModuleName GraphKit { throw 'vault resolution must not run for an injected certificate' } + Mock Register-GraphModuleOwnedResource -ModuleName GraphKit { throw 'task6-registration-refused' } + + try { + { + InModuleScope GraphKit -Parameters @{ InjectedCertificate = $certificate } { + param($InjectedCertificate) + New-GraphAuthTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = '7d6e5f44-9999-8888-7777-666655554444' + AuthMethod = 'Certificate'; Environment = 'Global' + Credential = @{} + } -Cloud @{ + Name = 'Global'; Authority = [uri]'https://login.microsoftonline.com'; Resource = [uri]'https://graph.microsoft.com' + } -Certificate $InjectedCertificate + } + } | Should -Throw -ExpectedMessage '*task6-registration-refused*' + + Test-Task6CertificateDisposed -Certificate $certificate | Should -BeFalse + Should -Invoke Get-GraphVaultCredential -ModuleName GraphKit -Times 0 -Exactly + Should -Invoke Register-GraphModuleOwnedResource -ModuleName GraphKit -Times 1 -Exactly + } + finally { + $certificate.Dispose() + } + } } Context 'Assert-GraphTokenSource duck contract' { @@ -141,8 +1011,7 @@ Describe 'GraphTokenSource' { It 'returns the in-flight result to a concurrent caller without a second acquisition' { InModuleScope GraphKit { $flight = [GraphTokenFlight]::new() - $flight.Result = 'already-acquired' - $flight.Done.Set() + $null = $flight.Completion.TrySetResult('already-acquired') [GraphTokenFlightRegistry]::Flights['seeded-key'] = $flight try { @@ -158,55 +1027,268 @@ Describe 'GraphTokenSource' { } } - It 'collapses N concurrent same-tuple acquires to a single acquisition' { - $key = 'tuple-key' + It 'lets a cancelled waiter leave without cancelling or removing the shared flight' { + InModuleScope GraphKit { + $key = 'cancelled-waiter-key' + $flight = [GraphTokenFlight]::new() + [GraphTokenFlightRegistry]::Flights[$key] = $flight + $cts = [System.Threading.CancellationTokenSource]::new() + $cts.Cancel() - $ready = [System.Threading.CountdownEvent]::new(8) - $go = [System.Threading.ManualResetEventSlim]::new($false) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Ready', $ready) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Go', $go) + try { + $state = @{ calls = 0 } + $message = try { + $null = Invoke-GraphTokenSingleFlight -Key $key -CancellationToken $cts.Token ` + -AcquireScript { $state.calls++; 'should-not-run' } + '' + } + catch { + $_.Exception.Message + } - $jobs = $null - try { - $jobs = 1..8 | ForEach-Object { - Start-ThreadJob -ThrottleLimit 8 -ScriptBlock { - param($key, $manifest) - Import-Module $manifest - $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.Ready') - $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.Go') - $null = $ready.Signal() - $null = $go.Wait() + $message | Should -BeLike '*canceled*' + $state.calls | Should -Be 0 + [GraphTokenFlightRegistry]::Flights.ContainsKey($key) | Should -BeTrue + Get-GraphTokenFlightWaiterCount -Flight $flight | Should -Be 0 + } + finally { + if ($null -ne $flight.PSObject.Properties['Completion']) { + $null = $flight.Completion.TrySetResult('cleanup') + } + elseif ($null -ne $flight.PSObject.Properties['Done']) { + $flight.Done.Set() + } + $removed = [GraphTokenFlight]$null + $null = [GraphTokenFlightRegistry]::Flights.TryRemove($key, [ref]$removed) + $cts.Dispose() + } + } + } + + It 'does not make a live waiter inherit cancellation from the former leader' { + InModuleScope GraphKit { + $key = 'cancelled-leader-key' + $flight = [GraphTokenFlight]::new() + $null = $flight.Completion.TrySetException( + [System.OperationCanceledException]::new('former leader cancelled') + ) + $flight.LeaderCancellationRequested = $true + [GraphTokenFlightRegistry]::Flights[$key] = $flight + + try { + $state = @{ calls = 0 } + $result = Invoke-GraphTokenSingleFlight -Key $key ` + -CancellationToken ([System.Threading.CancellationToken]::None) ` + -AcquireScript { $state.calls++; 'replacement-result' } + + $result | Should -Be 'replacement-result' + $state.calls | Should -Be 1 + } + finally { + $removed = [GraphTokenFlight]$null + $null = [GraphTokenFlightRegistry]::Flights.TryRemove($key, [ref]$removed) + } + } + } + + It 'fans out an unsignalled provider cancellation exception without re-electing' { + InModuleScope GraphKit { + $key = 'provider-oce-key' + $flight = [GraphTokenFlight]::new() + $null = $flight.Completion.TrySetException( + [System.OperationCanceledException]::new('provider timed out internally') + ) + [GraphTokenFlightRegistry]::Flights[$key] = $flight + + try { + $state = @{ calls = 0 } + { + $null = Invoke-GraphTokenSingleFlight -Key $key ` + -CancellationToken ([System.Threading.CancellationToken]::None) ` + -AcquireScript { $state.calls++; 'must-not-re-elect' } + } | Should -Throw -ExpectedMessage '*provider timed out internally*' + + $state.calls | Should -Be 0 + } + finally { + $removed = [GraphTokenFlight]$null + $null = [GraphTokenFlightRegistry]::Flights.TryRemove($key, [ref]$removed) + } + } + } + + It 'adopts a shared forced-refresh result into the follower source cache' { + InModuleScope GraphKit { + $key = 'forced-refresh-cache-adoption-key' + $leaderState = @{ calls = 0 } + $followerState = @{ calls = 0 } + $expiry = [System.DateTimeOffset]::UtcNow.AddHours(1) + + $leader = [ProviderTokenSource]::new({ + $leaderState.calls++ + $token = if ($leaderState.calls -eq 1) { 'leader-old-token' } else { 'shared-fresh-token' } + @{ Token = $token; ExpiresOnUtc = $expiry } + }.GetNewClosure(), 'https://graph.microsoft.com', 'shared-client', 'shared-generation') + $follower = [ProviderTokenSource]::new({ + $followerState.calls++ + @{ Token = 'follower-rejected-token'; ExpiresOnUtc = $expiry } + }.GetNewClosure(), 'https://graph.microsoft.com', 'shared-client', 'shared-generation') + + $delayedOrdinary = $leader.Acquire($false, [System.Threading.CancellationToken]::None) + $null = $follower.Acquire($false, [System.Threading.CancellationToken]::None) + $fresh = $leader.Acquire($true, [System.Threading.CancellationToken]::None) + + $flightKey = Get-GraphTokenFlightKey -AcquisitionKey $key -ForceRefresh:$true + $flight = [GraphTokenFlight]::new() + $null = $flight.Completion.TrySetResult($fresh) + [GraphTokenFlightRegistry]::Flights[$flightKey] = $flight + + try { + { + $null = Send-GraphHttpRequest ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/mutation') ` + -Method POST -Body @{} -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $follower -TokenAcquisitionKey $key -ForceRefresh:$true ` + -TargetTenantId ([guid] '00000000-0000-0000-0000-000000000001') ` + -VerifyTenantBinding -TenantBindingProver { + param($Context, $TokenResult, $CancellationToken) + throw 'cache-adoption-proof-sentinel' + } + } | Should -Throw -ExpectedMessage '*cache-adoption-proof-sentinel*' + + $afterSharedRefresh = $follower.Acquire($false, [System.Threading.CancellationToken]::None) + $afterSharedRefresh.AccessToken | Should -Be 'shared-fresh-token' + $followerState.calls | Should -Be 1 + } + finally { + $removed = [GraphTokenFlight]$null + $null = [GraphTokenFlightRegistry]::Flights.TryRemove($flightKey, [ref]$removed) + } + + # Reproduce the dangerous ordering deterministically: the forced + # result has already been adopted, then an ordinary flight from + # the same clock tick, with a later expiry, reaches sender adoption + # last. Forced-refresh precedence is the only reason it cannot win. + $delayedOrdinary.ReceivedOnUtc = $fresh.ReceivedOnUtc + $delayedOrdinary.ExpiresOnUtc = $fresh.ExpiresOnUtc.AddMinutes(30) + $ordinaryFlightKey = Get-GraphTokenFlightKey -AcquisitionKey $key -ForceRefresh:$false + $ordinaryFlight = [GraphTokenFlight]::new() + $null = $ordinaryFlight.Completion.TrySetResult($delayedOrdinary) + [GraphTokenFlightRegistry]::Flights[$ordinaryFlightKey] = $ordinaryFlight + + try { + { + $null = Send-GraphHttpRequest ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/mutation') ` + -Method POST -Body @{} -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $follower -TokenAcquisitionKey $key -ForceRefresh:$false ` + -TargetTenantId ([guid] '00000000-0000-0000-0000-000000000001') ` + -VerifyTenantBinding -TenantBindingProver { + param($Context, $TokenResult, $CancellationToken) + throw 'late-ordinary-proof-sentinel' + } + } | Should -Throw -ExpectedMessage '*late-ordinary-proof-sentinel*' + } + finally { + $removed = [GraphTokenFlight]$null + $null = [GraphTokenFlightRegistry]::Flights.TryRemove($ordinaryFlightKey, [ref]$removed) + } + + $afterSharedRefresh = $follower.Acquire($false, [System.Threading.CancellationToken]::None) + $afterSharedRefresh.AccessToken | Should -Be 'shared-fresh-token' + $followerState.calls | Should -Be 1 + } + } + + It 'collapses N concurrent same-tuple acquires to a single acquisition' { + $key = 'tuple-key' + $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $ready = [System.Threading.CountdownEvent]::new(8) + $go = [System.Threading.ManualResetEventSlim]::new($false) + $entered = [System.Threading.ManualResetEventSlim]::new($false) + $release = [System.Threading.ManualResetEventSlim]::new($false) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OrdinaryCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Ready', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Go', $go) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OrdinaryEntered', $entered) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OrdinaryRelease', $release) + + $jobs = $null + try { + $jobs = 1..8 | ForEach-Object { + Start-ThreadJob -ThrottleLimit 8 -ScriptBlock { + param($key, $manifest) + Import-Module $manifest + $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.Ready') + $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.Go') + $null = $ready.Signal() + $null = $go.Wait() & (Get-Module GraphKit) { - Invoke-GraphTokenSingleFlight -Key $key -AcquireScript { - Start-Sleep -Milliseconds 400 + param($FlightKey) + Invoke-GraphTokenSingleFlight -Key $FlightKey -AcquireScript { + $calls = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.OrdinaryCalls') + $entered = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.OrdinaryEntered') + $release = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.OrdinaryRelease') + $calls.Enqueue('acquire') + $entered.Set() + $null = $release.Wait() [pscustomobject]@{ Token = [guid]::NewGuid().ToString() } } - } + } $key } -ArgumentList $key, $script:BuiltManifest } - $null = $ready.Wait(15000) + $ready.Wait(15000) | Should -BeTrue $go.Set() + $entered.Wait(5000) | Should -BeTrue + $followerObserved = Wait-Task7OuterFollowerCount -Key $key -ExpectedCount 7 + $beforeRelease = Get-Task7OuterFlightState -Key $key + $release.Set() + $followerObserved | Should -BeTrue + $beforeRelease.Exists | Should -BeTrue + $beforeRelease.WaiterCount | Should -Be 7 - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $results = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 8 + $calls.Count | Should -Be 1 @($results.Token | Sort-Object -Unique).Count | Should -Be 1 + Get-Task7ExactFlightWaiterCount -Flight $beforeRelease.Flight | Should -Be 0 + (Get-Task7OuterFlightState -Key $key).Exists | Should -BeFalse } finally { + $release.Set() + $go.Set() + if ($null -ne $jobs) { + $null = @($jobs | Wait-Job -Timeout 10) + } [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Ready', $null) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Go', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OrdinaryCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OrdinaryEntered', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OrdinaryRelease', $null) if ($null -ne $jobs) { - $jobs | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + $jobs | Remove-Job -Force -ErrorAction SilentlyContinue } + $ready.Dispose() + $go.Dispose() + $entered.Dispose() + $release.Dispose() } } It 'surfaces an acquisition failure to every concurrent waiter' { $key = 'failure-key' - + $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() $ready = [System.Threading.CountdownEvent]::new(8) $go = [System.Threading.ManualResetEventSlim]::new($false) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Ready', $ready) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Go', $go) + $entered = [System.Threading.ManualResetEventSlim]::new($false) + $release = [System.Threading.ManualResetEventSlim]::new($false) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureReady', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureGo', $go) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureEntered', $entered) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureRelease', $release) $jobs = $null try { @@ -214,14 +1296,23 @@ Describe 'GraphTokenSource' { Start-ThreadJob -ThrottleLimit 8 -ScriptBlock { param($key, $manifest) Import-Module $manifest - $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.Ready') - $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.Go') + $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.FailureReady') + $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.FailureGo') $null = $ready.Signal() $null = $go.Wait() try { $null = & (Get-Module GraphKit) { - Invoke-GraphTokenSingleFlight -Key $key -AcquireScript { throw 'acquisition failed' } - } + param($FlightKey) + Invoke-GraphTokenSingleFlight -Key $FlightKey -AcquireScript { + $calls = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.FailureCalls') + $entered = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.FailureEntered') + $release = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.FailureRelease') + $calls.Enqueue('acquire') + $entered.Set() + $null = $release.Wait() + throw 'acquisition failed' + } + } $key 'ok' } catch { @@ -230,23 +1321,744 @@ Describe 'GraphTokenSource' { } -ArgumentList $key, $script:BuiltManifest } - $null = $ready.Wait(15000) + $ready.Wait(15000) | Should -BeTrue $go.Set() + $entered.Wait(5000) | Should -BeTrue + $followerObserved = Wait-Task7OuterFollowerCount -Key $key -ExpectedCount 7 + $beforeRelease = Get-Task7OuterFlightState -Key $key + $release.Set() + $followerObserved | Should -BeTrue + $beforeRelease.WaiterCount | Should -Be 7 - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $results = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 8 + $calls.Count | Should -Be 1 @($results | Where-Object { $_ -ne 'err' }).Count | Should -Be 0 $results.Count | Should -Be 8 + Get-Task7ExactFlightWaiterCount -Flight $beforeRelease.Flight | Should -Be 0 + (Get-Task7OuterFlightState -Key $key).Exists | Should -BeFalse } finally { - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Ready', $null) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.Go', $null) + $release.Set() + $go.Set() + if ($null -ne $jobs) { + $null = @($jobs | Wait-Job -Timeout 10) + } + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureGo', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureEntered', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.FailureRelease', $null) + if ($null -ne $jobs) { + $jobs | Remove-Job -Force -ErrorAction SilentlyContinue + } + $ready.Dispose() + $go.Dispose() + $entered.Dispose() + $release.Dispose() + } + } + + It 'fans one unsignalled provider cancellation exception out to concurrent waiters' { + $key = 'provider-oce-concurrency-key' + $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $ready = [System.Threading.CountdownEvent]::new(8) + $go = [System.Threading.ManualResetEventSlim]::new($false) + $entered = [System.Threading.ManualResetEventSlim]::new($false) + $release = [System.Threading.ManualResetEventSlim]::new($false) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceReady', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceGo', $go) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceEntered', $entered) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceRelease', $release) + + $jobs = $null + try { + $jobs = 1..8 | ForEach-Object { + Start-ThreadJob -ThrottleLimit 8 -ScriptBlock { + param($Key, $Manifest) + Import-Module $Manifest + $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProviderOceReady') + $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProviderOceGo') + $null = $ready.Signal() + $null = $go.Wait() + + try { + $null = & (Get-Module GraphKit) { + param($FlightKey) + Invoke-GraphTokenSingleFlight -Key $FlightKey ` + -CancellationToken ([System.Threading.CancellationToken]::None) ` + -AcquireScript { + $queue = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProviderOceCalls') + $entered = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProviderOceEntered') + $release = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProviderOceRelease') + $queue.Enqueue('acquire') + $entered.Set() + $null = $release.Wait() + throw [System.OperationCanceledException]::new('provider timed out internally') + } + } $Key + 'unexpected-success' + } + catch { + $_.Exception.Message + } + } -ArgumentList $key, $script:BuiltManifest + } + + $ready.Wait(15000) | Should -BeTrue + $go.Set() + $entered.Wait(5000) | Should -BeTrue + $followerObserved = Wait-Task7OuterFollowerCount -Key $key -ExpectedCount 7 + $beforeRelease = Get-Task7OuterFlightState -Key $key + $release.Set() + $followerObserved | Should -BeTrue + $beforeRelease.WaiterCount | Should -Be 7 + $results = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 8 + + $calls.Count | Should -Be 1 + $results.Count | Should -Be 8 + @($results | Where-Object { $_ -notlike '*provider timed out internally*' }).Count | Should -Be 0 + Get-Task7ExactFlightWaiterCount -Flight $beforeRelease.Flight | Should -Be 0 + (Get-Task7OuterFlightState -Key $key).Exists | Should -BeFalse + } + finally { + $release.Set() + $go.Set() + if ($null -ne $jobs) { + $null = @($jobs | Wait-Job -Timeout 10) + } + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceGo', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceEntered', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceRelease', $null) + if ($null -ne $jobs) { + $jobs | Remove-Job -Force -ErrorAction SilentlyContinue + } + $ready.Dispose() + $go.Dispose() + $entered.Dispose() + $release.Dispose() + } + } + + It 're-elects one replacement after an actual leader cancellation' { + $key = 'actual-cancelled-leader-key' + $leaderCts = [System.Threading.CancellationTokenSource]::new() + $leaderStarted = [System.Threading.CountdownEvent]::new(1) + $waitersReady = [System.Threading.CountdownEvent]::new(7) + $waitersGo = [System.Threading.ManualResetEventSlim]::new($false) + $replacementStarted = [System.Threading.CountdownEvent]::new(1) + $releaseReplacement = [System.Threading.ManualResetEventSlim]::new($false) + $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.LeaderCts', $leaderCts) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.LeaderStarted', $leaderStarted) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.WaitersReady', $waitersReady) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.WaitersGo', $waitersGo) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ReplacementStarted', $replacementStarted) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ReleaseReplacement', $releaseReplacement) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.CancelledLeaderCalls', $calls) + + $leaderJob = $null + $waiterJobs = $null + try { + $leaderJob = Start-ThreadJob -ScriptBlock { + param($Key, $Manifest) + Import-Module $Manifest + try { + $null = & (Get-Module GraphKit) { + param($FlightKey) + $cts = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.LeaderCts') + Invoke-GraphTokenSingleFlight -Key $FlightKey -CancellationToken $cts.Token ` + -AcquireScript { + $queue = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.CancelledLeaderCalls') + $started = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.LeaderStarted') + $queue.Enqueue('leader') + $null = $started.Signal() + $null = $cts.Token.WaitHandle.WaitOne() + $cts.Token.ThrowIfCancellationRequested() + }.GetNewClosure() + } $Key + 'unexpected-leader-success' + } + catch { + 'leader-cancelled' + } + } -ArgumentList $key, $script:BuiltManifest + + $leaderStarted.Wait(15000) | Should -BeTrue + InModuleScope GraphKit -Parameters @{ K = $key } { + param($K) + $oldFlight = [GraphTokenFlightRegistry]::Flights[$K] + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OldLeaderFlight', $oldFlight) + } + + $waiterJobs = 1..7 | ForEach-Object { + Start-ThreadJob -ThrottleLimit 8 -ScriptBlock { + param($Key, $Manifest) + Import-Module $Manifest + $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.WaitersReady') + $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.WaitersGo') + $null = $ready.Signal() + $null = $go.Wait() + + & (Get-Module GraphKit) { + param($FlightKey) + Invoke-GraphTokenSingleFlight -Key $FlightKey ` + -CancellationToken ([System.Threading.CancellationToken]::None) ` + -AcquireScript { + $queue = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.CancelledLeaderCalls') + $started = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ReplacementStarted') + $release = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ReleaseReplacement') + $queue.Enqueue('replacement') + $null = $started.Signal() + $null = $release.Wait() + 'replacement-result' + } + } $Key + } -ArgumentList $key, $script:BuiltManifest + } + + $waitersReady.Wait(15000) | Should -BeTrue + $waitersGo.Set() + $oldFollowersObserved = Wait-Task7OuterFollowerCount -Key $key -ExpectedCount 7 + $oldStateBeforeCancel = Get-Task7OuterFlightState -Key $key + $leaderCts.Cancel() + + $replacementStarted.Wait(15000) | Should -BeTrue + $replacementFollowersObserved = Wait-Task7OuterFollowerCount ` + -Key $key -ExpectedCount 6 + $replacementStateBeforeRelease = Get-Task7OuterFlightState -Key $key + $leaderResult = Receive-Task7BoundedJobs -Jobs @($leaderJob) -ExpectedCount 1 + $leaderResult | Should -Contain 'leader-cancelled' + + # The old leader's finally block has now run while the replacement + # is still held open. Its exact-instance cleanup must not remove the + # replacement registered under the same key. + InModuleScope GraphKit -Parameters @{ K = $key } { + param($K) + $oldFlight = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.OldLeaderFlight') + [GraphTokenFlightRegistry]::Flights.ContainsKey($K) | Should -BeTrue + [object]::ReferenceEquals([GraphTokenFlightRegistry]::Flights[$K], $oldFlight) | Should -BeFalse + } + + $releaseReplacement.Set() + $waiterResults = Receive-Task7BoundedJobs -Jobs $waiterJobs -ExpectedCount 7 + $oldFollowersObserved | Should -BeTrue + $oldStateBeforeCancel.WaiterCount | Should -Be 7 + $replacementFollowersObserved | Should -BeTrue + $replacementStateBeforeRelease.WaiterCount | Should -Be 6 + $waiterResults.Count | Should -Be 7 + @($waiterResults | Where-Object { $_ -ne 'replacement-result' }).Count | Should -Be 0 + @($calls | Where-Object { $_ -eq 'leader' }).Count | Should -Be 1 + @($calls | Where-Object { $_ -eq 'replacement' }).Count | Should -Be 1 + Get-Task7ExactFlightWaiterCount -Flight $oldStateBeforeCancel.Flight | Should -Be 0 + Get-Task7ExactFlightWaiterCount -Flight $replacementStateBeforeRelease.Flight | + Should -Be 0 + InModuleScope GraphKit -Parameters @{ K = $key } { + param($K) + [GraphTokenFlightRegistry]::Flights.ContainsKey($K) | Should -BeFalse + } + } + finally { + $releaseReplacement.Set() + $waitersGo.Set() + $leaderCts.Cancel() + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.LeaderCts', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.LeaderStarted', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.WaitersReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.WaitersGo', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ReplacementStarted', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ReleaseReplacement', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.CancelledLeaderCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.OldLeaderFlight', $null) + if ($null -ne $leaderJob) { + $null = @($leaderJob | Wait-Job -Timeout 10) + $leaderJob | Remove-Job -Force -ErrorAction SilentlyContinue + } + if ($null -ne $waiterJobs) { + $null = @($waiterJobs | Wait-Job -Timeout 10) + $waiterJobs | Remove-Job -Force -ErrorAction SilentlyContinue + } + $leaderCts.Dispose() + $leaderStarted.Dispose() + $waitersReady.Dispose() + $waitersGo.Dispose() + $replacementStarted.Dispose() + $releaseReplacement.Dispose() + } + } + + It 'collapses real sender acquisitions across contexts sharing one canonical tuple' { + $key = 'production-sender-tuple-key' + $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $ready = [System.Threading.CountdownEvent]::new(8) + $go = [System.Threading.ManualResetEventSlim]::new($false) + $entered = [System.Threading.ManualResetEventSlim]::new($false) + $release = [System.Threading.ManualResetEventSlim]::new($false) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightReady', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightGo', $go) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightEntered', $entered) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightRelease', $release) + + $jobs = $null + try { + $jobs = 1..8 | ForEach-Object { + Start-ThreadJob -ThrottleLimit 8 -ScriptBlock { + param($Key, $Manifest) + Import-Module $Manifest + $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProductionSingleFlightReady') + $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProductionSingleFlightGo') + $null = $ready.Signal() + $null = $go.Wait() + + & (Get-Module GraphKit) { + param($AcquisitionKey) + $provider = { + $queue = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProductionSingleFlightCalls') + $entered = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProductionSingleFlightEntered') + $release = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ProductionSingleFlightRelease') + $queue.Enqueue('acquire') + $entered.Set() + $null = $release.Wait() + return @{ + Token = 'runtime-single-flight-token' + ExpiresOnUtc = [System.DateTimeOffset]::UtcNow.AddHours(1) + } + } + $source = [ProviderTokenSource]::new( + $provider, 'https://graph.microsoft.com', 'client-id', 'runtime-generation' + ) + $prover = { + param($Context, $TokenResult, $CancellationToken) + throw "proof-sentinel:$($TokenResult.TokenFingerprint)" + } + + try { + $null = Send-GraphHttpRequest ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/mutation') ` + -Method POST -Body @{} -CredentialPolicy GraphBearer ` + -ExpectedAuthority ([uri] 'https://graph.microsoft.com') ` + -TokenSource $source -TokenAcquisitionKey $AcquisitionKey ` + -TargetTenantId ([guid] '00000000-0000-0000-0000-000000000001') ` + -VerifyTenantBinding -TenantBindingProver $prover + return 'unexpected-success' + } + catch { + return $_.Exception.Message + } + } $Key + } -ArgumentList $key, $script:BuiltManifest + } + + $ready.Wait(15000) | Should -BeTrue + $go.Set() + $entered.Wait(5000) | Should -BeTrue + $flightKey = InModuleScope GraphKit -Parameters @{ K = $key } { + param($K) + Get-GraphTokenFlightKey -AcquisitionKey $K -ForceRefresh:$false + } + $followerObserved = Wait-Task7OuterFollowerCount ` + -Key $flightKey -ExpectedCount 7 + $beforeRelease = Get-Task7OuterFlightState -Key $flightKey + $release.Set() + $followerObserved | Should -BeTrue + $beforeRelease.WaiterCount | Should -Be 7 + $results = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 8 + + $calls.Count | Should -Be 1 + $results.Count | Should -Be 8 + @($results | Where-Object { $_ -notlike 'proof-sentinel:*' }).Count | Should -Be 0 + @($results | Sort-Object -Unique).Count | Should -Be 1 + Get-Task7ExactFlightWaiterCount -Flight $beforeRelease.Flight | Should -Be 0 + InModuleScope GraphKit -Parameters @{ K = $key } { + param($K) + $flightKey = Get-GraphTokenFlightKey -AcquisitionKey $K -ForceRefresh:$false + [GraphTokenFlightRegistry]::Flights.ContainsKey($flightKey) | Should -BeFalse + } + } + finally { + $release.Set() + $go.Set() + if ($null -ne $jobs) { + $null = @($jobs | Wait-Job -Timeout 10) + } + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightGo', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightEntered', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightRelease', $null) if ($null -ne $jobs) { - $jobs | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + $jobs | Remove-Job -Force -ErrorAction SilentlyContinue } + $ready.Dispose() + $go.Dispose() + $entered.Dispose() + $release.Dispose() } } } + Context 'Credential generation' { + + It 'is stable for identical PFX bytes and changes when the bytes at the same path change' { + $pfxPath = Join-Path $TestDrive 'generation.pfx' + [System.IO.File]::WriteAllBytes($pfxPath, [byte[]] @(1, 2, 3, 4)) + + $first = InModuleScope GraphKit -Parameters @{ Path = $pfxPath } { + param($Path) + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = $Path + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v1' } + } + } + } + $same = InModuleScope GraphKit -Parameters @{ Path = $pfxPath } { + param($Path) + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = $Path + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v1' } + } + } + } + + [System.IO.File]::WriteAllBytes($pfxPath, [byte[]] @(1, 2, 3, 5)) + $changed = InModuleScope GraphKit -Parameters @{ Path = $pfxPath } { + param($Path) + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = $Path + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v1' } + } + } + } + + $same | Should -Be $first + $changed | Should -Not -Be $first + $first | Should -Match 'sha256:[0-9a-f]{64}' + $first | Should -Not -Match ([regex]::Escape([Convert]::ToBase64String([byte[]] @(1, 2, 3, 4)))) + } + + It 'changes when only the PFX password secret version changes' { + $pfxPath = Join-Path $TestDrive 'password-version.pfx' + [System.IO.File]::WriteAllBytes($pfxPath, [byte[]] @(5, 6, 7, 8)) + + $v1 = InModuleScope GraphKit -Parameters @{ Path = $pfxPath } { + param($Path) + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = $Path + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v1' } + } + } + } + $v2 = InModuleScope GraphKit -Parameters @{ Path = $pfxPath } { + param($Path) + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = $Path + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v2' } + } + } + } + + $v2 | Should -Not -Be $v1 + $v1 | Should -Match '\|2:v1$' + $v2 | Should -Match '\|2:v2$' + } + + It 'zeroes the internal PFX snapshot after deriving its generation' { + $script:GenerationSnapshotProbe = [byte[]] @(9, 8, 7, 6) + Mock Get-GraphPfxSnapshot -ModuleName GraphKit { + [pscustomobject] @{ + Path = '/canonical/test.pfx' + Bytes = $script:GenerationSnapshotProbe + Sha256 = ('b' * 64) + } + } + + $generation = InModuleScope GraphKit { + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = 'relative/test.pfx' + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v1' } + } + } + } + + $generation | Should -Be ( + "g1|Certificate.PFX|19:/canonical/test.pfx|71:sha256:$('b' * 64)|5:vault|8:password|2:v1" + ) + @($script:GenerationSnapshotProbe | Where-Object { $_ -ne 0 }).Count | Should -Be 0 + } + + It 'pins a relative PFX path into the legacy generation selected by a compatibility factory' { + $original = Join-Path $TestDrive 'relative-pfx-origin' + $elsewhere = Join-Path $TestDrive 'relative-pfx-elsewhere' + $captureKey = 'GraphKitTest.CanonicalFactoryPfxPath' + $argumentCountKey = 'GraphKitTest.NoArgumentFactoryArgumentCount' + [System.AppDomain]::CurrentDomain.SetData($captureKey, $null) + [System.AppDomain]::CurrentDomain.SetData($argumentCountKey, $null) + $null = New-Item -ItemType Directory -Path $original, $elsewhere -Force + [System.IO.File]::WriteAllBytes((Join-Path $original 'credential.pfx'), [byte[]] @(1, 3, 3, 7)) + $sources = InModuleScope GraphKit -Parameters @{ + Origin = $original + CaptureKey = $captureKey + ArgumentCountKey = $argumentCountKey + } { + param($Origin, $CaptureKey, $ArgumentCountKey) + Push-Location $Origin + try { + $tenantProfile = @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = 'credential.pfx' + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v1' } + } + } + $cloud = @{ + Resource = 'https://graph.microsoft.com' + Authority = 'https://login.microsoftonline.com' + } + [pscustomobject] @{ + ProfileBound = New-GraphTokenSource -Profile $tenantProfile -Cloud $cloud -MsalFactory { + param($FactoryProfile) + $capturedPath = if ($null -eq $FactoryProfile) { + '' + } + else { + [string] $FactoryProfile.Credential.PfxPath + } + [System.AppDomain]::CurrentDomain.SetData($CaptureKey, $capturedPath) + [pscustomobject] @{ Kind = 'profile-bound-compatibility-factory-fixture' } + }.GetNewClosure() + NoArgument = New-GraphTokenSource -Profile $tenantProfile -Cloud $cloud -MsalFactory { + [System.AppDomain]::CurrentDomain.SetData($ArgumentCountKey, $args.Count) + if ($args.Count -ne 0) { + throw 'The no-argument compatibility factory received an unexpected profile argument.' + } + [pscustomobject] @{ Kind = 'no-argument-compatibility-factory-fixture' } + }.GetNewClosure() + } + } + finally { + Pop-Location + } + } + + Push-Location $elsewhere + try { + $null = $sources.ProfileBound.GetApplication() + $null = $sources.NoArgument.GetApplication() + $sources.ProfileBound.CredentialGeneration | Should -Match '^g1\|Certificate\.PFX\|.+\|71:sha256:[0-9a-f]{64}\|5:vault\|8:password\|2:v1$' + $canonicalPath = [System.IO.Path]::GetFullPath((Join-Path $original 'credential.pfx')) + $sources.ProfileBound.CredentialGeneration | Should -Match ([regex]::Escape($canonicalPath)) + [System.AppDomain]::CurrentDomain.GetData($captureKey) | Should -BeExactly $canonicalPath + [System.AppDomain]::CurrentDomain.GetData($argumentCountKey) | Should -Be 0 + } + finally { + Pop-Location + [System.AppDomain]::CurrentDomain.SetData($captureKey, $null) + [System.AppDomain]::CurrentDomain.SetData($argumentCountKey, $null) + } + } + + It 'changes when a vault-certificate material or password version changes' { + $baseProfile = @{ + AuthMethod = 'Certificate' + Credential = @{ + VaultName = 'vault' + CertificateName = 'certificate' + Version = 'cert-v1' + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'password-v1' } + } + } + + $base = InModuleScope GraphKit -Parameters @{ TenantProfile = $baseProfile } { + param($TenantProfile) + Get-GraphCredentialGeneration -TenantProfile $TenantProfile + } + $passwordChanged = $baseProfile.Clone() + $passwordChanged.Credential = $baseProfile.Credential.Clone() + $passwordChanged.Credential.Password = $baseProfile.Credential.Password.Clone() + $passwordChanged.Credential.Password.Version = 'password-v2' + $passwordGeneration = InModuleScope GraphKit -Parameters @{ TenantProfile = $passwordChanged } { + param($TenantProfile) + Get-GraphCredentialGeneration -TenantProfile $TenantProfile + } + $materialChanged = $baseProfile.Clone() + $materialChanged.Credential = $baseProfile.Credential.Clone() + $materialChanged.Credential.Version = 'cert-v2' + $materialGeneration = InModuleScope GraphKit -Parameters @{ TenantProfile = $materialChanged } { + param($TenantProfile) + Get-GraphCredentialGeneration -TenantProfile $TenantProfile + } + + $passwordGeneration | Should -Not -Be $base + $materialGeneration | Should -Not -Be $base + } + + It 'fails actionably when a persisted PFX cannot be read for identity' { + $missing = Join-Path $TestDrive 'missing.pfx' + + { + InModuleScope GraphKit -Parameters @{ Path = $missing } { + param($Path) + Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + PfxPath = $Path + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'v1' } + } + } + } + } | Should -Throw -ExpectedMessage '*PFX*read*' + } + + It 'isolates mutable vault selectors per context while versioned references still coalesce' { + $cloud = @{ Resource = 'https://graph.microsoft.com'; Authority = 'https://login.microsoftonline.com' } + $unversioned = @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'ClientSecret' + Credential = @{ VaultName = 'vault'; SecretName = 'secret' } + } + $versioned = $unversioned.Clone() + $versioned.Credential = $unversioned.Credential.Clone() + $versioned.Credential.Version = 'immutable-v1' + + $generations = InModuleScope GraphKit -Parameters @{ + Cloud = $cloud + Unversioned = $unversioned + Versioned = $versioned + } { + param($Cloud, $Unversioned, $Versioned) + $factory = { throw 'generation-only test must not acquire' } + [pscustomobject] @{ + UnpinnedA = (New-GraphTokenSource -Profile $Unversioned -Cloud $Cloud -MsalFactory $factory).CredentialGeneration + UnpinnedB = (New-GraphTokenSource -Profile $Unversioned -Cloud $Cloud -MsalFactory $factory).CredentialGeneration + PinnedA = (New-GraphTokenSource -Profile $Versioned -Cloud $Cloud -MsalFactory $factory).CredentialGeneration + PinnedB = (New-GraphTokenSource -Profile $Versioned -Cloud $Cloud -MsalFactory $factory).CredentialGeneration + } + } + + $generations.UnpinnedA | Should -Not -Be $generations.UnpinnedB + $generations.UnpinnedA | Should -Match '\|context:[0-9a-f]{32}$' + $generations.PinnedA | Should -Be $generations.PinnedB + $generations.PinnedA | Should -Be 'g1|ClientSecret|5:vault|6:secret|12:immutable-v1' + } + + It 'does not collide when distinct versioned reference fields contain the old delimiter' { + $generations = InModuleScope GraphKit { + [pscustomobject] @{ + First = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'ClientSecret' + Credential = @{ VaultName = 'a|b'; SecretName = 'c'; Version = 'd' } + } + Second = Get-GraphCredentialGeneration -TenantProfile @{ + AuthMethod = 'ClientSecret' + Credential = @{ VaultName = 'a'; SecretName = 'b'; Version = 'c|d' } + } + } + } + + $generations.First | Should -Not -Be $generations.Second + $generations.First | Should -Be 'g1|ClientSecret|3:a|b|1:c|1:d' + $generations.Second | Should -Be 'g1|ClientSecret|1:a|1:b|3:c|d' + } + + It 'isolates unversioned bearer rotations so old and new tokens cannot share a flight key' { + $script:BearerRotationValues = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $script:BearerRotationValues.Enqueue('old-bearer') + $script:BearerRotationValues.Enqueue('new-bearer') + Mock Get-GraphVaultCredential -ModuleName GraphKit { + $resolved = $null + if (-not $script:BearerRotationValues.TryDequeue([ref] $resolved)) { + throw 'bearer rotation test exhausted its values' + } + [pscustomobject] @{ Material = $resolved } + } + + $result = InModuleScope GraphKit { + $tenantProfile = @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = $null + AuthMethod = 'BearerToken' + Environment = 'Global' + Credential = @{ VaultName = 'vault'; SecretName = 'bearer' } + } + $cloud = @{ + Name = 'Global' + Resource = 'https://graph.microsoft.com' + Authority = 'https://login.microsoftonline.com' + } + $old = New-GraphTokenSource -Profile $tenantProfile -Cloud $cloud + $new = New-GraphTokenSource -Profile $tenantProfile -Cloud $cloud + [pscustomobject] @{ + OldGeneration = $old.CredentialGeneration + NewGeneration = $new.CredentialGeneration + OldToken = $old.Acquire($false, [System.Threading.CancellationToken]::None).AccessToken + NewToken = $new.Acquire($false, [System.Threading.CancellationToken]::None).AccessToken + OldKey = Get-GraphTokenAcquisitionKey -Environment $cloud.Name -TenantId $tenantProfile.TenantId ` + -Authority $cloud.Authority -Resource $cloud.Resource -ClientId $tenantProfile.ClientId ` + -AuthMode BearerToken -Generation $old.CredentialGeneration + NewKey = Get-GraphTokenAcquisitionKey -Environment $cloud.Name -TenantId $tenantProfile.TenantId ` + -Authority $cloud.Authority -Resource $cloud.Resource -ClientId $tenantProfile.ClientId ` + -AuthMode BearerToken -Generation $new.CredentialGeneration + } + } + + $result.OldToken | Should -Be 'old-bearer' + $result.NewToken | Should -Be 'new-bearer' + $result.OldGeneration | Should -Not -Be $result.NewGeneration + $result.OldKey | Should -Not -Be $result.NewKey + } + + It 'treats a subject-only store selector and unversioned vault certificate as mutable' { + $pinned = InModuleScope GraphKit { + [pscustomobject] @{ + SubjectOnly = Test-GraphCredentialReferencePinned -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ StoreLocation = 'CurrentUser'; StoreName = 'My'; Subject = 'CN=example' } + } + Thumbprint = Test-GraphCredentialReferencePinned -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ StoreLocation = 'CurrentUser'; StoreName = 'My'; Thumbprint = 'ABC123' } + } + VaultUnversioned = Test-GraphCredentialReferencePinned -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ VaultName = 'vault'; CertificateName = 'cert' } + } + VaultVersioned = Test-GraphCredentialReferencePinned -TenantProfile @{ + AuthMethod = 'Certificate' + Credential = @{ + VaultName = 'vault' + CertificateName = 'cert' + Version = 'cert-v1' + Password = @{ VaultName = 'vault'; SecretName = 'password'; Version = 'password-v1' } + } + } + } + } + + $pinned.SubjectOnly | Should -BeFalse + $pinned.Thumbprint | Should -BeTrue + $pinned.VaultUnversioned | Should -BeFalse + $pinned.VaultVersioned | Should -BeTrue + } + } + Context 'Canonical tuple normalization' { It 'yields the same key for GUID case, host case and scope order differences' { @@ -284,5 +2096,147 @@ Describe 'GraphTokenSource' { $k1 | Should -Not -Be $k2 } } + + It 'keeps ordinary and forced acquisitions in different in-flight groups' { + InModuleScope GraphKit { + $ordinary = Get-GraphTokenFlightKey -AcquisitionKey 'same-tuple' -ForceRefresh:$false + $forced = Get-GraphTokenFlightKey -AcquisitionKey 'same-tuple' -ForceRefresh:$true + + $ordinary | Should -Not -Be $forced + $ordinary | Should -Not -Match 'True|False' + $forced | Should -Not -Match 'True|False' + } + } + + It 'collapses same-mode callers while ordinary and forced flights remain separate' { + $key = 'mode-partition-key' + $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $entered = [System.Threading.CountdownEvent]::new(2) + $release = [System.Threading.ManualResetEventSlim]::new($false) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeEntered', $entered) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeRelease', $release) + + $workers = [System.Collections.Generic.List[object]]::new() + try { + # Start-ThreadJob shares one process-global throttle. An unrelated + # running job can consume a slot and deadlock a participant-count + # readiness barrier before GraphKit is reached. Prepare dedicated + # runspaces synchronously so this test measures token-flight + # concurrency rather than ambient job-scheduler capacity. + 0..5 | ForEach-Object { + $force = $_ -ge 3 + $runspace = [runspacefactory]::CreateRunspace() + $runspace.ThreadOptions = + [System.Management.Automation.Runspaces.PSThreadOptions]::UseNewThread + $runspace.Open() + + $initializer = [powershell]::Create() + $initializer.Runspace = $runspace + try { + $null = $initializer.AddCommand('Import-Module'). + AddParameter('Name', $script:BuiltManifest). + AddParameter('ErrorAction', 'Stop').Invoke() + } + finally { + $initializer.Dispose() + } + + $pipeline = [powershell]::Create() + $pipeline.Runspace = $runspace + $null = $pipeline.AddScript({ + param($Key, $Force) + & (Get-Module GraphKit) { + param($AcquisitionKey, $ForceRefresh) + $mode = if ($ForceRefresh) { 'refresh' } else { 'ordinary' } + $flightKey = Get-GraphTokenFlightKey ` + -AcquisitionKey $AcquisitionKey -ForceRefresh:$ForceRefresh + Invoke-GraphTokenSingleFlight -Key $flightKey -AcquireScript { + $queue = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ModeCalls') + $entered = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ModeEntered') + $release = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ModeRelease') + $queue.Enqueue($mode) + $null = $entered.Signal() + $null = $release.Wait() + $mode + }.GetNewClosure() + } $Key $Force + }).AddArgument($key).AddArgument($force) + + $workers.Add([pscustomobject] @{ + PowerShell = $pipeline + Runspace = $runspace + Async = $null + Received = $false + }) + } + + foreach ($worker in $workers) { + $worker.Async = $worker.PowerShell.BeginInvoke() + } + + $entered.Wait(5000) | Should -BeTrue + $flightKeys = InModuleScope GraphKit -Parameters @{ K = $key } { + param($K) + [pscustomobject] @{ + Ordinary = Get-GraphTokenFlightKey ` + -AcquisitionKey $K -ForceRefresh:$false + Forced = Get-GraphTokenFlightKey ` + -AcquisitionKey $K -ForceRefresh:$true + } + } + $ordinaryFollowersObserved = Wait-Task7OuterFollowerCount ` + -Key $flightKeys.Ordinary -ExpectedCount 2 + $forcedFollowersObserved = Wait-Task7OuterFollowerCount ` + -Key $flightKeys.Forced -ExpectedCount 2 + $ordinaryBeforeRelease = Get-Task7OuterFlightState -Key $flightKeys.Ordinary + $forcedBeforeRelease = Get-Task7OuterFlightState -Key $flightKeys.Forced + $release.Set() + $ordinaryFollowersObserved | Should -BeTrue + $forcedFollowersObserved | Should -BeTrue + $ordinaryBeforeRelease.WaiterCount | Should -Be 2 + $forcedBeforeRelease.WaiterCount | Should -Be 2 + $ordinaryBeforeRelease.RegistryCount | Should -Be 2 + $forcedBeforeRelease.RegistryCount | Should -Be 2 + $results = @( + foreach ($worker in $workers) { + $worker.Async.AsyncWaitHandle.WaitOne(10000) | + Should -BeTrue -Because 'each dedicated runspace must complete' + $worker.Received = $true + $worker.PowerShell.EndInvoke($worker.Async) + } + ) + + @($calls | Where-Object { $_ -eq 'ordinary' }).Count | Should -Be 1 + @($calls | Where-Object { $_ -eq 'refresh' }).Count | Should -Be 1 + @($results | Where-Object { $_ -eq 'ordinary' }).Count | Should -Be 3 + @($results | Where-Object { $_ -eq 'refresh' }).Count | Should -Be 3 + Get-Task7ExactFlightWaiterCount -Flight $ordinaryBeforeRelease.Flight | Should -Be 0 + Get-Task7ExactFlightWaiterCount -Flight $forcedBeforeRelease.Flight | Should -Be 0 + (Get-Task7OuterFlightState -Key $flightKeys.Ordinary).Exists | Should -BeFalse + (Get-Task7OuterFlightState -Key $flightKeys.Forced).Exists | Should -BeFalse + } + finally { + $release.Set() + foreach ($worker in $workers) { + if ($null -ne $worker.Async -and -not $worker.Received) { + if ($worker.Async.AsyncWaitHandle.WaitOne(10000)) { + try { $null = $worker.PowerShell.EndInvoke($worker.Async) } catch { } + } + else { + try { $worker.PowerShell.Stop() } catch { } + } + } + $worker.PowerShell.Dispose() + $worker.Runspace.Close() + $worker.Runspace.Dispose() + } + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeEntered', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeRelease', $null) + $entered.Dispose() + $release.Dispose() + } + } } } diff --git a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 new file mode 100644 index 0000000..c43b1ff --- /dev/null +++ b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 @@ -0,0 +1,775 @@ +BeforeAll { + $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent + $built = Get-ChildItem -Path (Join-Path $repoRoot 'output/module/GraphKit') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -eq $built) { + throw 'GraphKit is not built. Run ./build.ps1 -Tasks build first, then re-run the tests.' + } + $script:BuiltManifest = Join-Path $built.FullName 'GraphKit.psd1' + Import-Module $script:BuiltManifest -Force -ErrorAction Stop + + if ($null -eq ('GraphKit.Tests.TrackingDisposable' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Concurrent; +using System.Reflection; +using System.Threading; + +namespace GraphKit.Tests +{ + public sealed class TrackingDisposable : IDisposable + { + public const string ContractMarker = "GraphKit.Task7.ModuleLifecycleFixture/1"; + private int _disposeCount; + public int DisposeCount { get { return _disposeCount; } } + public ManualResetEventSlim Disposed { get; } = new ManualResetEventSlim(false); + + public void Dispose() + { + Interlocked.Increment(ref _disposeCount); + Disposed.Set(); + } + } + + public sealed class BlockingCancellationCallback + { + public ManualResetEventSlim Started { get; } = new ManualResetEventSlim(false); + public ManualResetEventSlim Release { get; } = new ManualResetEventSlim(false); + public Action Callback { get { return Invoke; } } + + private void Invoke() + { + Started.Set(); + Release.Wait(); + } + } + + public sealed class ThrowingCancellationCallback + { + private readonly string _message; + + public ThrowingCancellationCallback(string message) + { + _message = message; + } + + public Action Callback { get { return Invoke; } } + + private void Invoke() + { + throw new InvalidOperationException(_message); + } + } + + public sealed class FailureObservingDisposable : IDisposable + { + private readonly object _state; + private int _disposeCount; + private int _failureCountAtDispose = -1; + + public FailureObservingDisposable(object state) + { + _state = state; + } + + public int DisposeCount { get { return Volatile.Read(ref _disposeCount); } } + public int FailureCountAtDispose { get { return Volatile.Read(ref _failureCountAtDispose); } } + + public void Dispose() + { + MethodInfo getFailures = _state.GetType().GetMethod( + "GetFailures", + BindingFlags.Instance | BindingFlags.Public); + Exception[] failures = (Exception[])getFailures.Invoke(_state, null); + Volatile.Write(ref _failureCountAtDispose, failures.Length); + Interlocked.Increment(ref _disposeCount); + } + } + + public sealed class StaleModuleLifecycleState + { + public static string ContractMarker + { + get { return "GraphKit.ModuleLifecycle.RuntimeV0/stale"; } + } + } + + public sealed class BlockingDisposable : IDisposable + { + private int _disposeCount; + public int DisposeCount { get { return _disposeCount; } } + public ManualResetEventSlim Started { get; } = new ManualResetEventSlim(false); + public ManualResetEventSlim Release { get; } = new ManualResetEventSlim(false); + public ManualResetEventSlim Completed { get; } = new ManualResetEventSlim(false); + + public void Dispose() + { + Started.Set(); + Release.Wait(); + Interlocked.Increment(ref _disposeCount); + Completed.Set(); + } + } + + public sealed class OrderedDisposable : IDisposable + { + private readonly string _name; + private readonly ConcurrentQueue _order; + private readonly bool _throws; + private int _disposeCount; + + public OrderedDisposable(string name, ConcurrentQueue order, bool throws) + { + _name = name; + _order = order; + _throws = throws; + } + + public int DisposeCount { get { return Volatile.Read(ref _disposeCount); } } + + public void Dispose() + { + Interlocked.Increment(ref _disposeCount); + _order.Enqueue(_name); + if (_throws) throw new InvalidOperationException("dispose-failed-" + _name); + } + } +} +'@ + } + $trackingType = 'GraphKit.Tests.TrackingDisposable' -as [type] + $trackingMarker = if ($null -ne $trackingType) { + $trackingType.GetField('ContractMarker') + } + else { + $null + } + if ($null -eq $trackingMarker -or + [string] $trackingMarker.GetRawConstantValue() -cne + 'GraphKit.Task7.ModuleLifecycleFixture/1') { + throw ( + 'The process-global GraphModuleLifecycle test fixture is stale. ' + + 'Run this file in a fresh PowerShell process.' + ) + } +} + +Describe 'GraphKit module lifecycle' { + It 'pins the compiled lifecycle coordinator to the expected namespace and ABI surface' { + InModuleScope GraphKit { + $expectedTypeName = 'GraphKit.Internal.RuntimeV1.ModuleLifecycleState' + $expectedMarker = 'GraphKit.ModuleLifecycle.RuntimeV1/2026-08-30.2' + $stateType = $expectedTypeName -as [type] + + $stateType | Should -Not -BeNullOrEmpty + $stateType.FullName | Should -BeExactly $expectedTypeName + $stateType.GetProperty( + 'ContractMarker', + [System.Reflection.BindingFlags]'Public, Static' + ).GetValue($null) | Should -BeExactly $expectedMarker + + { + $null = Assert-GraphModuleLifecycleTypeContract -Type $stateType + } | Should -Not -Throw + + { + $null = Assert-GraphModuleLifecycleTypeContract -Type ([GraphKit.Tests.StaleModuleLifecycleState]) + } | Should -Throw -ExceptionType ([System.InvalidOperationException]) -ExpectedMessage '*ContractMarker*EnterOperation*' + } + } + + It 'waits for an active operation before disposing only GraphKit-owned resources' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $owned = [GraphKit.Tests.TrackingDisposable]::new() + $injected = [GraphKit.Tests.TrackingDisposable]::new() + + InModuleScope GraphKit -Parameters @{ State = $state; Owned = $owned; Injected = $injected } { + param($State, $Owned, $Injected) + $null = Register-GraphModuleOwnedResource -State $State -Resource $Owned -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource -State $State -Resource $Injected -OwnedByGraphKit:$false + $null = Enter-GraphModuleOperation -State $State + } + + $stateKey = 'GraphKitTest.LifecycleState.' + [guid]::NewGuid().ToString('N') + [System.AppDomain]::CurrentDomain.SetData($stateKey, $state) + $stopJob = $null + try { + $stopJob = Start-ThreadJob -ScriptBlock { + param($Manifest, $StateKey) + Import-Module $Manifest -Force -ErrorAction Stop + $sharedState = [System.AppDomain]::CurrentDomain.GetData($StateKey) + & (Get-Module GraphKit) { + param($State) + Stop-GraphModule -State $State -DrainTimeoutMilliseconds 30000 + } $sharedState + } -ArgumentList $script:BuiltManifest, $stateKey + + $state.ShutdownCts.Token.WaitHandle.WaitOne(10000) | Should -BeTrue ` + -Because 'Stop must signal the module lifetime before waiting for the active operation, including on a loaded CI worker' + $stopJob.State | Should -Not -Be 'Completed' -Because 'cleanup must drain the active operation before disposing shared transport resources' + $owned.DisposeCount | Should -Be 0 + $injected.DisposeCount | Should -Be 0 + + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Exit-GraphModuleOperation -State $State + } + + $completedJobs = @($stopJob | Wait-Job -Timeout 10) + $completedJobs.Count | Should -Be 1 -Because 'active-operation release must let module cleanup finish within the bounded liveness timeout' + $null = $stopJob | Receive-Job -ErrorAction Stop + $owned.DisposeCount | Should -Be 1 + $injected.DisposeCount | Should -Be 0 -Because 'caller-injected resources remain caller-owned' + } + finally { + [System.AppDomain]::CurrentDomain.SetData($stateKey, $null) + if ($null -ne $stopJob) { + $stopJob | Remove-Job -Force -ErrorAction SilentlyContinue + } + } + } + + It 'makes cleanup idempotent and refuses new operations after stop' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $owned = [GraphKit.Tests.TrackingDisposable]::new() + + InModuleScope GraphKit -Parameters @{ State = $state; Owned = $owned } { + param($State, $Owned) + $null = Register-GraphModuleOwnedResource -State $State -Resource $Owned -OwnedByGraphKit:$true + Stop-GraphModule -State $State + Stop-GraphModule -State $State + } + + $owned.DisposeCount | Should -Be 1 + $state.WaitForCleanup(0) | Should -BeTrue + $state.WaitForCleanup(0) | Should -BeTrue ` + -Because 'completed cleanup waits must remain safe after lifecycle signals are released' + $shutdownDisposeError = try { + $state.ShutdownCts.Cancel() + $null + } + catch { + $_.Exception + } + $shutdownDisposeError | Should -Not -BeNullOrEmpty + $shutdownDisposeError.GetBaseException() | Should -BeOfType ([System.ObjectDisposedException]) + + $drainedDisposeError = try { + $null = $state.Drained.Wait(0) + $null + } + catch { + $_.Exception + } + $drainedDisposeError | Should -Not -BeNullOrEmpty + $drainedDisposeError.GetBaseException() | Should -BeOfType ([System.ObjectDisposedException]) + { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + $null = Enter-GraphModuleOperation -State $State + } + } | Should -Throw -ExceptionType ([System.ObjectDisposedException]) + } + + It 'leaves ownership with the caller when registration races shutdown' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $owned = [GraphKit.Tests.TrackingDisposable]::new() + + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + + { + InModuleScope GraphKit -Parameters @{ State = $state; Owned = $owned } { + param($State, $Owned) + $null = Register-GraphModuleOwnedResource -State $State -Resource $Owned -OwnedByGraphKit:$true + } + } | Should -Throw -ExceptionType ([System.ObjectDisposedException]) + + $owned.DisposeCount | Should -Be 0 -Because 'a failed registration never accepted ownership' + $owned.Dispose() + $owned.DisposeCount | Should -Be 1 + } + + It 'bounds shutdown and lets the final non-cooperative operation perform deferred cleanup' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $owned = [GraphKit.Tests.TrackingDisposable]::new() + + InModuleScope GraphKit -Parameters @{ State = $state; Owned = $owned } { + param($State, $Owned) + $null = Register-GraphModuleOwnedResource -State $State -Resource $Owned -OwnedByGraphKit:$true + $null = Enter-GraphModuleOperation -State $State + Stop-GraphModule -State $State -DrainTimeoutMilliseconds 25 -WarningAction SilentlyContinue + } + + $state.StopRequested | Should -BeTrue + $state.CleanupDeferred | Should -BeTrue + $state.CleanupComplete | Should -BeFalse + $owned.DisposeCount | Should -Be 0 -Because 'active operations retain every owned resource' + + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Exit-GraphModuleOperation -State $State + } + + $state.WaitForCleanup(5000) | Should -BeTrue + $state.CleanupComplete | Should -BeTrue + $owned.DisposeCount | Should -Be 1 + } + + It 'waits for cancellation callbacks after operations drain before disposing resources' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $blocker = [GraphKit.Tests.BlockingCancellationCallback]::new() + $owned = [GraphKit.Tests.TrackingDisposable]::new() + $registration = $null + try { + $token = InModuleScope GraphKit -Parameters @{ State = $state; Owned = $owned } { + param($State, $Owned) + $null = Register-GraphModuleOwnedResource -State $State -Resource $Owned -OwnedByGraphKit:$true + Enter-GraphModuleOperation -State $State + } + $registration = $token.Register($blocker.Callback) + + $watch = [System.Diagnostics.Stopwatch]::StartNew() + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State -DrainTimeoutMilliseconds 25 -WarningAction SilentlyContinue + } + $watch.Stop() + + $watch.ElapsedMilliseconds | Should -BeLessThan 1000 + $state.StopRequested | Should -BeTrue + $state.CleanupDeferred | Should -BeTrue + $state.CancellationTask | Should -Not -BeNullOrEmpty + $blocker.Started.Wait(5000) | Should -BeTrue + $state.CancellationTask.IsCompleted | Should -BeFalse + + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Exit-GraphModuleOperation -State $State + } + + $state.Drained.IsSet | Should -BeTrue + $state.CleanupStarted | Should -BeFalse + $state.CleanupComplete | Should -BeFalse + $owned.DisposeCount | Should -Be 0 -Because 'cancellation callbacks still have access to operation-owned resources' + + $blocker.Release.Set() + $state.CancellationTask.Wait(5000) | Should -BeTrue + $state.WaitForCleanup(5000) | Should -BeTrue + $state.CleanupComplete | Should -BeTrue + $owned.DisposeCount | Should -Be 1 + } + finally { + $blocker.Release.Set() + if ($null -ne $registration) { + $registration.Dispose() + } + $blocker.Started.Dispose() + $blocker.Release.Dispose() + } + } + + It 'records every fast cancellation callback failure before cleanup can dispose resources' { + foreach ($iteration in 1..128) { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $sentinel = "graphkit-fast-cancellation-$iteration" + $callback = [GraphKit.Tests.ThrowingCancellationCallback]::new($sentinel) + $owned = [GraphKit.Tests.FailureObservingDisposable]::new($state) + $registration = $null + + try { + $token = InModuleScope GraphKit -Parameters @{ State = $state; Owned = $owned } { + param($State, $Owned) + $null = Register-GraphModuleOwnedResource -State $State -Resource $Owned -OwnedByGraphKit:$true + $operationToken = Enter-GraphModuleOperation -State $State + Exit-GraphModuleOperation -State $State + return $operationToken + } + $registration = $token.Register($callback.Callback) + + $stopFailure = $null + try { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + } + catch { + $stopFailure = $_.Exception + } + + $stopFailure | Should -Not -BeNullOrEmpty + $stopFailure.ToString() | Should -Match ([regex]::Escape($sentinel)) + $state.WaitForCleanup(0) | Should -BeTrue + $state.CleanupComplete | Should -BeTrue + $state.CancellationObserved | Should -BeTrue + $owned.DisposeCount | Should -Be 1 + $owned.FailureCountAtDispose | Should -Be 1 -Because 'cleanup must not begin until callback failure recording is complete' + @($state.GetFailures()).Count | Should -Be 1 + } + finally { + if ($null -ne $registration) { + $registration.Dispose() + } + } + } + } + + It 'returns within the stop bound while a disposable blocks and completes cleanup later' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $owned = [GraphKit.Tests.BlockingDisposable]::new() + $stateKey = 'GraphKitTest.BlockingDisposeState.' + [guid]::NewGuid().ToString('N') + [System.AppDomain]::CurrentDomain.SetData($stateKey, $state) + $stopJob = $null + + try { + InModuleScope GraphKit -Parameters @{ State = $state; Owned = $owned } { + param($State, $Owned) + $null = Register-GraphModuleOwnedResource -State $State -Resource $Owned -OwnedByGraphKit:$true + } + + $stopJob = Start-ThreadJob -ScriptBlock { + param($Manifest, $StateKey) + Import-Module $Manifest -Force -ErrorAction Stop + $sharedState = [System.AppDomain]::CurrentDomain.GetData($StateKey) + & (Get-Module GraphKit) { + param($State) + Stop-GraphModule -State $State -DrainTimeoutMilliseconds 25 -WarningAction SilentlyContinue + } $sharedState + } -ArgumentList $script:BuiltManifest, $stateKey + + $owned.Started.Wait(5000) | Should -BeTrue -Because 'cleanup must eventually attempt disposal' + $completedBeforeRelease = $null -ne ($stopJob | Wait-Job -Timeout 10) + + $owned.Release.Set() + $completedJobs = @($stopJob | Wait-Job -Timeout 10) + $completedJobs.Count | Should -Be 1 -Because 'the background dispose must complete after its explicit release gate opens' + $null = $stopJob | Receive-Job -ErrorAction Stop + + $completedBeforeRelease | Should -BeTrue -Because 'blocking Dispose must run outside the bounded module-removal path' + $state.WaitForCleanup(5000) | Should -BeTrue + $state.CleanupComplete | Should -BeTrue + $owned.Completed.IsSet | Should -BeTrue + $owned.DisposeCount | Should -Be 1 + } + finally { + $owned.Release.Set() + [System.AppDomain]::CurrentDomain.SetData($stateKey, $null) + if ($null -ne $stopJob) { + $stopJob | Remove-Job -Force -ErrorAction SilentlyContinue + } + $owned.Started.Dispose() + $owned.Release.Dispose() + $owned.Completed.Dispose() + } + } + + It 'disposes exact host and source probes once in LIFO order and reports an observed failure' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $order = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $hostProbe = [GraphKit.Tests.OrderedDisposable]::new('host', $order, $false) + $source1Probe = [GraphKit.Tests.OrderedDisposable]::new('source1', $order, $true) + $source2Probe = [GraphKit.Tests.OrderedDisposable]::new('source2', $order, $false) + $injected = [GraphKit.Tests.OrderedDisposable]::new('injected', $order, $false) + + InModuleScope GraphKit -Parameters @{ + State = $state + HostProbe = $hostProbe + Source1Probe = $source1Probe + Source2Probe = $source2Probe + Injected = $injected + } { + param($State, $HostProbe, $Source1Probe, $Source2Probe, $Injected) + $null = Register-GraphModuleOwnedResource -State $State -Resource $HostProbe -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource -State $State -Resource $Source1Probe -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource -State $State -Resource $Source2Probe -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource -State $State -Resource $Injected -OwnedByGraphKit:$false + } + + $registered = @($state.OwnedResources) + $registered.Count | Should -Be 3 + [object]::ReferenceEquals($registered[0], $hostProbe) | Should -BeTrue + [object]::ReferenceEquals($registered[1], $source1Probe) | Should -BeTrue + [object]::ReferenceEquals($registered[2], $source2Probe) | Should -BeTrue + + { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + } | Should -Throw -ExceptionType ([System.AggregateException]) -ExpectedMessage '*dispose-failed-source1*' + + $state.WaitForCleanup(0) | Should -BeTrue + $state.CleanupComplete | Should -BeTrue + $state.ActiveOperations | Should -Be 0 + $state.OwnedResources.Count | Should -Be 0 + @($order.ToArray()) | Should -Be @('source2', 'source1', 'host') + $hostProbe.DisposeCount | Should -Be 1 + $source1Probe.DisposeCount | Should -Be 1 + $source2Probe.DisposeCount | Should -Be 1 + $injected.DisposeCount | Should -Be 0 + @($state.GetFailures()).Count | Should -Be 1 + } + + It 'does not clear a process-wide token flight during module-scoped cleanup' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $key = 'module-removal-flight-' + [guid]::NewGuid().ToString('N') + + try { + InModuleScope GraphKit -Parameters @{ State = $state; Key = $key } { + param($State, $Key) + $flight = [GraphTokenFlight]::new() + [GraphTokenFlightRegistry]::Flights[$Key] = $flight + Stop-GraphModule -State $State + + [GraphTokenFlightRegistry]::Flights.ContainsKey($Key) | Should -BeTrue + [object]::ReferenceEquals([GraphTokenFlightRegistry]::Flights[$Key], $flight) | Should -BeTrue + } + } + finally { + InModuleScope GraphKit -Parameters @{ Key = $key } { + param($Key) + $removed = [GraphTokenFlight] $null + $null = [GraphTokenFlightRegistry]::Flights.TryRemove($Key, [ref] $removed) + if ($null -ne $removed) { + $null = $removed.Completion.TrySetResult('test-cleanup') + } + } + } + } + + It 'initializes the real module lifecycle and disposes owned resources on removal' { + $job = Start-ThreadJob -ScriptBlock { + param($Manifest) + + $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop + $state = & $module { + $script:GraphKitModuleLifecycle + } + $owned = [GraphKit.Tests.TrackingDisposable]::new() + + & $module { + param($Resource) + $null = Register-GraphModuleOwnedResource -Resource $Resource -OwnedByGraphKit:$true + } $owned + + $stateType = $state.PSObject.TypeNames[0] + $onRemoveInstalled = $module.OnRemove -is [scriptblock] + $resourceRegistered = + $state.OwnedResources.Count -ge 1 -and + [object]::ReferenceEquals($state.OwnedResources[$state.OwnedResources.Count - 1], $owned) + + $null = Remove-Module -ModuleInfo $module -Force -ErrorAction Stop + + [pscustomobject] @{ + StateType = $stateType + OnRemoveInstalled = $onRemoveInstalled + ResourceRegistered = $resourceRegistered + ModuleRemoved = $null -eq (Get-Module -Name GraphKit) + StopRequested = $state.StopRequested + CleanupComplete = $state.CleanupComplete + ResourceDisposed = $owned.Disposed.Wait(5000) + DisposeCount = $owned.DisposeCount + } + } -ArgumentList $script:BuiltManifest + + try { + $completed = $job | Wait-Job -Timeout 15 + $completed | Should -Not -BeNullOrEmpty -Because 'module removal must remain bounded' + $job.State | Should -Be 'Completed' + + $result = @($job | Receive-Job -ErrorAction Stop) + $result.Count | Should -Be 1 + $result[0].StateType | Should -Be 'GraphKit.ModuleLifecycleState' + $result[0].OnRemoveInstalled | Should -BeTrue + $result[0].ResourceRegistered | Should -BeTrue + $result[0].ModuleRemoved | Should -BeTrue + $result[0].StopRequested | Should -BeTrue + $result[0].CleanupComplete | Should -BeTrue + $result[0].ResourceDisposed | Should -BeTrue + $result[0].DisposeCount | Should -Be 1 + } + finally { + $job | Remove-Job -Force -ErrorAction SilentlyContinue + } + } + + It 'registers the compiled auth host before sources so module cleanup is source-first LIFO' { + $job = Start-ThreadJob -ScriptBlock { + param($Manifest) + $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop + $result = & $module { + $before = @($script:GraphKitModuleLifecycle.OwnedResources) + $source1 = New-GraphAuthTokenSource -Profile @{ + TenantId = '3a4b5c6d-1111-2222-3333-444455556666' + ClientId = $null + AuthMethod = 'BearerToken' + Environment = 'Global' + Credential = @{ Token = 'module-lifecycle-fixed-bearer'; Version = 'fixture-v1' } + } -Cloud @{ + Name = 'Global' + Authority = [uri]'https://login.microsoftonline.com' + Resource = [uri]'https://graph.microsoft.com' + } + $source2 = New-GraphAuthTokenSource -Profile @{ + TenantId = '4b5c6d7e-2222-3333-4444-555566667777' + ClientId = $null + AuthMethod = 'BearerToken' + Environment = 'Global' + Credential = @{ + Token = 'module-lifecycle-fixed-bearer-two' + Version = 'fixture-v2' + } + } -Cloud @{ + Name = 'Global' + Authority = [uri]'https://login.microsoftonline.com' + Resource = [uri]'https://graph.microsoft.com' + } + $resources = @($script:GraphKitModuleLifecycle.OwnedResources) + [pscustomobject]@{ + BeforeCount = $before.Count + BeforeType = $before[0].GetType().FullName + HostReferenceMatches = [object]::ReferenceEquals($before[0], $script:GraphKitAuthHost) + ExactResourceReferences = + $resources.Count -eq 3 -and + [object]::ReferenceEquals($resources[0], $script:GraphKitAuthHost) -and + [object]::ReferenceEquals($resources[1], $source1) -and + [object]::ReferenceEquals($resources[2], $source2) + ResourceTypes = @($resources | ForEach-Object { $_.GetType().FullName }) + State = $script:GraphKitModuleLifecycle + Source1 = $source1 + Source2 = $source2 + } + } + $null = Remove-Module -ModuleInfo $module -Force -ErrorAction Stop + $rejectedCount = 0 + foreach ($source in @($result.Source1, $result.Source2)) { + try { + $null = $source.Acquire($false, [Threading.CancellationToken]::None) + } + catch [ObjectDisposedException] { + $rejectedCount++ + } + } + [pscustomobject]@{ + BeforeCount = $result.BeforeCount + BeforeType = $result.BeforeType + HostReferenceMatches = $result.HostReferenceMatches + ExactResourceReferences = $result.ExactResourceReferences + ResourceTypes = $result.ResourceTypes + SourceRejectedCount = $rejectedCount + CleanupObserved = $result.State.WaitForCleanup(5000) + CleanupComplete = $result.State.CleanupComplete + ActiveOperations = $result.State.ActiveOperations + OwnedResourceCount = $result.State.OwnedResources.Count + FailureCount = @($result.State.GetFailures()).Count + } + } -ArgumentList $script:BuiltManifest + + try { + $job | Wait-Job -Timeout 15 | Should -Not -BeNullOrEmpty + $result = @($job | Receive-Job -ErrorAction Stop) + $result.Count | Should -Be 1 + $result[0].BeforeCount | Should -Be 1 + $result[0].BeforeType | Should -BeExactly 'GraphKit.Auth.GraphAuthHost' + $result[0].HostReferenceMatches | Should -BeTrue + $result[0].ExactResourceReferences | Should -BeTrue + @($result[0].ResourceTypes) | Should -Be @( + 'GraphKit.Auth.GraphAuthHost', + 'GraphKit.Auth.GraphTokenSourceProxy', + 'GraphKit.Auth.GraphTokenSourceProxy' + ) + $result[0].SourceRejectedCount | Should -Be 2 + $result[0].CleanupObserved | Should -BeTrue + $result[0].CleanupComplete | Should -BeTrue + $result[0].ActiveOperations | Should -Be 0 + $result[0].OwnedResourceCount | Should -Be 0 + $result[0].FailureCount | Should -Be 0 + } + finally { + $job | Remove-Job -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'GraphKit HTTP client lifecycle' { + It 'caches by connect timeout and disposes only factory entries marked as owned' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $owned = [System.Net.Http.HttpClient]::new() + $injected = [System.Net.Http.HttpClient]::new() + $calls = [System.Collections.Concurrent.ConcurrentDictionary[string, int]]::new() + + try { + $clients = InModuleScope GraphKit -Parameters @{ + State = $state + Owned = $owned + Injected = $injected + Calls = $calls + } { + param($State, $Owned, $Injected, $Calls) + $factory = { + param([int] $ConnectTimeoutSeconds) + $key = [string] $ConnectTimeoutSeconds + $null = $Calls.AddOrUpdate($key, 1, [Func[string, int, int]] { param($k, $v) $v + 1 }) + if ($ConnectTimeoutSeconds -eq 10) { + return [pscustomobject] @{ Client = $Owned; OwnedByGraphKit = $true } + } + return [pscustomobject] @{ Client = $Injected; OwnedByGraphKit = $false } + }.GetNewClosure() + + @( + (Get-GraphHttpClient -State $State -ConnectTimeoutSeconds 10 -ClientFactory $factory), + (Get-GraphHttpClient -State $State -ConnectTimeoutSeconds 10 -ClientFactory $factory), + (Get-GraphHttpClient -State $State -ConnectTimeoutSeconds 30 -ClientFactory $factory) + ) + } + + [object]::ReferenceEquals($clients[0], $clients[1]) | Should -BeTrue + [object]::ReferenceEquals($clients[0], $clients[2]) | Should -BeFalse + $calls['10'] | Should -Be 1 + $calls['30'] | Should -Be 1 + + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + + $disposeError = try { + $owned.CancelPendingRequests() + $null + } + catch { + $_.Exception + } + $disposeError | Should -Not -BeNullOrEmpty + $disposeError.GetBaseException() | Should -BeOfType ([System.ObjectDisposedException]) + { $injected.CancelPendingRequests() } | Should -Not -Throw + } + finally { + $owned.Dispose() + $injected.Dispose() + } + } +} diff --git a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 index 2f15b49..d27d79d 100644 --- a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 +++ b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 @@ -14,6 +14,9 @@ BeforeAll { return $script:scopeToReturn } Mock Wait-GraphThrottleGate -ModuleName GraphKit { + param($Scope, $CancellationToken, $UtcNow, $UtcNowScript, $DeadlineUtc, $RemainingDeadline) + $script:lastGateDeadlineUtc = $DeadlineUtc + $script:lastGateRemainingDeadline = $RemainingDeadline if ($null -ne $script:throttleWaitScript) { & $script:throttleWaitScript } return $script:admissionToReturn } @@ -74,7 +77,8 @@ BeforeAll { [string] $CredentialPolicy = 'None', [string] $ApiVersion = 'v1.0', [string] $ResourceFamily = 'Test.Family', - [hashtable] $Condition = $null + [hashtable] $Condition = $null, + [string] $IdentityRequirement = 'AllowUnverifiedRead' ) return @{ @@ -84,15 +88,29 @@ BeforeAll { ResourceFamily = $ResourceFamily Condition = $Condition Reconciliation = $null + IdentityRequirement = $IdentityRequirement } } function New-TestSend { return { - param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding) + param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $TokenAcquisitionKey, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding, $TenantBindingContext) $script:sendCount++ $script:lastSendHeaders = $Headers - return $script:results.Dequeue() + $script:lastTokenAcquisitionKey = $TokenAcquisitionKey + $result = $script:results.Dequeue() + + # The injected sender models the real sender's acquisition ownership: + # exactly one acquisition per physical attempt, using the refresh + # decision supplied by the retry engine. + if ($CredentialPolicy -eq 'GraphBearer' -and $null -ne $TokenSource) { + $tokenResult = $TokenSource.Acquire([bool] $ForceRefresh, $CancellationToken) + $result | Add-Member -MemberType NoteProperty -Name VerifiedTenantId -Value $tokenResult.VerifiedTenantId -Force + $result | Add-Member -MemberType NoteProperty -Name TokenFingerprint -Value $tokenResult.TokenFingerprint -Force + $result | Add-Member -MemberType NoteProperty -Name CredentialGeneration -Value $tokenResult.CredentialGeneration -Force + } + + return $result } } @@ -100,18 +118,29 @@ BeforeAll { return @{ Send = (New-TestSend) UtcNow = { $script:clock } - Delay = { param([double] $s) $script:clock = $script:clock.AddSeconds($s); $script:requestedDelays.Add($s) } + Delay = { + [CmdletBinding()] + param([double] $s) + $script:clock = $script:clock.AddSeconds($s) + $script:requestedDelays.Add($s) + } Jitter = { 0.5 } } } function New-TestTokenSource { - param([bool] $CanRefresh = $true, [guid] $VerifiedTenantId = [guid] '00000000-0000-0000-0000-000000000001') + param( + [bool] $CanRefresh = $true, + [guid] $VerifiedTenantId = [guid] '00000000-0000-0000-0000-000000000001', + [AllowNull()] [string] $TokenFingerprint = 'test-token-fingerprint', + [AllowNull()] [string] $CredentialGeneration = 'test-generation' + ) $source = [pscustomobject] @{ CanRefresh = $CanRefresh VerifiedTenantId = $VerifiedTenantId - CredentialGeneration = 'test-generation' + TokenFingerprint = $TokenFingerprint + CredentialGeneration = $CredentialGeneration } # Duck-typed GraphTokenSource: Acquire is a ScriptMethod so the module can @@ -123,7 +152,7 @@ BeforeAll { AccessToken = 'test-token' ExpiresOnUtc = [System.DateTimeOffset]::UtcNow.AddHours(1) VerifiedTenantId = $this.VerifiedTenantId - TokenFingerprint = 'test-token-fingerprint' + TokenFingerprint = $this.TokenFingerprint CredentialGeneration = $this.CredentialGeneration } } -PassThru @@ -144,6 +173,9 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $script:completeCalls = 0 $script:acquireCalls = [System.Collections.Generic.List[bool]]::new() $script:lastSendHeaders = $null + $script:lastTokenAcquisitionKey = $null + $script:lastGateDeadlineUtc = $null + $script:lastGateRemainingDeadline = $null $script:scopeToReturn = @{ CoarseKey = 'Global|tenant|client|Read' LeafKey = 'Global|tenant|client|Test.Family|Read' @@ -193,6 +225,28 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $r.Telemetry[0].DelaySource | Should -Be 'RetryAfterDelta' } + It 'never replays an accepted 202 when its response body fails' { + $accepted = New-TestTransportResult -StatusCode 202 + $accepted.TransportException = [System.IO.IOException]::new('accepted response body closed early') + $script:results.Enqueue($accepted) + $script:results.Enqueue((New-TestTransportResult -StatusCode 200 -Body @{ value = @('replay-must-not-run') })) + + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor -ReplayPolicy Safe), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method POST -Headers @{} -Body @{} ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Succeeded' + $r.Certainty | Should -BeExactly 'Known' + $script:sendCount | Should -Be 1 + ($null -eq $r.Data) | Should -BeTrue + $r.Telemetry | Should -HaveCount 1 + $r.Telemetry[0].StatusCode | Should -Be 202 + $r.Telemetry[0].AttemptOutcome | Should -BeExactly 'Succeeded' + } + It 'does not replay an ambiguous POST and surfaces Failed + Indeterminate' { $script:results.Enqueue((New-TestTransportResult -StatusCode 503)) @@ -242,6 +296,73 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $r.Certainty | Should -Be 'Known' $script:sendCount | Should -Be 2 } + + It 'retries a safe read when a 200 response body fails and returns only the complete retry body' { + $bodyFailure = New-TestTransportResult -StatusCode 200 -Body @{ value = @('partial-must-not-escape') } + $bodyFailure.TransportException = [System.IO.IOException]::new('response body closed early') + $script:results.Enqueue($bodyFailure) + $script:results.Enqueue((New-TestTransportResult -StatusCode 200 -Body @{ value = @('complete') })) + + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor -ReplayPolicy Safe), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Succeeded' + $r.Certainty | Should -BeExactly 'Known' + @($r.Data.value) | Should -Be @('complete') + $script:sendCount | Should -Be 2 + $r.Telemetry | Should -HaveCount 2 + $r.Telemetry[0].AttemptCertainty | Should -BeExactly 'Ambiguous' + $r.Telemetry[0].AttemptOutcome | Should -BeExactly 'Retrying' + } + + It 'retries an unmarked timeout cancellation exception when no operation token is signalled' { + $timeout = New-TestTransportResult -StatusCode 0 -ResponseReceived $false + $timeout.TransportException = [System.Threading.Tasks.TaskCanceledException]::new('header phase timed out') + $script:results.Enqueue($timeout) + $script:results.Enqueue((New-TestTransportResult -StatusCode 200 -Body @{ value = @('complete') })) + + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor -ReplayPolicy Safe), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Succeeded' + @($r.Data.value) | Should -Be @('complete') + $script:sendCount | Should -Be 2 + $r.Telemetry | Should -HaveCount 2 + $r.Telemetry[0].AttemptCertainty | Should -BeExactly 'Ambiguous' + $r.Telemetry[0].AttemptOutcome | Should -BeExactly 'Retrying' + } + + It 'does not call a NeverReplay write successful when its 200 response body fails' { + $bodyFailure = New-TestTransportResult -StatusCode 200 -Body @{ value = @('partial-must-not-escape') } + $bodyFailure.TransportException = [System.IO.IOException]::new('response body closed early') + $script:results.Enqueue($bodyFailure) + + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor -ReplayPolicy NeverReplay), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method POST -Headers @{} -Body @{} ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Failed' + $r.Certainty | Should -BeExactly 'Indeterminate' + @($r.Data).Count | Should -Be 0 + $script:sendCount | Should -Be 1 + $r.Telemetry | Should -HaveCount 1 + $r.Telemetry[0].AttemptCertainty | Should -BeExactly 'Ambiguous' + Should-Invoke Complete-GraphThrottleGate -ModuleName GraphKit -Times 1 -Exactly ` + -ParameterFilter { -not $Success } + Should-Invoke Complete-GraphThrottleGate -ModuleName GraphKit -Times 0 -Exactly ` + -ParameterFilter { $Success } + } } Context 'deadlines and cancellation' { @@ -260,6 +381,162 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $script:sendCount | Should -Be 0 } + It 'turns a marked throttle-gate deadline into a no-send DeadlineExpired envelope' { + $script:throttleWaitScript = { + $script:clock = $script:clock.AddSeconds(5) + $failure = [System.TimeoutException]::new('operation deadline expired in throttle gate') + $failure.Data['GraphKit.OperationDeadlineExpired'] = $true + throw $failure + } + + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) ` + -Injections $Injections -DeadlineSeconds 5 + } + + $r.Outcome | Should -BeExactly 'DeadlineExpired' + $r.Certainty | Should -BeExactly 'Indeterminate' + $script:sendCount | Should -Be 0 + $script:completeCalls | Should -Be 0 + $script:lastGateRemainingDeadline | Should -BeGreaterThan ([TimeSpan]::Zero) + $script:lastGateRemainingDeadline | Should -BeLessOrEqual ([TimeSpan]::FromSeconds(5)) + $script:lastGateDeadlineUtc | Should -Be ([datetime] '2026-01-01T00:00:05Z') + } + + It 'gives caller cancellation precedence over a simultaneous marked throttle deadline' { + $cts = [System.Threading.CancellationTokenSource]::new() + $script:throttleWaitScript = { + $cts.Cancel() + $failure = [System.TimeoutException]::new('simultaneous throttle deadline') + $failure.Data['GraphKit.OperationDeadlineExpired'] = $true + throw $failure + }.GetNewClosure() + + try { + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), (New-TestInjections), $cts.Token { + param($Context, $Descriptor, $Injections, $CancellationToken) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken $CancellationToken -Injections $Injections -DeadlineSeconds 5 + } + + $r.Outcome | Should -BeExactly 'Cancelled' + $r.Certainty | Should -BeExactly 'Indeterminate' + $script:sendCount | Should -Be 0 + $script:completeCalls | Should -Be 0 + } + finally { + $cts.Dispose() + } + } + + It 'returns Cancelled without sending when cancellation is raised inside throttle admission' { + $cts = [System.Threading.CancellationTokenSource]::new() + $script:throttleWaitScript = { + $cts.Cancel() + throw [System.OperationCanceledException]::new('cancelled inside throttle admission') + }.GetNewClosure() + + try { + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), (New-TestInjections), $cts.Token { + param($Context, $Descriptor, $Injections, $CancellationToken) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken $CancellationToken -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Cancelled' + $r.Certainty | Should -BeExactly 'Indeterminate' + $script:sendCount | Should -Be 0 + $script:completeCalls | Should -Be 0 + } + finally { + $cts.Dispose() + } + } + + It 'clamps retry backoff to the remaining deadline and does not start another attempt' { + $script:results.Enqueue((New-TestTransportResult -StatusCode 429 -Headers @{ 'Retry-After' = '30' })) + + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) ` + -Injections $Injections -DeadlineSeconds 5 + } + + $r.Outcome | Should -BeExactly 'DeadlineExpired' + $r.Certainty | Should -BeExactly 'Indeterminate' + $script:sendCount | Should -Be 1 + $script:completeCalls | Should -Be 1 + $script:requestedDelays | Should -HaveCount 1 + $script:requestedDelays[0] | Should -BeGreaterThan 0 + $script:requestedDelays[0] | Should -BeLessOrEqual 5 + } + + It 'passes caller cancellation into retry backoff and preserves Cancelled' { + $cts = [System.Threading.CancellationTokenSource]::new() + $script:results.Enqueue((New-TestTransportResult -StatusCode 429 -Headers @{ 'Retry-After' = '30' })) + $backoffCapture = [pscustomobject] @{ SawCancelableToken = $false } + $injections = New-TestInjections + $injections.Delay = { + param([double] $Seconds, [System.Threading.CancellationToken] $CancellationToken) + $backoffCapture.SawCancelableToken = $CancellationToken.CanBeCanceled + $cts.Cancel() + $CancellationToken.ThrowIfCancellationRequested() + }.GetNewClosure() + + try { + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), $injections, $cts.Token { + param($Context, $Descriptor, $Injections, $CancellationToken) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken $CancellationToken -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Cancelled' + $r.Certainty | Should -BeExactly 'Indeterminate' + $backoffCapture.SawCancelableToken | Should -BeTrue + $script:sendCount | Should -Be 1 + $script:completeCalls | Should -Be 1 + } + finally { + $cts.Dispose() + } + } + + It 'gives caller cancellation precedence over a simultaneous marked proof deadline' { + $cts = [System.Threading.CancellationTokenSource]::new() + $injections = New-TestInjections + $injections.Send = { + param($Uri, $Method, $Headers, $Body, $CancellationToken) + $cts.Cancel() + $failure = [System.TimeoutException]::new('simultaneous proof deadline') + $failure.Data['GraphKit.TenantBindingDeadlineExpired'] = $true + throw $failure + }.GetNewClosure() + + try { + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), $injections, $cts.Token { + param($Context, $Descriptor, $Injections, $CancellationToken) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken $CancellationToken -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Cancelled' + $r.Certainty | Should -BeExactly 'Indeterminate' + $script:completeCalls | Should -Be 1 + } + finally { + $cts.Dispose() + } + } + It 'returns Cancelled for a pre-cancelled token' { $cts = [System.Threading.CancellationTokenSource]::new() $cts.Cancel() @@ -274,6 +551,103 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $r.Outcome | Should -Be 'Cancelled' $script:sendCount | Should -Be 0 } + + It 'returns Cancelled when the caller cancels while waiting inside the sender' { + $cts = [System.Threading.CancellationTokenSource]::new() + $script:cancelDuringSendSource = $cts + $injections = New-TestInjections + $injections.Send = { + param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $TokenAcquisitionKey, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding, $TenantBindingContext) + $script:cancelDuringSendSource.Cancel() + throw [System.OperationCanceledException]::new('single-flight waiter cancelled') + } + + try { + $r = InModuleScope GraphKit -ArgumentList (New-TestContext -TokenSource (New-TestTokenSource)), (New-TestDescriptor -CredentialPolicy GraphBearer), $injections, $cts.Token { + param($Context, $Descriptor, $Injections, $CancellationToken) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken $CancellationToken -Injections $Injections + } + + $r.Outcome | Should -Be 'Cancelled' + $r.Certainty | Should -Be 'Indeterminate' + $script:completeCalls | Should -Be 1 + } + finally { + $cts.Dispose() + $script:cancelDuringSendSource = $null + } + } + + It 'turns a normalized operation cancellation into a no-data Cancelled envelope' { + $failure = [System.OperationCanceledException]::new('module lifetime ended during the send') + $failure.Data['GraphKit.OperationCancellation'] = $true + $transportResult = New-TestTransportResult -StatusCode 200 -Body @{ value = @('must-not-escape') } + $transportResult.TransportException = $failure + $script:results.Enqueue($transportResult) + + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Cancelled' + $r.Certainty | Should -BeExactly 'Indeterminate' + @($r.Data).Count | Should -Be 0 -Because 'a successful-looking body must not escape a marked cancellation' + @($r.Telemetry).Count | Should -Be 0 -Because 'cancellation must win before success telemetry is recorded' + $script:sendCount | Should -Be 1 + $script:completeCalls | Should -Be 1 + Should-Invoke Complete-GraphThrottleGate -ModuleName GraphKit -Times 1 -Exactly ` + -ParameterFilter { -not $Success } + Should-Invoke Complete-GraphThrottleGate -ModuleName GraphKit -Times 0 -Exactly ` + -ParameterFilter { $Success } + } + + It 'rejects a clean success when the caller is cancelled immediately before the sender returns' { + $cts = [System.Threading.CancellationTokenSource]::new() + $capture = [pscustomobject] @{ SendCount = 0 } + $injections = New-TestInjections + $injections.Send = { + param($Uri, $Method, $Headers, $Body, $CancellationToken) + $capture.SendCount++ + $cts.Cancel() + return [pscustomobject] @{ + StatusCode = 200 + Headers = @{} + Body = @{ value = @('must-not-escape') } + RequestId = $null + TransportException = $null + ResponseReceived = $true + } + }.GetNewClosure() + + try { + $r = InModuleScope GraphKit -ArgumentList (New-TestContext), (New-TestDescriptor), $injections, $cts.Token { + param($Context, $Descriptor, $Injections, $CancellationToken) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken $CancellationToken -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Cancelled' + $r.Certainty | Should -BeExactly 'Indeterminate' + $cts.IsCancellationRequested | Should -BeTrue + @($r.Data).Count | Should -Be 0 -Because 'a clean response returned after cancellation must not become operation data' + @($r.Telemetry).Count | Should -Be 0 -Because 'cancellation must win before success telemetry is recorded' + $capture.SendCount | Should -Be 1 + $script:completeCalls | Should -Be 1 + Should-Invoke Complete-GraphThrottleGate -ModuleName GraphKit -Times 1 -Exactly ` + -ParameterFilter { -not $Success } + Should-Invoke Complete-GraphThrottleGate -ModuleName GraphKit -Times 0 -Exactly ` + -ParameterFilter { $Success } + } + finally { + $cts.Dispose() + } + } } Context 'attempt accounting' { @@ -345,7 +719,7 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $r.PSObject.TypeNames | Should -Contain 'GraphKit.OperationResult' $names = @($r.PSObject.Properties.Name) - foreach ($f in @('Data', 'Outcome', 'Certainty', 'Telemetry', 'Provenance')) { + foreach ($f in @('Data', 'Outcome', 'Certainty', 'Truncated', 'Telemetry', 'Provenance')) { $names | Should -Contain $f } @@ -356,6 +730,7 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $r.Data | Should -Not -BeNullOrEmpty $r.Outcome | Should -Be 'Succeeded' + $r.Truncated | Should -BeFalse } It 'adds a client-request-id header on every attempt' { @@ -370,6 +745,66 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $script:lastSendHeaders.ContainsKey('client-request-id') | Should -BeTrue } + + It 'forwards the context acquisition key to the real sender contract' { + $script:results.Enqueue((New-TestTransportResult -StatusCode 200 -Body @{ value = @() })) + $tokenSource = New-TestTokenSource + + $null = InModuleScope GraphKit -ArgumentList (New-TestContext -TokenSource $tokenSource), (New-TestDescriptor -CredentialPolicy GraphBearer), (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + + $script:lastTokenAcquisitionKey | Should -Be 'test-acquisition-cache-key' + } + + It 'pins exact token and cloud identity into verified provenance' { + $script:results.Enqueue((New-TestTransportResult -StatusCode 200 -Body @{ value = @() })) + $tokenSource = New-TestTokenSource + + $r = InModuleScope GraphKit -ArgumentList ` + (New-TestContext -TokenSource $tokenSource -IdentityState NotAcquired), ` + (New-TestDescriptor -CredentialPolicy GraphBearer -IdentityRequirement Verified), ` + (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + + $r.Outcome | Should -BeExactly 'Succeeded' + $r.Provenance.IdentityState | Should -BeExactly 'VerifiedForToken' + $r.Provenance.TokenFingerprint | Should -BeExactly 'test-token-fingerprint' + $r.Provenance.CredentialGeneration | Should -BeExactly 'test-generation' + $r.Provenance.Cloud | Should -BeExactly 'Global' + $r.Provenance.Keys | Should -Not -Contain 'ClientId' + $r.Provenance.Keys | Should -Not -Contain 'ClientScopeFingerprint' + } + + It 'rejects verified transport provenance with ' -ForEach @( + @{ Case = 'a blank token fingerprint'; Token = ' '; Generation = 'test-generation' } + @{ Case = 'a blank credential generation'; Token = 'test-token-fingerprint'; Generation = "`t" } + ) { + $script:results.Enqueue((New-TestTransportResult -StatusCode 200 -Body @{ value = @('must-not-escape') })) + $tokenSource = New-TestTokenSource -TokenFingerprint $Token -CredentialGeneration $Generation + + { + InModuleScope GraphKit -ArgumentList ` + (New-TestContext -TokenSource $tokenSource -IdentityState NotAcquired), ` + (New-TestDescriptor -CredentialPolicy GraphBearer -IdentityRequirement Verified), ` + (New-TestInjections) { + param($Context, $Descriptor, $Injections) + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor ` + -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') -Method GET -Headers @{} -Body $null ` + -CancellationToken ([System.Threading.CancellationToken]::None) -Injections $Injections + } + } | Should -Throw -ExpectedMessage '*non-empty TokenFingerprint and CredentialGeneration*' + + $script:sendCount | Should -Be 1 + $script:completeCalls | Should -Be 1 -Because 'the attempt admission must still be released' + } } } }