From 7ef409aa8ac1577896a727a6eaac728aed5dd269 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 18:06:44 -0400 Subject: [PATCH 01/79] Fix exact-token tenant proof and refresh --- CHANGELOG.md | 11 + source/Private/Confirm-GraphTenantBinding.ps1 | 59 ++- source/Private/Invoke-GraphRetry.ps1 | 34 +- .../Private/TokenSources/GraphTokenSource.ps1 | 6 +- .../Transport/GraphTransportResult.ps1 | 8 + .../Transport/Send-GraphHttpRequest.ps1 | 34 +- tests/Adapter/TokenIdentityPipeline.Tests.ps1 | 368 ++++++++++++++++++ tests/Concurrency/TokenIsolation.Tests.ps1 | 95 ++++- .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 74 ++++ tests/Unit/Auth/MsalGuard.Tests.ps1 | 14 + .../Transport/Invoke-GraphRetry.Tests.ps1 | 16 +- 11 files changed, 683 insertions(+), 36 deletions(-) create mode 100644 tests/Adapter/TokenIdentityPipeline.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf6bed..edaba2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- 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. + ## [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/source/Private/Confirm-GraphTenantBinding.ps1 b/source/Private/Confirm-GraphTenantBinding.ps1 index a3ce105..17c349d 100644 --- a/source/Private/Confirm-GraphTenantBinding.ps1 +++ b/source/Private/Confirm-GraphTenantBinding.ps1 @@ -62,6 +62,41 @@ function Test-GraphTenantBinding { return ($script:GraphTenantBindingCache.ContainsKey($key) -and $script:GraphTenantBindingCache[$key] -eq $true) } +<# + 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,6 +106,8 @@ function Confirm-GraphTenantBinding { [Parameter(Mandatory = $true)] [object] $TokenResult, + [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None, + [scriptblock] $ProofTransport, [hashtable] $ProofCache @@ -114,13 +151,29 @@ function Confirm-GraphTenantBinding { $transport = $ProofTransport if ($null -eq $transport) { $transport = { - param($Context, $Descriptor, $Uri) + param($Context, $Descriptor, $Uri, $CancellationToken) Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri $Uri -Method GET ` - -Headers @{} -Body $null -CancellationToken ([System.Threading.CancellationToken]::None) + -Headers @{} -Body $null -CancellationToken $CancellationToken } } - $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. + $proofContext = [pscustomobject] @{ + ProfileId = 'tenant-proof' + TenantId = $targetTenant + Cloud = 'TenantProof' + GraphBaseUri = $Context.GraphBaseUri + ClientId = $null + TokenSource = New-GraphPinnedTokenSource -TokenResult $TokenResult + CredentialFingerprint = [string] $TokenResult.TokenFingerprint + AcquisitionCacheKey = "tenant-proof|$cacheKey" + IdentityState = 'NotAcquired' + } + + $envelope = & $transport -Context $proofContext -Descriptor $proofDescriptor -Uri $proofUri ` + -CancellationToken $CancellationToken if ($null -eq $envelope -or $envelope.Outcome -ne 'Succeeded') { throw ( diff --git a/source/Private/Invoke-GraphRetry.ps1 b/source/Private/Invoke-GraphRetry.ps1 index d9b3393..f59b9d7 100644 --- a/source/Private/Invoke-GraphRetry.ps1 +++ b/source/Private/Invoke-GraphRetry.ps1 @@ -233,22 +233,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,6 +287,7 @@ function Invoke-GraphRetry { if ($credentialPolicy -eq 'GraphBearer') { $sendParams.TokenSource = $Context.TokenSource + $sendParams.ForceRefresh = $forceRefreshPending $sendParams.ExpectedAuthority = $Context.GraphBaseUri $sendParams.TargetTenantId = $Context.TenantId if ($isMutating) { @@ -313,6 +298,17 @@ function Invoke-GraphRetry { # ---- One attempt = exactly one send ---- $result = & $send @sendParams + if ($forceRefreshPending) { + $forceRefreshPending = $false + $forceRefreshUsed = $true + } + + $attemptVerifiedTenantId = $null + if (-not [string]::IsNullOrEmpty([string] $result.VerifiedTenantId) -and + [string]::Equals([string] $result.VerifiedTenantId, [string] $Context.TenantId, [System.StringComparison]::OrdinalIgnoreCase)) { + $attemptVerifiedTenantId = $Context.TenantId + } + # ---- Runtime certainty, then release admission ---- # Complete-GraphThrottleGate's -Success switch drives additive-increase # (AIMD restore); without it a qualified throttle never recovers. @@ -419,6 +415,10 @@ function Invoke-GraphRetry { 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 $outcome = $decision.Outcome $certaintyFinal = $decision.Certainty if ($decision.Outcome -eq 'Succeeded') { @@ -439,7 +439,7 @@ 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 } diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index 87d9b0c..faa7032 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -122,7 +122,8 @@ class ConfidentialClientTokenSource : GraphTokenSourceBase { $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 @@ -166,7 +167,8 @@ class ManagedIdentityTokenSource : GraphTokenSourceBase { $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 diff --git a/source/Private/Transport/GraphTransportResult.ps1 b/source/Private/Transport/GraphTransportResult.ps1 index 3d60b35..4e439e3 100644 --- a/source/Private/Transport/GraphTransportResult.ps1 +++ b/source/Private/Transport/GraphTransportResult.ps1 @@ -16,6 +16,11 @@ TransportException The exception for transport-level failures (timeout, reset, cancellation); $null on a clean response. 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 +29,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..090f76a 100644 --- a/source/Private/Transport/Send-GraphHttpRequest.ps1 +++ b/source/Private/Transport/Send-GraphHttpRequest.ps1 @@ -84,6 +84,8 @@ function Send-GraphHttpRequest { [object] $TokenSource, + [bool] $ForceRefresh = $false, + [ValidateSet('GraphBearer', 'None')] [string] $CredentialPolicy = 'None', @@ -102,6 +104,9 @@ function Send-GraphHttpRequest { $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') { @@ -179,7 +184,11 @@ 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. + $tokenResult = $TokenSource.Acquire($ForceRefresh, $CancellationToken) if ($null -eq $tokenResult) { throw 'GraphBearer credential policy: token source returned no token.' } @@ -214,10 +223,14 @@ function Send-GraphHttpRequest { $prover = $TenantBindingProver if ($null -eq $prover) { - $prover = { param($Context, $TokenResult) Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult } + $prover = { + param($Context, $TokenResult, $CancellationToken) + Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` + -CancellationToken $CancellationToken + } } - & $prover -Context $proofContext -TokenResult $tokenResult + & $prover -Context $proofContext -TokenResult $tokenResult -CancellationToken $CancellationToken } if ($null -eq $tokenResult -or @@ -231,6 +244,21 @@ 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 } # ---- Send (one attempt = exactly one physical send) ---- diff --git a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 new file mode 100644 index 0000000..dc4b6a9 --- /dev/null +++ b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 @@ -0,0 +1,368 @@ +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 (-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' + $script:openServers = [System.Collections.Generic.List[object]]::new() + + function Get-TokenPipelineFreePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + $listener.Stop() + return $port + } + + function Start-TokenPipelineServer { + param( + [int] $Port, + [object[]] $Responses + ) + + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:$Port/") + $listener.Start() + + $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 + } + $script:openServers.Add($server) + return $server + } + + function Stop-TokenPipelineServer { + param($Server) + + if ($null -eq $Server) { return @() } + + $captured = @() + 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.Listener) { + try { $Server.Listener.Stop() } catch { } + try { $Server.Listener.Close() } catch { } + } + 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 + ) + + return [pscustomobject] @{ + ProfileId = 'token-identity-test' + TenantId = $script:TenantId + Cloud = 'Global' + GraphBaseUri = $Authority + ClientId = 'client-id' + TokenSource = $TokenSource + CredentialFingerprint = 'credential-fingerprint' + AcquisitionCacheKey = 'token-identity-acquisition-key' + IdentityState = 'NotAcquired' + } + } + + function New-TokenPipelineDescriptor { + param( + [string] $ReplayPolicy = 'Safe', + [string] $ThrottleClass = 'Read' + ) + + return @{ + CredentialPolicy = 'GraphBearer' + ReplayPolicy = $ReplayPolicy + ThrottleClass = $ThrottleClass + ResourceFamily = 'Graph.Test' + ApiVersion = 'v1.0' + Condition = $null + Reconciliation = $null + } + } +} + +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 'acquires exactly once for one ordinary Graph attempt' { + $port = Get-TokenPipelineFreePort + $server = Start-TokenPipelineServer -Port $port -Responses @( + @{ StatusCode = 204; Body = $null } + ) + $authority = [uri] "http://127.0.0.1:$port" + $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 'uses false then true acquisition flags across one 401 refresh' { + $port = Get-TokenPipelineFreePort + $server = Start-TokenPipelineServer -Port $port -Responses @( + @{ StatusCode = 401; Body = '{"error":{"code":"InvalidAuthenticationToken"}}' } + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = [uri] "http://127.0.0.1:$port" + $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' { + $port = Get-TokenPipelineFreePort + $server = Start-TokenPipelineServer -Port $port -Responses @( + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = [uri] "http://127.0.0.1:$port" + $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' { + $port = Get-TokenPipelineFreePort + $server = Start-TokenPipelineServer -Port $port -Responses @( + @{ StatusCode = 401; Body = '{"error":{"code":"InvalidAuthenticationToken"}}' } + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = [uri] "http://127.0.0.1:$port" + $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 { + $message = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor -ReplayPolicy NeverReplay -ThrottleClass Write), $authority, $cts.Token { + param($Context, $Descriptor, $Authority, $CancellationToken) + try { + Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'mutation')) ` + -Method POST -Headers @{} -Body @{ value = 'x' } -CancellationToken $CancellationToken + return '' + } + catch { + return $_.Exception.Message + } + } + + $listener.Pending() | Should -BeFalse + $tokenSource.AcquireFlags.Count | Should -Be 1 + $tokenSource.LastResult.VerifiedTenantId | Should -BeNullOrEmpty + (InModuleScope GraphKit { $script:GraphTenantBindingCache.Count }) | Should -Be 0 + $message | Should -BeLike '*Tenant proof failed*' + } + finally { + $listener.Stop() + $cts.Dispose() + } + } + + It 'proves and sends a mutation with the same exact token' { + $port = Get-TokenPipelineFreePort + $tenantBody = '{"value":[{"id":"' + $script:TenantId.ToString() + '"}]}' + $server = Start-TokenPipelineServer -Port $port -Responses @( + @{ StatusCode = 200; Body = $tenantBody } + @{ StatusCode = 204; Body = $null } + ) + $authority = [uri] "http://127.0.0.1:$port" + $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' + } +} diff --git a/tests/Concurrency/TokenIsolation.Tests.ps1 b/tests/Concurrency/TokenIsolation.Tests.ps1 index c5cde40..6e800d8 100644 --- a/tests/Concurrency/TokenIsolation.Tests.ps1 +++ b/tests/Concurrency/TokenIsolation.Tests.ps1 @@ -34,19 +34,30 @@ 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, [int] $DelayMs = 0, $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 + DelayMs = $DelayMs + 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 + DelayMs = $this.DelayMs + 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) @@ -75,10 +86,55 @@ 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, + [int] $DelayMs = 0, + $ForceRefreshFlags = ([System.Collections.Concurrent.ConcurrentQueue[bool]]::new()) + ) + InModuleScope GraphKit -Parameters @{ T = $Tenant; C = $Counter; D = $DelayMs; Q = $ForceRefreshFlags; F = $script:SourceFactoryScript } { + param($T, $C, $D, $Q, $F) + & $F $T $C $D $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 +182,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/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index c924dbb..321d295 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -233,6 +233,37 @@ Describe 'Confirm-GraphTenantBinding' { $script:proofCall.Descriptor.IdentityRequirement | Should -Be 'Verified' $script:proofCall.Descriptor.Keys | Should -Not -Contain 'VerifyTenantBinding' } + + It 'forwards the caller cancellation token into the proof retry pipeline' { + $cache = @{} + $script:proofCancellationToken = [System.Threading.CancellationToken]::None + $cts = [System.Threading.CancellationTokenSource]::new() + $cts.Cancel() + + Mock Invoke-GraphRetry -ModuleName GraphKit { + param($Context, $Descriptor, $Uri, $Method, $Headers, $Body, $CancellationToken) + $script:proofCancellationToken = $CancellationToken + return [pscustomobject] @{ + Outcome = 'Cancelled' + Data = $null + } + } + + $message = 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 '' + } + catch { + return $_.Exception.Message + } + } + + $script:proofCancellationToken.IsCancellationRequested | Should -BeTrue + $message | Should -BeLike '*Tenant proof failed*' + } } } @@ -269,6 +300,49 @@ 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" 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/Transport/Invoke-GraphRetry.Tests.ps1 b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 index 2f15b49..fe1f4a7 100644 --- a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 +++ b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 @@ -89,10 +89,22 @@ BeforeAll { function New-TestSend { return { - param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding) + param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding) $script:sendCount++ $script:lastSendHeaders = $Headers - return $script:results.Dequeue() + $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 } } From 67ab1d154fafe3c1961de14de73c61d11c60b91e Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 18:44:45 -0400 Subject: [PATCH 02/79] Wire production token single-flight --- CHANGELOG.md | 5 + source/Private/Invoke-GraphRetry.ps1 | 23 + .../Private/TokenSources/GraphTokenSource.ps1 | 294 ++++++++-- .../Transport/Send-GraphHttpRequest.ps1 | 34 +- .../TokenSources/GraphTokenSource.Tests.ps1 | 527 +++++++++++++++++- .../Transport/Invoke-GraphRetry.Tests.ps1 | 46 +- 6 files changed, 867 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edaba2b..f71a3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ## [0.3.0] - 2026-08-30 diff --git a/source/Private/Invoke-GraphRetry.ps1 b/source/Private/Invoke-GraphRetry.ps1 index f59b9d7..e82f374 100644 --- a/source/Private/Invoke-GraphRetry.ps1 +++ b/source/Private/Invoke-GraphRetry.ps1 @@ -288,6 +288,7 @@ 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) { @@ -319,10 +320,32 @@ function Invoke-GraphRetry { $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 + 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 } diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index faa7032..34fc8f3 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -38,6 +38,12 @@ class GraphTokenSourceBase { [string] $CredentialGeneration hidden [GraphTokenResult] $CachedResult + hidden [bool] $CachedResultWasForceRefresh + hidden [object] $CacheLock + + GraphTokenSourceBase() { + $this.CacheLock = [object]::new() + } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { throw [System.NotImplementedException]::new('GraphTokenSourceBase.Acquire must be overridden by a concrete token source.') @@ -73,25 +79,90 @@ 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) { + 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) } } @@ -116,8 +187,11 @@ class ConfidentialClientTokenSource : GraphTokenSourceBase { } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { - if (-not $forceRefresh -and $this.HasValidCachedToken()) { - return $this.CachedResult + if (-not $forceRefresh) { + $cached = $this.GetValidCachedToken() + if ($null -ne $cached) { + return $cached + } } $app = $this.GetApplication() @@ -135,7 +209,7 @@ class ConfidentialClientTokenSource : GraphTokenSourceBase { $result.TokenFingerprint = Get-GraphFingerprint -Value $authResult.AccessToken $result.CredentialGeneration = $this.CredentialGeneration - $this.CacheResult($result) + $this.CacheResult($result, $forceRefresh) return $result } } @@ -161,8 +235,11 @@ class ManagedIdentityTokenSource : GraphTokenSourceBase { } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { - if (-not $forceRefresh -and $this.HasValidCachedToken()) { - return $this.CachedResult + if (-not $forceRefresh) { + $cached = $this.GetValidCachedToken() + if ($null -ne $cached) { + return $cached + } } $app = $this.GetApplication() @@ -180,7 +257,7 @@ class ManagedIdentityTokenSource : GraphTokenSourceBase { $result.TokenFingerprint = Get-GraphFingerprint -Value $authResult.AccessToken $result.CredentialGeneration = $this.CredentialGeneration - $this.CacheResult($result) + $this.CacheResult($result, $forceRefresh) return $result } } @@ -198,8 +275,11 @@ class ProviderTokenSource : GraphTokenSourceBase { } [GraphTokenResult] Acquire([bool]$forceRefresh, [System.Threading.CancellationToken]$cancellation) { - if (-not $forceRefresh -and $this.HasValidCachedToken()) { - return $this.CachedResult + if (-not $forceRefresh) { + $cached = $this.GetValidCachedToken() + if ($null -ne $cached) { + return $cached + } } $provided = & $this.Provider @@ -246,7 +326,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 } @@ -268,7 +348,8 @@ class FixedBearerTokenSource : GraphTokenSourceBase { 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 @@ -278,25 +359,29 @@ 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 GraphTokenFlight() { - $this.Done = [System.Threading.ManualResetEventSlim]::new($false) + $this.Completion = [System.Threading.Tasks.TaskCompletionSource[object]]::new( + [System.Threading.Tasks.TaskCreationOptions]::RunContinuationsAsynchronously + ) + $this.LeaderCancellationRequested = $false } } class GraphTokenFlightRegistry { static [System.Collections.Concurrent.ConcurrentDictionary[string, GraphTokenFlight]] $Flights = [System.Collections.Concurrent.ConcurrentDictionary[string, GraphTokenFlight]]::new() + static [object] $RemovalLock = [object]::new() } <# @@ -432,10 +517,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()] @@ -444,42 +561,103 @@ 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 { + $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 + } + } + + $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 + } - if ([GraphTokenFlightRegistry]::Flights.TryAdd($Key, $flight)) { try { - $flight.Result = & $AcquireScript + return $existing.Completion.Task.WaitAsync($CancellationToken).GetAwaiter().GetResult() } catch { - $flight.Error = $_.Exception - } - finally { - $flight.Done.Set() - $removed = [GraphTokenFlight] $null - $null = [GraphTokenFlightRegistry]::Flights.TryRemove($Key, [ref] $removed) + $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 } } - 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 - } - $existing.Done.Wait() - if ($null -ne $existing.Error) { - throw $existing.Error - } - 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" } <# diff --git a/source/Private/Transport/Send-GraphHttpRequest.ps1 b/source/Private/Transport/Send-GraphHttpRequest.ps1 index 090f76a..77ac26a 100644 --- a/source/Private/Transport/Send-GraphHttpRequest.ps1 +++ b/source/Private/Transport/Send-GraphHttpRequest.ps1 @@ -86,6 +86,8 @@ function Send-GraphHttpRequest { [bool] $ForceRefresh = $false, + [string] $TokenAcquisitionKey, + [ValidateSet('GraphBearer', 'None')] [string] $CredentialPolicy = 'None', @@ -188,10 +190,40 @@ function Send-GraphHttpRequest { # 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. - $tokenResult = $TokenSource.Acquire($ForceRefresh, $CancellationToken) + 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, $CancellationToken) + } + else { + $sourceForAcquire = $TokenSource + $forceForAcquire = $ForceRefresh + $cancellationForAcquire = $CancellationToken + $flightKey = Get-GraphTokenFlightKey ` + -AcquisitionKey $TokenAcquisitionKey ` + -ForceRefresh:$ForceRefresh + $tokenResult = Invoke-GraphTokenSingleFlight ` + -Key $flightKey ` + -CancellationToken $CancellationToken ` + -AcquireScript { + $sourceForAcquire.Acquire($forceForAcquire, $cancellationForAcquire) + }.GetNewClosure() + } 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 + ) + } if ($VerifyTenantBinding) { # Mutating sends require tenant proof BEFORE the request is issued. # A result that carries no VerifiedTenantId, or whose binding is not diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index fb6af29..3095a0b 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -141,8 +141,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,6 +157,180 @@ Describe 'GraphTokenSource' { } } + 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() + + try { + $state = @{ calls = 0 } + $message = try { + $null = Invoke-GraphTokenSingleFlight -Key $key -CancellationToken $cts.Token ` + -AcquireScript { $state.calls++; 'should-not-run' } + '' + } + catch { + $_.Exception.Message + } + + $message | Should -BeLike '*canceled*' + $state.calls | Should -Be 0 + [GraphTokenFlightRegistry]::Flights.ContainsKey($key) | Should -BeTrue + } + 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' @@ -245,6 +418,287 @@ Describe 'GraphTokenSource' { } } } + + 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) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceReady', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceGo', $go) + + $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') + $queue.Enqueue('acquire') + Start-Sleep -Milliseconds 800 + 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() + $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $jobs = $null + + $calls.Count | Should -Be 1 + $results.Count | Should -Be 8 + @($results | Where-Object { $_ -notlike '*provider timed out internally*' }).Count | Should -Be 0 + } + finally { + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProviderOceGo', $null) + if ($null -ne $jobs) { + $jobs | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + } + $ready.Dispose() + $go.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() + Start-Sleep -Milliseconds 200 + $leaderCts.Cancel() + + $replacementStarted.Wait(15000) | Should -BeTrue + $leaderResult = @($leaderJob | Receive-Job -Wait) + Remove-Job -Job $leaderJob -Force -ErrorAction SilentlyContinue + $leaderJob = $null + $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 = @($waiterJobs | Receive-Job -Wait) + Remove-Job -Job $waiterJobs -Force -ErrorAction SilentlyContinue + $waiterJobs = $null + $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 + 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 -and $leaderJob.State -ne 'Completed') { + $leaderJob | Remove-Job -Force -ErrorAction SilentlyContinue + } + if ($null -ne $waiterJobs) { + $waiterJobs | Where-Object { $_.State -ne 'Completed' } | 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) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightReady', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightGo', $go) + + $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') + $queue.Enqueue('acquire') + Start-Sleep -Milliseconds 400 + 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 + } + + $null = $ready.Wait(15000) + $go.Set() + $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + + $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 + InModuleScope GraphKit -Parameters @{ K = $key } { + param($K) + $flightKey = Get-GraphTokenFlightKey -AcquisitionKey $K -ForceRefresh:$false + [GraphTokenFlightRegistry]::Flights.ContainsKey($flightKey) | Should -BeFalse + } + } + finally { + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ProductionSingleFlightGo', $null) + if ($null -ne $jobs) { + $jobs | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + } + $ready.Dispose() + $go.Dispose() + } + } } Context 'Canonical tuple normalization' { @@ -284,5 +738,74 @@ 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() + $ready = [System.Threading.CountdownEvent]::new(6) + $go = [System.Threading.ManualResetEventSlim]::new($false) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeCalls', $calls) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeReady', $ready) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeGo', $go) + + $jobs = $null + try { + $jobs = 0..5 | ForEach-Object { + $force = $_ -ge 3 + Start-ThreadJob -ThrottleLimit 6 -ScriptBlock { + param($Key, $Force, $Manifest) + Import-Module $Manifest + $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ModeReady') + $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ModeGo') + $null = $ready.Signal() + $null = $go.Wait() + + & (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') + $queue.Enqueue($mode) + Start-Sleep -Milliseconds 500 + $mode + }.GetNewClosure() + } $Key $Force + } -ArgumentList $key, $force, $script:BuiltManifest + } + + $ready.Wait(15000) | Should -BeTrue + $go.Set() + $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $jobs = $null + + @($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 + } + finally { + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeCalls', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeReady', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeGo', $null) + if ($null -ne $jobs) { + $jobs | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + } + $ready.Dispose() + $go.Dispose() + } + } } } diff --git a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 index fe1f4a7..8506996 100644 --- a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 +++ b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 @@ -89,9 +89,10 @@ BeforeAll { function New-TestSend { return { - param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding) + param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $TokenAcquisitionKey, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding) $script:sendCount++ $script:lastSendHeaders = $Headers + $script:lastTokenAcquisitionKey = $TokenAcquisitionKey $result = $script:results.Dequeue() # The injected sender models the real sender's acquisition ownership: @@ -156,6 +157,7 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $script:completeCalls = 0 $script:acquireCalls = [System.Collections.Generic.List[bool]]::new() $script:lastSendHeaders = $null + $script:lastTokenAcquisitionKey = $null $script:scopeToReturn = @{ CoarseKey = 'Global|tenant|client|Read' LeafKey = 'Global|tenant|client|Test.Family|Read' @@ -286,6 +288,34 @@ 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) + $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 + } + } } Context 'attempt accounting' { @@ -382,6 +412,20 @@ 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' + } } } } From 65f06e4eb10b2262592b0470537339bc4391d140 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 18:46:45 -0400 Subject: [PATCH 03/79] build: bind releases to canonical test proof --- .build/ReleaseProof.tasks.ps1 | 20 + .github/workflows/ci.yml | 19 +- AGENTS.md | 2 +- build.yaml | 9 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 383 +++++++++ scripts/Publish-GraphKitPackage.ps1 | 145 ++-- scripts/Publish-GraphKitToGallery.ps1 | 128 ++- scripts/Test-GraphKitReleaseProof.ps1 | 750 +++++++++++++++++ tests/QA/MinimumTestsRatchetSync.tests.ps1 | 13 +- tests/QA/PublishChannel.tests.ps1 | 62 +- tests/QA/ReleaseProof.tests.ps1 | 918 +++++++++++++++++++++ 11 files changed, 2326 insertions(+), 123 deletions(-) create mode 100644 .build/ReleaseProof.tasks.ps1 create mode 100644 scripts/New-GraphKitTestedReleaseProof.ps1 create mode 100644 scripts/Test-GraphKitReleaseProof.ps1 create mode 100644 tests/QA/ReleaseProof.tests.ps1 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f32b3..0c45485 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,4 +93,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 825 -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..bbc4263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Two things about the container are worth knowing before repeating it. The immuta 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 825 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. **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. diff --git a/build.yaml b/build.yaml index 85bf20e..73e4032 100644 --- a/build.yaml +++ b/build.yaml @@ -46,7 +46,7 @@ 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: @@ -67,11 +67,16 @@ BuildWorkflow: test: # Uncomment to modify the PSModulePath in the test pipeline (also requires the build configuration section SetPSModulePath). #- Set_PSModulePath + # Invalidate stale proof and capture the exact package/module candidate before Pester. + - Capture_Tested_Release_Proof_Candidate - Pester_Tests_Stop_On_Fail # 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. @@ -181,5 +186,3 @@ GitConfig: - - diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 new file mode 100644 index 0000000..a83b71e --- /dev/null +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -0,0 +1,383 @@ +<# + .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 = 825 +$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) + + $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 ([string] $manifest.ModuleVersion -cne $version) { + throw "Built manifest version '$($manifest.ModuleVersion)' does not match its version directory '$version'." + } + + [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.$version.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 = $version + files = $files + } + 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 + $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.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 = 1 + runId = [guid]::NewGuid().ToString('D') + module = $candidate.module + 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 1 -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 = 1 + runId = [string] $captured.runId + 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" + } + + Remove-Item -LiteralPath $candidatePath -Force + [System.IO.File]::Move($stagedProofPath, $proofPath, $true) +} +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..20129c6 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" + # 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' } - - # 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." - } - - # 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,14 +178,16 @@ 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 } if ((Test-Path -LiteralPath $target -PathType Leaf) -and -not $Force) { - $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash - if ($existingHash -eq $hash) { + $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + if ($existingHash -ceq $hash) { Write-Host ' Already published with identical bytes; nothing to do.' -ForegroundColor Green } else { @@ -186,7 +202,25 @@ switch ($Channel) { Write-Host " Published to $target" -ForegroundColor Green } - $publishedSource = (Resolve-Path -LiteralPath $Destination).Path + 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 + Write-Host " Published tested-release proof to $proofTarget" -ForegroundColor Green + } + $publishedProofSource = [System.IO.Path]::GetFullPath($proofTarget) + } + + $publishedSource = [System.IO.Path]::GetFullPath($Destination) } 'GitHubRelease' { @@ -211,12 +245,19 @@ 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 + $uploadArguments = @( + 'release', 'upload', $tag, + $package.FullName, $verifiedProofSnapshot.FullName, + '--repo', $Destination + ) + if ($Force) { $uploadArguments += '--clobber' } + & gh @uploadArguments if ($LASTEXITCODE -ne 0) { throw "gh release upload failed for $Destination $tag." } - Write-Host " Uploaded $($package.Name) to $Destination release $tag" -ForegroundColor Green + Write-Host " Uploaded $($package.Name) and $proofAssetName to $Destination release $tag" -ForegroundColor Green } $publishedSource = "https://github.com/$Destination/releases/tag/$tag" + $publishedProofSource = "https://github.com/$Destination/releases/download/$tag/$proofAssetName" } } @@ -243,7 +284,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 +301,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..bd817df 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($PackagePath)) { - $PackagePath = Join-Path $repoRoot "output/GraphKit.$version.nupkg" + 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 +} + +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 @@ -190,19 +270,15 @@ 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) { @@ -258,3 +334,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..f7f5fe7 --- /dev/null +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -0,0 +1,750 @@ +<# + .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 = 825 +$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 + $proofModuleName = [string] $proof.module.name + $proofModuleVersion = [string] $proof.module.version + $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 1 -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 ($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) +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.IndexOf('\') -ge 0 -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'." + } +} +if ($proofFileMap.Count -eq 0) { + throw 'The tested release proof records zero shipped module files.' +} + +$builtModuleDirectory = Join-Path $RepositoryRoot "output/module/GraphKit/$moduleVersion" +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) +foreach ($relativePath in $currentRelativePaths) { + if (-not $currentPathMap.TryAdd($relativePath, $relativePath)) { + throw "The built module contains case-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 $moduleVersion) { + throw "The built GraphKit.psd1 declares version '$($builtManifest.ModuleVersion)', not proof version '$moduleVersion'." +} + +$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', + 'package/services/metadata/core-properties/nuget.psmdcp' + )) { + $null = $wrapperPaths.Add($wrapperPath) + } + + $archivePathMap = [System.Collections.Generic.Dictionary[string, System.IO.Compression.ZipArchiveEntry]]::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'." + } + } + + 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')." + } + } + + $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." + } + $supportedMetadataNames = @( + 'id', + 'version', + 'authors', + 'owners', + 'requireLicenseAcceptance', + 'licenseUrl', + 'description', + 'releaseNotes', + 'copyright', + 'tags', + 'dependencies' + ) + $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." + } + } + if ($metadataNameSet.Count -ne $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 ' ' + } + 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 ' ') + } + else { + $actualValue = ConvertTo-CanonicalLineEndings -Value $actualValue + $expectedValue = ConvertTo-CanonicalLineEndings -Value $expectedValue + } + 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 @($builtManifest.RequiredModules)) { + $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 ($dependencyContainers.Count -ne 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) + foreach ($dependencyNode in @($dependencyContainers[0].ChildNodes | Where-Object NodeType -EQ ([System.Xml.XmlNodeType]::Element))) { + $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 + 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/tests/QA/MinimumTestsRatchetSync.tests.ps1 b/tests/QA/MinimumTestsRatchetSync.tests.ps1 index bb01f63..76d6f91 100644 --- a/tests/QA/MinimumTestsRatchetSync.tests.ps1 +++ b/tests/QA/MinimumTestsRatchetSync.tests.ps1 @@ -23,16 +23,17 @@ BeforeAll { } Describe 'MinimumTests ratchet synchronization' -Tag 'QA' { - It 'keeps CI, package verification, operator guidance, and the passing fixture equal' { + It 'keeps CI, proof production, proof verification, and the canonical fixture equal' { $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 - $publishTests = Get-Content -LiteralPath (Join-Path $script:repoRoot 'tests/QA/PublishChannel.tests.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 $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' } @($values.Values | Select-Object -Unique).Count | Should -Be 1 -Because ( diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index f488201..ca3949c 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 = 825) $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,22 @@ 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 } } diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 new file mode 100644 index 0000000..cda5de7 --- /dev/null +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -0,0 +1,918 @@ +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 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, + [int] $Total = 825 + ) + + $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) + $version = '9.9.9' + $moduleDir = Join-Path $fixtureRoot "output/module/GraphKit/$version" + $resultsDir = Join-Path $fixtureRoot 'output/testResults' + $gateDir = Join-Path $fixtureRoot 'tests/QA' + $scriptsDir = Join-Path $fixtureRoot 'scripts' + New-Item -ItemType Directory -Path $moduleDir, $resultsDir, $gateDir, $scriptsDir -Force | Out-Null + + 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 (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') + + $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 = '$version' + 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') + RequiredModules = @(@{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' }) + PrivateData = @{ PSData = @{ + Tags = @('Fixture', 'Graph') + LicenseUri = 'https://opensource.org/licenses/MIT' + ReleaseNotes = 'Fixture release notes.' + } } +} +"@ + 'GraphKit.psm1' = "function Get-GraphProbe { 'fixture' }`n" + 'en-US/about_GraphKit.help.txt' = "TOPIC`n about_GraphKit`n" + } + + 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 + + $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 +"@ + 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 = 1 + runId = [guid]::NewGuid().ToString('D') + module = [pscustomobject] [ordered] @{ + name = 'GraphKit' + version = $version + 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 = 825 + 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 + ModuleDir = $moduleDir + PackagePath = $packagePath + ProofPath = $proofPath + NUnitPath = $nunitPath + PesterObjectPath = $pesterObjectPath + } + } + + function Invoke-GraphKitReleaseProofVerifier { + param([Parameter(Mandatory)] [pscustomobject] $Fixture) + + $output = & pwsh -NoLogo -NoProfile -File $script:verifierPath ` + -PackagePath $Fixture.PackagePath ` + -ProofPath $Fixture.ProofPath ` + -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-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.Version)/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) { + Remove-Item -LiteralPath $script:fixture.Root -Recurse -Force -ErrorAction SilentlyContinue + $script:fixture = $null + } + } + + It 'accepts one proof binding the module, package, full result, and every shipped file' { + $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' + } + + 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 unsafe package path ' -ForEach @( + @{ EntryName = '../outside/' } + @{ EntryName = '/absolute.ps1' } + @{ EntryName = 'C:/absolute.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 nuspec drift' -ForEach @( + @{ Field = 'id'; Find = 'GraphKit'; Replace = 'OtherModule' } + @{ Field = 'version'; Find = '9.9.9'; 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' + 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 + + $result = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture + + $result.ExitCode | Should -Be 0 + $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 emits the one proof only after the captured candidate and result pair pass' { + $script:fixture = New-GraphKitReleaseProofFixture + $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 -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 '9.9.9' + @($proof.module.files).Count | Should -Be 5 + $proof.testRun.summary.total | Should -Be 825 + $proof.testRun.summary.notRun | Should -Be 0 + Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse + } + + It 'finalize refuses module drift after capture and leaves no tested proof' { + $script:fixture = New-GraphKitReleaseProofFixture + $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 -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_Stop_On_Fail\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_Stop_On_Fail') + $testWorkflow.IndexOf('Pester_Tests_Stop_On_Fail') | 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*$' + + $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 '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.9.9.9.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 '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 + $privatePublisher | Should -Not -Match '--clobber:' + $privatePublisher | Should -Match '\$uploadArguments \+= ''--clobber''' + } +} From 5f706b51df9c26aaae280805157d4b43506aeb1d Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 20:24:49 -0400 Subject: [PATCH 04/79] fix: harden credential and module lifecycle --- .github/workflows/ci.yml | 2 +- AGENTS.md | 26 +- CHANGELOG.md | 11 + .../specs/2026-08-14-graphkit-design.md | 20 +- ...hkit-tenantpulse-product-program-design.md | 5 + scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 7 +- source/Private/Get-GraphVaultCredential.ps1 | 193 ++++-- .../Initialize-GraphModuleLifecycle.ps1 | 610 +++++++++++++++++ .../Private/TokenSources/GraphTokenSource.ps1 | 346 +++++++++- .../TokenSources/New-GraphMsalApplication.ps1 | 150 ++++- .../Transport/Send-GraphHttpRequest.ps1 | 215 +++++- source/Public/Get-GraphContext.ps1 | 8 +- source/Public/Register-GraphTenant.ps1 | 59 +- .../GraphModuleLifecycleSender.Tests.ps1 | 178 +++++ tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 32 +- .../Auth/Get-GraphVaultCredential.Tests.ps1 | 201 +++++- .../Auth/New-GraphMsalApplication.Tests.ps1 | 404 ++++++++++++ .../Profiles/Register-GraphTenant.Tests.ps1 | 44 ++ .../TokenSources/GraphTokenSource.Tests.ps1 | 595 ++++++++++++++++- .../Transport/GraphModuleLifecycle.Tests.ps1 | 617 ++++++++++++++++++ 22 files changed, 3587 insertions(+), 140 deletions(-) create mode 100644 source/Private/Initialize-GraphModuleLifecycle.ps1 create mode 100644 tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 create mode 100644 tests/Unit/Auth/New-GraphMsalApplication.Tests.ps1 create mode 100644 tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c45485..cc4fd1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ 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 825 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 896 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index bbc4263..c62c7ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,9 +25,15 @@ 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 825 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. +**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 896 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. **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. @@ -52,7 +58,9 @@ 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. The approved end state + resolves it before parallel work begins; the post-`0.3.0` legacy PowerShell token source is + temporarily same-runspace-only until `GraphKit.Auth` supplies the compiled boundary. 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 +72,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`. Until `GraphKit.Auth` lands, built-in PowerShell token sources must be created and + used in the same runspace; the public sender rejects a crossed 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 +123,11 @@ 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. +- The target contract resolves immutable contexts before asynchronous/runspace work. Current + legacy PowerShell token sources are same-runspace-only fail-fast containment; do not enable or + claim cross-runspace context use until the compiled `GraphKit.Auth` source passes that gate. + 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 f71a3dc..706f69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/docs/superpowers/specs/2026-08-14-graphkit-design.md b/docs/superpowers/specs/2026-08-14-graphkit-design.md index 7ebf287..904ed87 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: -`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..d070413 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 @@ -430,6 +430,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 diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index a83b71e..07618bf 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 825 +$minimumTests = 896 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index f7f5fe7..38631ec 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 825 +$minimumTests = 896 $allowedSkips = 0 $allowedNotRun = 0 @@ -374,6 +374,11 @@ try { 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." diff --git a/source/Private/Get-GraphVaultCredential.ps1 b/source/Private/Get-GraphVaultCredential.ps1 index 79ea9c5..86a7937 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]) { @@ -83,35 +92,81 @@ function Get-GraphVaultCredential { '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)." @@ -253,11 +308,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 +320,40 @@ function Get-GraphSecret { return $secret } +function Assert-GraphSecretVersionSupported { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Name, + + [string] $Version + ) + + if ([string]::IsNullOrEmpty($Version)) { + return + } + + $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 +367,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 +397,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 +428,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 +478,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 +565,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 +584,11 @@ function New-GraphCredentialMaterial { [object] $Material, - [object] $ManagedIdentityClientId + [object] $ManagedIdentityClientId, + + [bool] $OwnsMaterial = $false, + + [string] $CredentialGeneration ) return [PSCustomObject]@{ @@ -481,5 +596,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..c69e466 --- /dev/null +++ b/source/Private/Initialize-GraphModuleLifecycle.ps1 @@ -0,0 +1,610 @@ +<# + 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 for CleanupDone 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.1' + +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 = 'CleanupDone'; 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[]] @() } + ) + $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.1"; } + } + + 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 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); + CleanupDone = new ManualResetEventSlim(false); + } + + // 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 ManualResetEventSlim CleanupDone { 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(); } + } + + 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 CleanupDone 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 + { + lock (_stateSync) { _cleanupComplete = true; } + CleanupDone.Set(); + } + } + + 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 { + [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 { + [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.CleanupDone.Wait($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 +$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/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index 34fc8f3..ea742a5 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -36,6 +36,7 @@ class GraphTokenSourceBase { [System.DateTimeOffset] $ExpiresOn [string] $VerifiedTenantId [string] $CredentialGeneration + hidden [guid] $CreationRunspaceId hidden [GraphTokenResult] $CachedResult hidden [bool] $CachedResultWasForceRefresh @@ -43,6 +44,13 @@ class GraphTokenSourceBase { 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) { @@ -149,6 +157,16 @@ class GraphTokenSourceBase { } [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') } @@ -169,9 +187,11 @@ class GraphTokenSourceBase { 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 @@ -180,13 +200,35 @@ 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) { + $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) { @@ -217,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 @@ -228,13 +272,35 @@ 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) { + $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) { @@ -275,6 +341,16 @@ class ProviderTokenSource : 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 (-not $forceRefresh) { $cached = $this.GetValidCachedToken() if ($null -ne $cached) { @@ -344,6 +420,16 @@ 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.') } @@ -412,17 +498,104 @@ function Get-GraphFingerprint { } } +function Get-GraphPfxSnapshot { + <# + Read a persisted PFX once and bind its canonical path, exact bytes and + SHA-256 identity together. Callers that construct a certificate use the + returned Bytes property rather than reopening the path, so the material + cannot change between generation verification and import. + #> + [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 has already read the exact + # bytes it will import, so it supplies their digest/path to avoid a + # second path read and a generation-to-load TOCTOU window. + [string] $PfxContentSha256, + + [string] $PfxCanonicalPath ) $authMethod = [string]$TenantProfile.AuthMethod @@ -430,32 +603,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'." @@ -463,6 +685,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 @@ -679,7 +953,39 @@ function New-GraphTokenSource { $authMethod = [string]$Profile.AuthMethod $audience = [string]$Cloud.Resource $clientId = $Profile.ClientId - $generation = Get-GraphCredentialGeneration -TenantProfile $Profile + $factoryProfile = $Profile + 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 + } + 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' { @@ -688,14 +994,16 @@ function New-GraphTokenSource { # module could not authenticate by any means outside a test. $factory = $MsalFactory if ($null -eq $factory) { - $factory = New-GraphMsalApplicationFactory -Profile $Profile -Cloud $Cloud + $factory = New-GraphMsalApplicationFactory -Profile $factoryProfile -Cloud $Cloud ` + -ExpectedCredentialGeneration $generation } return [ConfidentialClientTokenSource]::new($factory, 'Certificate', $audience, $clientId, $generation) } 'ClientSecret' { $factory = $MsalFactory if ($null -eq $factory) { - $factory = New-GraphMsalApplicationFactory -Profile $Profile -Cloud $Cloud + $factory = New-GraphMsalApplicationFactory -Profile $Profile -Cloud $Cloud ` + -ExpectedCredentialGeneration $generation } return [ConfidentialClientTokenSource]::new($factory, 'ClientSecret', $audience, $clientId, $generation) } 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/Send-GraphHttpRequest.ps1 b/source/Private/Transport/Send-GraphHttpRequest.ps1 index 77ac26a..10e8cd2 100644 --- a/source/Private/Transport/Send-GraphHttpRequest.ps1 +++ b/source/Private/Transport/Send-GraphHttpRequest.ps1 @@ -25,34 +25,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) - - # 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 - - $script:GraphKitHttpClients[$key] = [pscustomobject] @{ - Handler = $handler - Client = $client + if ($State.HttpClients.ContainsKey($key)) { + return [System.Net.Http.HttpClient] $State.HttpClients[$key].Client } - } - return $script:GraphKitHttpClients[$key].Client + 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.' + ) + } + + $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 $client + } + finally { + [System.Threading.Monitor]::Exit($State.SyncRoot) + } } function Send-GraphHttpRequest { @@ -97,9 +167,30 @@ function Send-GraphHttpRequest { [switch] $VerifyTenantBinding, - [scriptblock] $TenantBindingProver + [scriptblock] $TenantBindingProver, + + # 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 + $phaseCts = $null + $request = $null + $response = $null + + $moduleCancellationToken = Enter-GraphModuleOperation -State $LifecycleState + $leaseAcquired = $true + try { + $lifetimeCts = [System.Threading.CancellationTokenSource]::CreateLinkedTokenSource( + $CancellationToken, + $moduleCancellationToken + ) + $effectiveCancellationToken = $lifetimeCts.Token + $result = [GraphTransportResult]::new() $result.StatusCode = 0 $result.Headers = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -133,6 +224,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 ---- @@ -193,18 +307,18 @@ function Send-GraphHttpRequest { 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, $CancellationToken) + $tokenResult = $TokenSource.Acquire($ForceRefresh, $effectiveCancellationToken) } else { $sourceForAcquire = $TokenSource $forceForAcquire = $ForceRefresh - $cancellationForAcquire = $CancellationToken + $cancellationForAcquire = $effectiveCancellationToken $flightKey = Get-GraphTokenFlightKey ` -AcquisitionKey $TokenAcquisitionKey ` -ForceRefresh:$ForceRefresh $tokenResult = Invoke-GraphTokenSingleFlight ` -Key $flightKey ` - -CancellationToken $CancellationToken ` + -CancellationToken $effectiveCancellationToken ` -AcquireScript { $sourceForAcquire.Acquire($forceForAcquire, $cancellationForAcquire) }.GetNewClosure() @@ -262,7 +376,7 @@ function Send-GraphHttpRequest { } } - & $prover -Context $proofContext -TokenResult $tokenResult -CancellationToken $CancellationToken + & $prover -Context $proofContext -TokenResult $tokenResult -CancellationToken $effectiveCancellationToken } if ($null -eq $tokenResult -or @@ -294,13 +408,13 @@ function Send-GraphHttpRequest { } # ---- 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($effectiveCancellationToken) try { $phaseCts.CancelAfter([TimeSpan]::FromSeconds($TimeoutHeadersSeconds)) @@ -354,13 +468,44 @@ function Send-GraphHttpRequest { $result.StatusCode = 0 } } + return $result + } finally { - $phaseCts.Dispose() - $request.Dispose() - if ($null -ne $response) { $response.Dispose() } + # 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 $lifetimeCts) { + $lifetimeCts.Dispose() + } + } + finally { + if ($leaseAcquired) { + Exit-GraphModuleOperation -State $LifecycleState + } + } + } + } + } } - - return $result } <# diff --git a/source/Public/Get-GraphContext.ps1 b/source/Public/Get-GraphContext.ps1 index 8d9a4b1..45a3e7f 100644 --- a/source/Public/Get-GraphContext.ps1 +++ b/source/Public/Get-GraphContext.ps1 @@ -104,13 +104,19 @@ function Get-GraphContext { Credential = @{ Thumbprint = $Certificate.Thumbprint } } ` -Cloud $cloud ` + -ExpectedCredentialGeneration $generation ` -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 } + [pscustomobject] @{ + AuthMethod = 'Certificate' + Material = $injected + OwnsMaterial = $false + CredentialGeneration = $generation + } }.GetNewClosure() } $source = [ConfidentialClientTokenSource]::new($factory, 'Certificate', [string]$cloud.Resource, $tenantProfile.ClientId, $generation) diff --git a/source/Public/Register-GraphTenant.ps1 b/source/Public/Register-GraphTenant.ps1 index 2ddbd14..f5bae55 100644 --- a/source/Public/Register-GraphTenant.ps1 +++ b/source/Public/Register-GraphTenant.ps1 @@ -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 @@ -117,6 +142,9 @@ function Register-GraphTenant { #> [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 +181,18 @@ function Register-GraphTenant { [string] $PfxSecretName, + [string] $PfxSecretVersion, + [string] $CertificateName, [string] $CertificateVersion, + [string] $CertificatePasswordVaultName, + + [string] $CertificatePasswordSecretName, + + [string] $CertificatePasswordVersion, + [string] $StoreLocation, [string] $StoreName, @@ -219,12 +255,29 @@ 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) + if ($hasPasswordVault -ne $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. diff --git a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 new file mode 100644 index 0000000..d70ce5e --- /dev/null +++ b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 @@ -0,0 +1,178 @@ +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.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace GraphKit.Tests +{ + public sealed class LifecycleBlockingHandler : 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 TaskCompletionSource Started { get; } = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _sendCount); + SeenToken = cancellationToken; + Started.TrySetResult(true); + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("The blocking test handler resumed without cancellation."); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Interlocked.Increment(ref _disposeCount); + } + base.Dispose(disposing); + } + } +} +'@ + } +} + +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 '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) + $stateKey = 'GraphKitTest.SenderState.' + [guid]::NewGuid().ToString('N') + $clientKey = 'GraphKitTest.SenderClient.' + [guid]::NewGuid().ToString('N') + [System.AppDomain]::CurrentDomain.SetData($stateKey, $state) + [System.AppDomain]::CurrentDomain.SetData($clientKey, $client) + $sendJob = $null + $stopJob = $null + + try { + $sendJob = Start-ThreadJob -ScriptBlock { + param($Manifest, $StateKey, $ClientKey) + Import-Module $Manifest -Force -ErrorAction Stop + $sharedState = [System.AppDomain]::CurrentDomain.GetData($StateKey) + $sharedClient = [System.AppDomain]::CurrentDomain.GetData($ClientKey) + & (Get-Module GraphKit) { + param($State, $Client) + $factory = { + param([int] $ConnectTimeoutSeconds) + [pscustomobject] @{ + Client = $Client + OwnedByGraphKit = $false + } + }.GetNewClosure() + Send-GraphHttpRequest -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` + -Method GET -CredentialPolicy None -LifecycleState $State ` + -HttpClientFactory $factory -TimeoutHeadersSeconds 30 -TimeoutBodySeconds 30 + } $sharedState $sharedClient + } -ArgumentList $script:BuiltManifest, $stateKey, $clientKey + + $handler.Started.Task.Wait(5000) | Should -BeTrue + $state.ActiveOperations | Should -Be 1 + + $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 + } $sharedState + } -ArgumentList $script:BuiltManifest, $stateKey + + $stopCompleted = $stopJob | Wait-Job -Timeout 5 + if ($null -eq $stopCompleted) { + $client.CancelPendingRequests() + throw 'Stop-GraphModule did not cancel and drain the in-flight sender within five seconds.' + } + + $null = $stopJob | Receive-Job -Wait -ErrorAction Stop + $result = $sendJob | Receive-Job -Wait -ErrorAction Stop + + $handler.SendCount | Should -Be 1 + $handler.SeenToken.IsCancellationRequested | Should -BeTrue + $result.TransportException | Should -Not -BeNullOrEmpty + $state.ActiveOperations | Should -Be 0 + $state.CleanupComplete | Should -BeTrue + $handler.DisposeCount | Should -Be 0 + { $client.CancelPendingRequests() } | Should -Not -Throw + } + finally { + try { $client.CancelPendingRequests() } catch { } + if ($null -ne $sendJob) { + $sendJob | Remove-Job -Force -ErrorAction SilentlyContinue + } + if ($null -ne $stopJob) { + $stopJob | Remove-Job -Force -ErrorAction SilentlyContinue + } + [System.AppDomain]::CurrentDomain.SetData($stateKey, $null) + [System.AppDomain]::CurrentDomain.SetData($clientKey, $null) + $client.Dispose() + } + } +} diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index ca3949c..1a0027a 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 = 825) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 896) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index cda5de7..ef5367b 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -101,7 +101,7 @@ BeforeAll { [string] $PesterResult, [int] $Passed = -1, [bool] $Executed = $true, - [int] $Total = 825 + [int] $Total = 896 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -257,7 +257,7 @@ BeforeAll { sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 825 + minimumTests = 896 allowedSkips = 0 allowedNotRun = 0 } @@ -471,6 +471,32 @@ Describe 'Canonical tested release proof' { $result.Output | Should -Match '5 shipped file' } + 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' } @@ -786,7 +812,7 @@ Describe 'Test workflow release-proof generation' { $proof.module.name | Should -Be 'GraphKit' $proof.module.version | Should -Be '9.9.9' @($proof.module.files).Count | Should -Be 5 - $proof.testRun.summary.total | Should -Be 825 + $proof.testRun.summary.total | Should -Be 896 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse } diff --git a/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 b/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 index da4fa2f..818ee28 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,53 @@ 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-NotInvoke Invoke-GraphSecretManagementGetVault -ModuleName GraphKit + Should-NotInvoke Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit + } } Context 'BearerToken' { @@ -114,6 +162,7 @@ Describe 'Get-GraphVaultCredential' { $result.AuthMethod | Should -Be 'BearerToken' $result.Material | Should -BeOfType [string] $result.Material | Should -Be $script:BearerPlain + $result.OwnsMaterial | Should -BeFalse $result.ManagedIdentityClientId | Should -BeNullOrEmpty } } @@ -123,18 +172,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 +273,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 +286,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 +325,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)' { @@ -206,6 +397,7 @@ Describe 'Get-GraphVaultCredential' { $result.AuthMethod | Should -Be 'ManagedIdentity' $result.Material | Should -BeNullOrEmpty $result.ManagedIdentityClientId | Should -Be '7d6e5f44-9999-8888-7777-666655554444' + $result.OwnsMaterial | Should -BeFalse Should-Invoke Invoke-GraphSecretManagementGetVault -ModuleName GraphKit -Times 0 -Exactly Should-Invoke Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit -Times 0 -Exactly } @@ -218,6 +410,7 @@ Describe 'Get-GraphVaultCredential' { $result.AuthMethod | Should -Be 'ManagedIdentity' $result.Material | Should -BeNullOrEmpty $result.ManagedIdentityClientId | Should -BeNullOrEmpty + $result.OwnsMaterial | Should -BeFalse Should-Invoke Invoke-GraphSecretManagementGetVault -ModuleName GraphKit -Times 0 -Exactly Should-Invoke Invoke-GraphSecretManagementGetSecret -ModuleName GraphKit -Times 0 -Exactly } 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/Profiles/Register-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 index 1260c92..90cee52 100644 --- a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 @@ -24,6 +24,50 @@ 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 -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 -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 -Environment 'Global' -AuthMethod 'Certificate' ` + -VaultName 'GraphKit' -CertificateName 'certificate-pfx' ` + -CertificatePasswordVaultName 'GraphKit' -StorePath $invalidStore + } | Should -Throw -ExpectedMessage '*must include both*' + } + It 'rejects an injected certificate object' { $script:storePath = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new() diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index 3095a0b..686c068 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -7,6 +7,86 @@ BeforeAll { } $script:BuiltManifest = Join-Path $built.FullName 'GraphKit.psd1' Import-Module $script:BuiltManifest -Force + + 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 + { + private static int _factoryCalls; + + public static int FactoryCalls { get { return Volatile.Read(ref _factoryCalls); } } + + public static void Reset() + { + Interlocked.Exchange(ref _factoryCalls, 0); + } + + public static ConcurrentConfidentialApplication Create() + { + Interlocked.Increment(ref _factoryCalls); + Thread.Sleep(400); + 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; } + } +} +'@ + } + + 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' + ) + } + } } Describe 'GraphTokenSource' { @@ -74,6 +154,187 @@ 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 = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $jobs = $null + + [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 { + $go.Set() + if ($null -ne $jobs) { + $jobs | Where-Object { $_.State -ne 'Completed' } | 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 -Wait + + $message | Should -Match 'bound to the runspace.*GraphKit\.Auth' + [GraphKit.Tests.ConcurrentApplicationHarness]::FactoryCalls | Should -Be 0 + $seed.Flight.Completion.Task.IsCompleted | Should -BeFalse + + 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 { + $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) { + $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' { @@ -91,7 +352,7 @@ Describe 'GraphTokenSource' { $cert = New-GraphTokenSource -Profile @{ AuthMethod = 'Certificate'; ClientId = $null - Credential = @{ PfxPath = '/tmp/x.pfx'; Password = @{ VaultName = 'v'; SecretName = 'p' } } + Credential = @{ VaultName = 'v'; CertificateName = 'cert'; Version = '1' } } -Cloud $cloud -MsalFactory { throw 'not invoked' } $cert.CanRefresh | Should -BeTrue $cert.AuthMode | Should -Be 'Certificate' @@ -701,6 +962,338 @@ Describe 'GraphTokenSource' { } } + 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 to the canonical path passed into its lazy factory' { + $original = Join-Path $TestDrive 'relative-pfx-origin' + $elsewhere = Join-Path $TestDrive 'relative-pfx-elsewhere' + $null = New-Item -ItemType Directory -Path $original, $elsewhere -Force + [System.IO.File]::WriteAllBytes((Join-Path $original 'credential.pfx'), [byte[]] @(1, 3, 3, 7)) + $script:CapturedPfxFactoryProfile = $null + + Mock New-GraphMsalApplicationFactory -ModuleName GraphKit { + param($Profile, $Cloud, $ExpectedCredentialGeneration) + $script:CapturedPfxFactoryProfile = $Profile + return { throw 'canonical-path capture test must not acquire' } + } + + $source = InModuleScope GraphKit -Parameters @{ Origin = $original } { + param($Origin) + Push-Location $Origin + try { + New-GraphTokenSource -Profile @{ + 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' + } + } + finally { + Pop-Location + } + } + + Push-Location $elsewhere + try { + $script:CapturedPfxFactoryProfile.Credential.PfxPath | Should -Be ( + [System.IO.Path]::GetFullPath((Join-Path $original 'credential.pfx')) + ) + $source.CredentialGeneration | Should -Match '^g1\|Certificate\.PFX\|.+\|71:sha256:[0-9a-f]{64}\|5:vault\|8:password\|2:v1$' + } + finally { + Pop-Location + } + } + + 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 @{ Profile = $baseProfile } { + param($Profile) + Get-GraphCredentialGeneration -TenantProfile $Profile + } + $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 @{ Profile = $passwordChanged } { + param($Profile) + Get-GraphCredentialGeneration -TenantProfile $Profile + } + $materialChanged = $baseProfile.Clone() + $materialChanged.Credential = $baseProfile.Credential.Clone() + $materialChanged.Credential.Version = 'cert-v2' + $materialGeneration = InModuleScope GraphKit -Parameters @{ Profile = $materialChanged } { + param($Profile) + Get-GraphCredentialGeneration -TenantProfile $Profile + } + + $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 { + $profile = @{ + TenantId = '00000000-0000-0000-0000-000000000001' + ClientId = '00000000-0000-0000-0000-000000000002' + AuthMethod = 'BearerToken' + Credential = @{ VaultName = 'vault'; SecretName = 'bearer' } + } + $cloud = @{ + Name = 'Global' + Resource = 'https://graph.microsoft.com' + Authority = 'https://login.microsoftonline.com' + } + $old = New-GraphTokenSource -Profile $profile -Cloud $cloud + $new = New-GraphTokenSource -Profile $profile -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 $profile.TenantId ` + -Authority $cloud.Authority -Resource $cloud.Resource -ClientId $profile.ClientId ` + -AuthMode BearerToken -Generation $old.CredentialGeneration + NewKey = Get-GraphTokenAcquisitionKey -Environment $cloud.Name -TenantId $profile.TenantId ` + -Authority $cloud.Authority -Resource $cloud.Resource -ClientId $profile.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' { diff --git a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 new file mode 100644 index 0000000..a329914 --- /dev/null +++ b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 @@ -0,0 +1,617 @@ +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 + { + 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; + + public OrderedDisposable(string name, ConcurrentQueue order, bool throws) + { + _name = name; + _order = order; + _throws = throws; + } + + public void Dispose() + { + _order.Enqueue(_name); + if (_throws) throw new InvalidOperationException("dispose-failed-" + _name); + } + } +} +'@ + } +} + +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.1' + $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 + } $sharedState + } -ArgumentList $script:BuiltManifest, $stateKey + + $state.ShutdownCts.Token.WaitHandle.WaitOne(5000) | Should -BeTrue -Because 'Stop must signal the module lifetime before waiting for the active operation' + $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 + } + + $null = $stopJob | Receive-Job -Wait -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 + { + 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.CleanupDone.Wait(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.CleanupDone.Wait(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.CleanupDone.IsSet | 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 1) + + $owned.Release.Set() + $null = $stopJob | Receive-Job -Wait -ErrorAction Stop + + $completedBeforeRelease | Should -BeTrue -Because 'blocking Dispose must run outside the bounded module-removal path' + $state.CleanupDone.Wait(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 owned resources in LIFO order and reports an observed disposal failure' { + $state = InModuleScope GraphKit { + New-GraphModuleLifecycleState + } + $order = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() + $first = [GraphKit.Tests.OrderedDisposable]::new('first', $order, $false) + $second = [GraphKit.Tests.OrderedDisposable]::new('second', $order, $true) + $third = [GraphKit.Tests.OrderedDisposable]::new('third', $order, $false) + $injected = [GraphKit.Tests.OrderedDisposable]::new('injected', $order, $false) + + InModuleScope GraphKit -Parameters @{ + State = $state + First = $first + Second = $second + Third = $third + Injected = $injected + } { + param($State, $First, $Second, $Third, $Injected) + $null = Register-GraphModuleOwnedResource -State $State -Resource $First -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource -State $State -Resource $Second -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource -State $State -Resource $Third -OwnedByGraphKit:$true + $null = Register-GraphModuleOwnedResource -State $State -Resource $Injected -OwnedByGraphKit:$false + } + + { + InModuleScope GraphKit -Parameters @{ State = $state } { + param($State) + Stop-GraphModule -State $State + } + } | Should -Throw -ExceptionType ([System.AggregateException]) -ExpectedMessage '*dispose-failed-second*' + + $state.CleanupDone.IsSet | Should -BeTrue + $state.CleanupComplete | Should -BeTrue + @($order.ToArray()) | Should -Be @('third', 'second', 'first') + @($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 -eq 1 + + $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 + } + } +} + +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() + } + } +} From 76feb01711a6933cd96e3372985bce8ec90d0c5b Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 20:41:15 -0400 Subject: [PATCH 05/79] docs: freeze the GraphKit Auth R8 train --- .../plans/2026-08-30-r8-graphkit-auth.md | 618 ++++++++++++++++++ .../2026-08-30-r8-graphkit-auth-design.md | 284 ++++++++ 2 files changed, 902 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md create mode 100644 docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md 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..c87a29a --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -0,0 +1,618 @@ +# 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. + +--- + +## 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` + +- [ ] **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) +``` + +- [ ] **Step 2: Run the focused tests and verify red** + +Run: + +```powershell +./build.ps1 -Tasks pack +Invoke-Pester ./tests/QA/PackageIdentity.tests.ps1,./tests/QA/ReleaseProof.tests.ps1 -Output Detailed +``` + +Expected: failures naming stable `0.3.0`, missing source revision, and prerelease package discovery. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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` + +- [ ] **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`. + +- [ ] **Step 2: Run the two files and verify red** + +Expected: missing assembly/package path failures only. + +- [ ] **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` + +- [ ] **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`. + +- [ ] **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; } +} +``` + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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` + +- [ ] **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); +} +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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 + +**Files:** + +- Create: `.build/GraphKitAuth.tasks.ps1` +- Modify: `build.yaml` +- Modify: `source/GraphKit.psd1` +- Modify: `.github/workflows/ci.yml` +- Test: `tests/QA/GraphKitAuthPackage.tests.ps1` +- Test: `tests/QA/BuiltModule.tests.ps1` + +- [ ] **Step 1: Add the locked build and allowlisted copy tasks** + +`Build_GraphKitAuth` runs locked restore, .NET tests, and Release publish into +`output/GraphKit.Auth/stage`. `Copy_GraphKitAuth_Into_BuiltModule` accepts only: + +```powershell +$allowed = @( + 'GraphKit.Auth.Contracts.dll', + 'GraphKit.Auth.dll', + 'GraphKit.Auth.deps.json', + 'Microsoft.Identity.Client.dll', + 'Microsoft.IdentityModel.Abstractions.dll' +) +``` + +If MSAL 4.82.1's locked runtime closure adds another managed dependency, add that exact filename to +the allowlist and package test in the same commit; never use `Copy-Item *`. + +- [ ] **Step 2: Wire the workflow and built manifest** + +Insert the two build tasks in the order fixed by the R8 design. Set +`RequiredAssemblies = @('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')` only in the built +manifest after the allowlisted contracts DLL exists, then run `Test-ModuleManifest` against that +built path. Keep source `RequiredAssemblies` empty so source validation never points at a generated +file absent from `source/`. Add `actions/setup-dotnet@v4` with `10.0.400` before dependency restore +in each existing matrix row. + +- [ ] **Step 3: Pack and run package tests** + +Run: + +```powershell +./build.ps1 -ResolveDependency -Tasks noop +./build.ps1 -Tasks pack +Invoke-Pester ./tests/QA/GraphKitAuthPackage.tests.ps1,./tests/QA/BuiltModule.tests.ps1 -Output Detailed +``` + +Expected: contracts load in Default ALC; provider and exact MSAL load in the named non-default ALC; +every packaged runtime file is allowlisted; no PDB/ref/native file exists. + +- [ ] **Step 4: Commit** + +```bash +git add .build/GraphKitAuth.tasks.ps1 build.yaml source/GraphKit.psd1 .github/workflows/ci.yml tests/QA/GraphKitAuthPackage.tests.ps1 tests/QA/BuiltModule.tests.ps1 +git commit -m "build: package the isolated GraphKit Auth runtime" +``` + +### 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` + +- [ ] **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. + +- [ ] **Step 2: Verify red** + +Run the three focused Pester files. Expected: built-in contexts still return PowerShell classes. + +- [ ] **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. + +- [ ] **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 + +**Files:** + +- Create: `tests/Unit/Auth/GraphKitAuthParity.Tests.ps1` +- Create: `tests/Concurrency/GraphKitAuthRunspace.Tests.ps1` +- Modify: `tests/Concurrency/TokenIsolation.Tests.ps1` +- Modify: `tests/Adapter/GraphModuleLifecycleSender.Tests.ps1` +- Modify: `tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1` + +- [ ] **Step 1: Add shared legacy/compiled contract cases** + +Run the same case table against both implementations: ordinary cache hit, expiry refresh, forced +refresh, acquisition failure, cancellation, fixed-bearer force refusal, fingerprint equality, +generation mismatch, adoption, and disposal. Compare behavior and public result properties, not +concrete implementation type. + +- [ ] **Step 2: Add real runspace acceptance** + +Create one compiled source/context in the parent. Pass that exact object reference to two thread +runspaces, release them with event gates, and require bounded completion. Cover distinct tenants, +same-key single-flight, force-refresh isolation, and fixed bearer. No child may recreate a context. + +- [ ] **Step 3: Add unload/lifecycle acceptance** + +Dispose sources, remove the module, clear strong references, perform bounded GC/finalizer cycles, +and assert the host's ALC weak reference is dead. A deliberately active acquisition must cancel and +drain before owned certificate/secret disposal. + +- [ ] **Step 4: Run focused concurrency files serially** + +Expected: all pass without Pester parallelism, sleeps, or unbounded waits. + +- [ ] **Step 5: Commit** + +```bash +git add tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 tests/Concurrency/TokenIsolation.Tests.ps1 tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +git commit -m "test: prove GraphKit Auth parity and runspace isolation" +``` + +### Task 8: Prove protected live parity before transitive cutover + +**Files:** + +- Create: `scripts/Invoke-GraphKitAuthParity.ps1` +- Create: `tests/QA/GraphKitAuthLiveParity.tests.ps1` +- Modify only the R8 evidence ledger/spec after observed results. + +- [ ] **Step 1: Write and test a digest-bound protected runner** + +The runner requires the exact package path and SHA-256, installs into an isolated module path, and +accepts one auth mode per invocation. 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. + +- [ ] **Step 2: Pack/test and freeze the pre-cutover artifact** + +Run the complete local gates with the transitive dependency still present but production contexts +already using the isolated provider. Record the exact prerelease and digest; do not rebuild between +live modes. + +- [ ] **Step 3: Run approved Ivy24 parity** + +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 4: Provision a fresh managed-identity host only with 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 5: Commit only the tested runner and redacted observed evidence** + +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-30-r8-graphkit-auth-design.md b/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md new file mode 100644 index 0000000..21e606b --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md @@ -0,0 +1,284 @@ +# GraphKit R8 compiled authentication boundary + +**Date:** 2026-08-30 + +**Status:** Approved by the active end-to-end product-program goal + +**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-diff-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`. + +## 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. From f432bd1528d234d8d3dc5836b7ec054fa484b063 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 21:16:53 -0400 Subject: [PATCH 06/79] feat: establish r8 prerelease identity --- RequiredModules.psd1 | 1 + build.ps1 | 18 ++++++ build.yaml | 3 +- scripts/Get-GraphKitTrainVersion.ps1 | 29 ++++++++++ scripts/New-GraphKitTestedReleaseProof.ps1 | 56 +++++++++++++++++-- scripts/Test-GraphKitReleaseProof.ps1 | 59 ++++++++++++++++++-- source/GraphKit.psd1 | 34 ++--------- tests/QA/PackageDependencies.tests.ps1 | 9 +-- tests/QA/PackageIdentity.tests.ps1 | 46 +++++++++++---- tests/QA/ReleaseProof.tests.ps1 | 65 ++++++++++++++++++---- tests/QA/ReleaseTruth.tests.ps1 | 7 ++- 11 files changed, 255 insertions(+), 72 deletions(-) create mode 100644 scripts/Get-GraphKitTrainVersion.ps1 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..9e68da7 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 { } @@ -524,6 +539,9 @@ begin { Write-Verbose -Message "Bootstrap completed. Handing back to InvokeBuild." + $versionScript = Join-Path $PSScriptRoot 'scripts/Get-GraphKitTrainVersion.ps1' + $env:ModuleVersion = (& $versionScript -RepositoryRoot $PSScriptRoot).Trim() + if ($PSBoundParameters.ContainsKey('ResolveDependency')) { Write-Verbose -Message "Dependency already resolved. Removing task." diff --git a/build.yaml b/build.yaml index 73e4032..8e2bbf2 100644 --- a/build.yaml +++ b/build.yaml @@ -59,7 +59,7 @@ BuildWorkflow: pack: - build - - package_module_nupkg + - package_graphkit_r8_nupkg @@ -185,4 +185,3 @@ GitConfig: # UpdateChangelogOnPrerelease: false # Set to true to update changelog on pre-releases too - diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 new file mode 100644 index 0000000..d4f27e0 --- /dev/null +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -0,0 +1,29 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $RepositoryRoot +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath +$base = '0.4.0' +$train = 'r8' +$revision = (& git -C $RepositoryRoot rev-parse HEAD).Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^[0-9a-f]{40}$') { + throw "Cannot resolve a 40-character source revision for '$RepositoryRoot'." +} +$diff = (& git -C $RepositoryRoot diff --binary HEAD) +if ($LASTEXITCODE -ne 0) { + throw "Cannot determine whether '$RepositoryRoot' has uncommitted source changes." +} +$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" diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 07618bf..be0d8bc 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -37,6 +37,32 @@ $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'." + } + $fullVersion = (& $versionScript -RepositoryRoot $Root).Trim() + if ($LASTEXITCODE -ne 0 -or $fullVersion -notmatch '^(?\d+\.\d+\.\d+)-r8\.g(?[0-9a-f]{12})(?:\.d(?[0-9a-f]{12}))?$') { + throw "Release proof received an invalid GraphKit train version '$fullVersion'." + } + $baseVersion = $Matches['base'] + $sourceRevision = (& git -C $Root rev-parse HEAD).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $sourceRevision -notmatch '^[0-9a-f]{40}$') { + throw "Release proof cannot resolve a 40-character source revision for '$Root'." + } + $sourceDiff = (& git -C $Root diff --binary HEAD) + if ($LASTEXITCODE -ne 0) { + throw "Release proof cannot determine the source state for '$Root'." + } + $sourceClean = [string]::IsNullOrEmpty($sourceDiff) + $sourceDiffHash = if ($sourceClean) { + $null + } + else { + $bytes = [Text.Encoding]::UTF8.GetBytes($sourceDiff) + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + } + $moduleRoot = Join-Path $Root 'output/module/GraphKit' $versionDirectories = @( Get-ChildItem -LiteralPath $moduleRoot -Directory -ErrorAction SilentlyContinue @@ -51,9 +77,13 @@ function Get-GraphKitReleaseCandidateState { throw "Built GraphKit manifest is missing at '$manifestPath'." } $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath - if ([string] $manifest.ModuleVersion -cne $version) { + 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 | @@ -74,7 +104,7 @@ function Get-GraphKitReleaseCandidateState { } ) - $packagePath = Join-Path $Root "output/GraphKit.$version.nupkg" + $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." } @@ -82,9 +112,15 @@ function Get-GraphKitReleaseCandidateState { [pscustomobject] [ordered] @{ module = [pscustomobject] [ordered] @{ name = 'GraphKit' - version = $version + version = $fullVersion + baseVersion = $baseVersion files = $files } + source = [pscustomobject] [ordered] @{ + revision = $sourceRevision + clean = $sourceClean + diffSha256 = $sourceDiffHash + } package = [pscustomobject] [ordered] @{ name = Split-Path -Leaf $packagePath sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() @@ -103,6 +139,7 @@ function Assert-GraphKitReleaseCandidateUnchanged { $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++) { @@ -116,6 +153,11 @@ function Assert-GraphKitReleaseCandidateUnchanged { 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.diffSha256 -cne [string] $Current.source.diffSha256) { + 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.' @@ -264,9 +306,10 @@ if ($Stage -eq 'Capture') { $candidate = Get-GraphKitReleaseCandidateState -Root $RepositoryRoot $capture = [pscustomobject] [ordered] @{ - schemaVersion = 1 + schemaVersion = 2 runId = [guid]::NewGuid().ToString('D') module = $candidate.module + source = $candidate.source package = $candidate.package } $stagedCandidatePath = "$candidatePath.tmp-$PID-$([guid]::NewGuid().ToString('N'))" @@ -295,7 +338,7 @@ catch { throw "The pre-test candidate capture is unreadable: $($_.Exception.Message)" } $parsedRunId = [guid]::Empty -if ([int] $captured.schemaVersion -ne 1 -or +if ([int] $captured.schemaVersion -ne 2 -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.' @@ -335,8 +378,9 @@ $postGateCandidate = Get-GraphKitReleaseCandidateState -Root $RepositoryRoot Assert-GraphKitReleaseCandidateUnchanged -Captured $captured -Current $postGateCandidate $releaseProof = [pscustomobject] [ordered] @{ - schemaVersion = 1 + schemaVersion = 2 runId = [string] $captured.runId + source = $captured.source module = $captured.module package = $captured.package testRun = [pscustomobject] [ordered] @{ diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 38631ec..998da3d 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -87,8 +87,12 @@ 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 + $proofSourceDiffHash = [string] $proof.source.diffSha256 $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 @@ -106,11 +110,29 @@ catch { } $parsedRunId = [guid]::Empty -if ($proofSchemaVersion -ne 1 -or +if ($proofSchemaVersion -ne 2 -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 ($proofSourceClean) { + if (-not [string]::IsNullOrEmpty($proofSourceDiffHash)) { + throw "The tested release proof '$ProofPath' records a diff hash for a clean source state." + } +} +elseif ($proofSourceDiffHash -notmatch '^[0-9a-f]{64}$') { + throw "The tested release proof '$ProofPath' has no valid dirty-source diff hash." +} +$expectedProofVersion = "$proofModuleBaseVersion-r8.g$($proofSourceRevision.Substring(0, 12))" +if (-not $proofSourceClean) { + $expectedProofVersion += ".d$($proofSourceDiffHash.Substring(0, 12))" +} +if ($proofModuleBaseVersion -notmatch '^\d+\.\d+\.\d+$' -or $proofModuleVersion -cne $expectedProofVersion) { + throw "The tested release proof '$ProofPath' does not bind its module version to source provenance." +} if ($proofModuleName -cne $moduleName -or $proofModuleVersion -cne $moduleVersion) { throw "The tested release proof names '$proofModuleName' $proofModuleVersion, not '$moduleName' $moduleVersion." } @@ -152,7 +174,7 @@ if ($proofFileMap.Count -eq 0) { throw 'The tested release proof records zero shipped module files.' } -$builtModuleDirectory = Join-Path $RepositoryRoot "output/module/GraphKit/$moduleVersion" +$builtModuleDirectory = Join-Path $RepositoryRoot "output/module/GraphKit/$proofModuleBaseVersion" if (-not (Test-Path -LiteralPath $builtModuleDirectory -PathType Container)) { throw "The built module directory '$builtModuleDirectory' is missing." } @@ -201,8 +223,12 @@ 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 $moduleVersion) { - throw "The built GraphKit.psd1 declares version '$($builtManifest.ModuleVersion)', not proof version '$moduleVersion'." +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'." } $currentPackageHash = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() @@ -217,8 +243,7 @@ try { foreach ($wrapperPath in @( "$moduleName.nuspec", '[Content_Types].xml', - '_rels/.rels', - 'package/services/metadata/core-properties/nuget.psmdcp' + '_rels/.rels' )) { $null = $wrapperPaths.Add($wrapperPath) } @@ -252,6 +277,16 @@ try { } } + $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($_) }) @@ -365,11 +400,22 @@ try { 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 retains only the manifest-declared 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 @@ -741,6 +787,7 @@ Write-Host "VERIFIED TESTED RELEASE: $moduleName $moduleVersion; $($proofFileMap [pscustomobject] [ordered] @{ ModuleName = $moduleName Version = $moduleVersion + BaseVersion = $proofModuleBaseVersion PackageName = $package.Name PackageSha256 = $proofPackageHash ProofSha256 = $initialProofHash diff --git a/source/GraphKit.psd1 b/source/GraphKit.psd1 index 8a3a5c4..35717ca 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 = @() @@ -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 diff --git a/tests/QA/PackageDependencies.tests.ps1 b/tests/QA/PackageDependencies.tests.ps1 index 01d346e..d5d48f8 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,7 +45,7 @@ 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("'", "''"))' @@ -102,7 +103,7 @@ Describe 'Packed GraphKit dependency contract' -Tag 'QA' { $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 $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' diff --git a/tests/QA/PackageIdentity.tests.ps1 b/tests/QA/PackageIdentity.tests.ps1 index 3aea774..225d568 100644 --- a/tests/QA/PackageIdentity.tests.ps1 +++ b/tests/QA/PackageIdentity.tests.ps1 @@ -2,9 +2,24 @@ 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:revision = (& git -C $script:repoRoot rev-parse HEAD).Trim().ToLowerInvariant() + $script:diff = (& git -C $script:repoRoot diff --binary HEAD) + $script:dirtySuffix = if ([string]::IsNullOrEmpty($script:diff)) { + '' + } + else { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($script:diff) + $hash = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + ".d$($hash.Substring(0, 12))" + } + $script:expectedVersion = "$($script:baseVersion)-$($script:train).g$($script:revision.Substring(0, 12))$($script:dirtySuffix)" + $script:expectedPrerelease = $script:expectedVersion.Substring($script:baseVersion.Length + 1) + $script:versionScriptPath = Join-Path $script:repoRoot 'scripts/Get-GraphKitTrainVersion.ps1' + $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 +46,45 @@ 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 + } } - 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/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index ef5367b..5bbd51f 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -101,12 +101,15 @@ BeforeAll { [string] $PesterResult, [int] $Passed = -1, [bool] $Executed = $true, + [switch] $ForGenerator, [int] $Total = 896 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) - $version = '9.9.9' - $moduleDir = Join-Path $fixtureRoot "output/module/GraphKit/$version" + $baseVersion = '0.4.0' + $revision = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + $version = "$baseVersion-r8.g$($revision.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' @@ -116,18 +119,32 @@ BeforeAll { -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') + if ($ForGenerator) { + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/Get-GraphKitTrainVersion.ps1') ` + -Destination (Join-Path $scriptsDir 'Get-GraphKitTrainVersion.ps1') + Set-Content -LiteralPath (Join-Path $fixtureRoot '.gitignore') -Value "output/`n" -NoNewline -Encoding utf8NoBOM + & git -C $fixtureRoot init --quiet + & git -C $fixtureRoot add .gitignore scripts + & 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) + $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 = '$version' + ModuleVersion = '$baseVersion' GUID = '12345678-1234-1234-9234-123456789abc' Author = 'Fixture Author' CompanyName = 'Fixture Company' @@ -139,6 +156,7 @@ BeforeAll { Tags = @('Fixture', 'Graph') LicenseUri = 'https://opensource.org/licenses/MIT' ReleaseNotes = 'Fixture release notes.' + Prerelease = '$prerelease' } } } "@ @@ -236,11 +254,17 @@ BeforeAll { ) $proofPath = Join-Path $resultsDir 'tested-release-proof.json' [pscustomobject] [ordered] @{ - schemaVersion = 1 + schemaVersion = 2 runId = [guid]::NewGuid().ToString('D') + source = [pscustomobject] [ordered] @{ + revision = $revision + clean = $true + diffSha256 = $null + } module = [pscustomobject] [ordered] @{ name = 'GraphKit' version = $version + baseVersion = $baseVersion files = $moduleFiles } package = [pscustomobject] [ordered] @{ @@ -282,6 +306,7 @@ BeforeAll { [pscustomobject] @{ Root = $fixtureRoot Version = $version + BaseVersion = $baseVersion ModuleDir = $moduleDir PackagePath = $packagePath ProofPath = $proofPath @@ -392,7 +417,7 @@ else { } [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.Version)/GraphKit.psd1" +$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'", @@ -471,6 +496,17 @@ Describe 'Canonical tested release proof' { $result.Output | Should -Match '5 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 'accepts package-serializer trimming of terminal release-note line endings' { $script:fixture = New-GraphKitReleaseProofFixture $manifestPath = Join-Path $script:fixture.ModuleDir 'GraphKit.psd1' @@ -649,7 +685,7 @@ Describe 'Canonical tested release proof' { It 'rejects nuspec drift' -ForEach @( @{ Field = 'id'; Find = 'GraphKit'; Replace = 'OtherModule' } - @{ Field = 'version'; Find = '9.9.9'; Replace = '9.9.8' } + @{ 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' } @@ -658,6 +694,9 @@ Describe 'Canonical tested release proof' { ) { $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 @@ -784,7 +823,7 @@ Describe 'Test workflow release-proof generation' { } It 'capture invalidates old proof and result files before recording the candidate' { - $script:fixture = New-GraphKitReleaseProofFixture + $script:fixture = New-GraphKitReleaseProofFixture -ForGenerator $result = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture @@ -797,7 +836,7 @@ Describe 'Test workflow release-proof generation' { } It 'finalize emits the one proof only after the captured candidate and result pair pass' { - $script:fixture = New-GraphKitReleaseProofFixture + $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 @@ -810,7 +849,9 @@ Describe 'Test workflow release-proof generation' { $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 '9.9.9' + $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 896 $proof.testRun.summary.notRun | Should -Be 0 @@ -818,7 +859,7 @@ Describe 'Test workflow release-proof generation' { } It 'finalize refuses module drift after capture and leaves no tested proof' { - $script:fixture = New-GraphKitReleaseProofFixture + $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 @@ -834,7 +875,7 @@ Describe 'Test workflow release-proof generation' { } It 'finalize refuses a NotRun result and leaves no tested proof' { - $script:fixture = New-GraphKitReleaseProofFixture -NotRun 1 + $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 @@ -902,7 +943,7 @@ Describe 'Both publisher paths consume the canonical proof verifier' { $result = Invoke-GraphKitFixturePublisher -Fixture $script:fixture -Publisher PrivateChannel $result.ExitCode | Should -Be 0 -Because $result.Output - $publishedPackage = Join-Path $script:fixture.Root 'channel/GraphKit.9.9.9.nupkg' + $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 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' { From cc4d4bdd2a23d05e784d607ba45633dabe4afab6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 21:37:44 -0400 Subject: [PATCH 07/79] fix: harden r8 release proof authority --- scripts/Get-GraphKitTrainVersion.ps1 | 131 +++++++++++++++++++-- scripts/New-GraphKitTestedReleaseProof.ps1 | 39 +++--- scripts/Test-GraphKitReleaseProof.ps1 | 25 ++-- source/GraphKit.psd1 | 5 +- tests/QA/BuiltModule.tests.ps1 | 4 +- tests/QA/PackageDependencies.tests.ps1 | 21 ++-- tests/QA/PackageIdentity.tests.ps1 | 14 +-- tests/QA/ReleaseProof.tests.ps1 | 68 +++++++++-- tests/QA/TrainVersion.tests.ps1 | 119 +++++++++++++++++++ 9 files changed, 345 insertions(+), 81 deletions(-) create mode 100644 tests/QA/TrainVersion.tests.ps1 diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index d4f27e0..febb7d0 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -1,29 +1,134 @@ [CmdletBinding()] param( [Parameter(Mandatory)] - [string] $RepositoryRoot + [string] $RepositoryRoot, + + [switch] $AsObject ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 +function Invoke-GraphKitGitBytes { + param( + [Parameter(Mandatory)] [string] $Root, + [Parameter(Mandatory)] [string[]] $Arguments + ) + + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = 'git' + $start.WorkingDirectory = $Root + $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() + $stream = [IO.MemoryStream]::new() + $process.StandardOutput.BaseStream.CopyTo($stream) + $error = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { + throw "git $($Arguments -join ' ') failed: $error" + } + return ,$stream.ToArray() +} + +function Get-GraphKitR8SourceState { + param([Parameter(Mandatory)] [string] $Root) + + # The dirty hash is SHA-256 over this exact, length-framed byte stream: + # ASCII 'GraphKit-R8-source-state-v1' NUL + # ASCII 'patch' UInt64LE(length) normalized git-diff bytes + # zero or more ASCII 'untracked' UInt64LE(path length) raw-path bytes + # UInt64LE(content length) file-content bytes, ordered by raw path bytes + # ASCII 'end' NUL + # The patch disables external/text conversions and fixes path prefixes and diff algorithm. + # `git ls-files --others --exclude-standard -z` includes every non-ignored untracked file. + $patch = Invoke-GraphKitGitBytes -Root $Root -Arguments @( + '-c', 'core.autocrlf=false', '-c', 'core.eol=lf', '-c', 'core.safecrlf=false', + '-c', 'core.quotePath=true', '-c', 'diff.noprefix=false', '-c', 'i18n.logOutputEncoding=UTF-8', + 'diff', '--no-ext-diff', '--no-textconv', '--no-renames', '--diff-algorithm=myers', + '--binary', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD' + ) + $untrackedPathStream = Invoke-GraphKitGitBytes -Root $Root -Arguments @( + 'ls-files', '--others', '--exclude-standard', '-z' + ) + + $untrackedPaths = [Collections.Generic.List[byte[]]]::new() + $offset = 0 + while ($offset -lt $untrackedPathStream.Length) { + $end = [Array]::IndexOf($untrackedPathStream, [byte] 0, $offset) + if ($end -lt 0) { throw 'Git returned an unterminated untracked-path stream.' } + $length = $end - $offset + $path = [byte[]]::new($length) + [Array]::Copy($untrackedPathStream, $offset, $path, 0, $length) + $untrackedPaths.Add($path) + $offset = $end + 1 + } + $orderedPaths = @($untrackedPaths | Sort-Object { [Convert]::ToHexString($_) }) + + $stateBytes = [IO.MemoryStream]::new() + $writeBytes = { + param([Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]] $Bytes) + $stateBytes.Write($Bytes, 0, $Bytes.Length) + } + $writeLength = { + param([Parameter(Mandatory)] [int] $Length) + & $writeBytes ([BitConverter]::GetBytes([uint64] $Length)) + } + + & $writeBytes ([Text.Encoding]::ASCII.GetBytes('GraphKit-R8-source-state-v1')) + & $writeBytes ([byte[]] @(0)) + & $writeBytes ([Text.Encoding]::ASCII.GetBytes('patch')) + & $writeLength $patch.Length + & $writeBytes $patch + foreach ($path in $orderedPaths) { + $relativePath = [Text.Encoding]::UTF8.GetString($path) + $contentPath = Join-Path $Root $relativePath + if (-not (Test-Path -LiteralPath $contentPath -PathType Leaf)) { + throw "Non-ignored untracked path '$relativePath' is not a file." + } + $content = [IO.File]::ReadAllBytes($contentPath) + & $writeBytes ([Text.Encoding]::ASCII.GetBytes('untracked')) + & $writeLength $path.Length + & $writeBytes $path + & $writeLength $content.Length + & $writeBytes $content + } + & $writeBytes ([Text.Encoding]::ASCII.GetBytes('end')) + & $writeBytes ([byte[]] @(0)) + + [pscustomobject] [ordered] @{ + clean = $patch.Length -eq 0 -and $orderedPaths.Count -eq 0 + sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stateBytes.ToArray())).ToLowerInvariant() + } +} + $RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath $base = '0.4.0' $train = 'r8' -$revision = (& git -C $RepositoryRoot rev-parse HEAD).Trim().ToLowerInvariant() -if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^[0-9a-f]{40}$') { +$revisionBytes = Invoke-GraphKitGitBytes -Root $RepositoryRoot -Arguments @('rev-parse', 'HEAD') +$revision = [Text.Encoding]::UTF8.GetString($revisionBytes).Trim().ToLowerInvariant() +if ($revision -notmatch '^[0-9a-f]{40}$') { throw "Cannot resolve a 40-character source revision for '$RepositoryRoot'." } -$diff = (& git -C $RepositoryRoot diff --binary HEAD) -if ($LASTEXITCODE -ne 0) { - throw "Cannot determine whether '$RepositoryRoot' has uncommitted source changes." -} -$suffix = if ([string]::IsNullOrEmpty($diff)) { - '' +$sourceState = Get-GraphKitR8SourceState -Root $RepositoryRoot +$suffix = if ($sourceState.clean) { '' } else { ".d$($sourceState.sha256.Substring(0, 12))" } +$version = "$base-$train.g$($revision.Substring(0, 12))$suffix" + +if ($AsObject) { + [pscustomobject] [ordered] @{ + version = $version + baseVersion = $base + train = $train + revision = $revision + clean = [bool] $sourceState.clean + sourceStateSha256 = $sourceState.sha256 + } } else { - $bytes = [Text.Encoding]::UTF8.GetBytes($diff) - $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() - ".d$($hash.Substring(0, 12))" + $version } -"$base-$train.g$($revision.Substring(0, 12))$suffix" diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index be0d8bc..f8d2720 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -41,26 +41,19 @@ function Get-GraphKitReleaseCandidateState { if (-not (Test-Path -LiteralPath $versionScript -PathType Leaf)) { throw "Release proof requires '$versionScript'." } - $fullVersion = (& $versionScript -RepositoryRoot $Root).Trim() - if ($LASTEXITCODE -ne 0 -or $fullVersion -notmatch '^(?\d+\.\d+\.\d+)-r8\.g(?[0-9a-f]{12})(?:\.d(?[0-9a-f]{12}))?$') { + $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 = $Matches['base'] - $sourceRevision = (& git -C $Root rev-parse HEAD).Trim().ToLowerInvariant() - if ($LASTEXITCODE -ne 0 -or $sourceRevision -notmatch '^[0-9a-f]{40}$') { - throw "Release proof cannot resolve a 40-character source revision for '$Root'." - } - $sourceDiff = (& git -C $Root diff --binary HEAD) - if ($LASTEXITCODE -ne 0) { - throw "Release proof cannot determine the source state for '$Root'." - } - $sourceClean = [string]::IsNullOrEmpty($sourceDiff) - $sourceDiffHash = if ($sourceClean) { - $null - } - else { - $bytes = [Text.Encoding]::UTF8.GetBytes($sourceDiff) - [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + $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' @@ -119,7 +112,7 @@ function Get-GraphKitReleaseCandidateState { source = [pscustomobject] [ordered] @{ revision = $sourceRevision clean = $sourceClean - diffSha256 = $sourceDiffHash + stateSha256 = $sourceStateHash } package = [pscustomobject] [ordered] @{ name = Split-Path -Leaf $packagePath @@ -155,7 +148,7 @@ function Assert-GraphKitReleaseCandidateUnchanged { } if ([string] $Captured.source.revision -cne [string] $Current.source.revision -or [bool] $Captured.source.clean -ne [bool] $Current.source.clean -or - [string] $Captured.source.diffSha256 -cne [string] $Current.source.diffSha256) { + [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 @@ -306,7 +299,7 @@ if ($Stage -eq 'Capture') { $candidate = Get-GraphKitReleaseCandidateState -Root $RepositoryRoot $capture = [pscustomobject] [ordered] @{ - schemaVersion = 2 + schemaVersion = 3 runId = [guid]::NewGuid().ToString('D') module = $candidate.module source = $candidate.source @@ -338,7 +331,7 @@ catch { throw "The pre-test candidate capture is unreadable: $($_.Exception.Message)" } $parsedRunId = [guid]::Empty -if ([int] $captured.schemaVersion -ne 2 -or +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.' @@ -378,7 +371,7 @@ $postGateCandidate = Get-GraphKitReleaseCandidateState -Root $RepositoryRoot Assert-GraphKitReleaseCandidateUnchanged -Captured $captured -Current $postGateCandidate $releaseProof = [pscustomobject] [ordered] @{ - schemaVersion = 2 + schemaVersion = 3 runId = [string] $captured.runId source = $captured.source module = $captured.module diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 998da3d..e6d144a 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -89,7 +89,7 @@ try { $proofRunId = [string] $proof.runId $proofSourceRevision = [string] $proof.source.revision $proofSourceClean = $proof.source.clean - $proofSourceDiffHash = [string] $proof.source.diffSha256 + $proofSourceStateHash = [string] $proof.source.stateSha256 $proofModuleName = [string] $proof.module.name $proofModuleVersion = [string] $proof.module.version $proofModuleBaseVersion = [string] $proof.module.baseVersion @@ -110,7 +110,7 @@ catch { } $parsedRunId = [guid]::Empty -if ($proofSchemaVersion -ne 2 -or +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." @@ -118,21 +118,22 @@ if ($proofSchemaVersion -ne 2 -or if ($proofSourceRevision -notmatch '^[0-9a-f]{40}$' -or $proofSourceClean -isnot [bool]) { throw "The tested release proof '$ProofPath' has invalid source provenance." } -if ($proofSourceClean) { - if (-not [string]::IsNullOrEmpty($proofSourceDiffHash)) { - throw "The tested release proof '$ProofPath' records a diff hash for a clean source state." - } -} -elseif ($proofSourceDiffHash -notmatch '^[0-9a-f]{64}$') { - throw "The tested release proof '$ProofPath' has no valid dirty-source diff hash." +if ($proofSourceStateHash -notmatch '^[0-9a-f]{64}$') { + throw "The tested release proof '$ProofPath' has no valid canonical source-state hash." } -$expectedProofVersion = "$proofModuleBaseVersion-r8.g$($proofSourceRevision.Substring(0, 12))" +$expectedProofVersion = "0.4.0-r8.g$($proofSourceRevision.Substring(0, 12))" if (-not $proofSourceClean) { - $expectedProofVersion += ".d$($proofSourceDiffHash.Substring(0, 12))" + $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 ($proofModuleBaseVersion -notmatch '^\d+\.\d+\.\d+$' -or $proofModuleVersion -cne $expectedProofVersion) { +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." } diff --git a/source/GraphKit.psd1 b/source/GraphKit.psd1 index 35717ca..08223db 100644 --- a/source/GraphKit.psd1 +++ b/source/GraphKit.psd1 @@ -54,6 +54,7 @@ PowerShellVersion = '7.4' RequiredModules = @( # MSAL delivery vehicle only - Connect-MgGraph is never called. See the design spec. @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' } + @{ ModuleName = 'Microsoft.PowerShell.SecretManagement'; ModuleVersion = '1.1.2' } ) # Assemblies that must be loaded prior to importing this module @@ -145,8 +146,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 remains a runtime RequiredModule even though vault-backed + # paths validate vault availability only when they are used. # ExternalModuleDependencies = @() } # End of PSData hashtable diff --git a/tests/QA/BuiltModule.tests.ps1 b/tests/QA/BuiltModule.tests.ps1 index 238f820..f4e25b2 100644 --- a/tests/QA/BuiltModule.tests.ps1 +++ b/tests/QA/BuiltModule.tests.ps1 @@ -37,11 +37,11 @@ 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 both always-required runtime dependencies' { $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 -Contain 'Microsoft.PowerShell.SecretManagement' } It 'registers the format file via FormatsToProcess' { diff --git a/tests/QA/PackageDependencies.tests.ps1 b/tests/QA/PackageDependencies.tests.ps1 index d5d48f8..2f35112 100644 --- a/tests/QA/PackageDependencies.tests.ps1 +++ b/tests/QA/PackageDependencies.tests.ps1 @@ -7,6 +7,7 @@ BeforeAll { $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' + $script:secretManagementPath = Join-Path $script:repoRoot 'output/RequiredModules/Microsoft.PowerShell.SecretManagement/1.1.2' function Get-PackageDependencies { param([Parameter(Mandatory)] [string] $PackagePath) @@ -36,9 +37,11 @@ BeforeAll { $modulePath = Join-Path $Root 'Modules' $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 + $secretManagementDestination = Join-Path $modulePath 'Microsoft.PowerShell.SecretManagement/1.1.2' + $null = New-Item -ItemType Directory -Path $graphKitDestination, $graphAuthDestination, $secretManagementDestination -Force [System.IO.Compression.ZipFile]::ExtractToDirectory($script:packagePath, $graphKitDestination) Copy-Item -Path (Join-Path $script:graphAuthPath '*') -Destination $graphAuthDestination -Recurse -Force + Copy-Item -Path (Join-Path $script:secretManagementPath '*') -Destination $secretManagementDestination -Recurse -Force return $modulePath } @@ -88,16 +91,18 @@ 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 Graph Authentication and SecretManagement as exact NuGet dependencies' { 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' + $dependencies.Count | Should -Be 2 + $dependencyMap = @{} + foreach ($dependency in $dependencies) { $dependencyMap[[string] $dependency.id] = [string] $dependency.version } + $dependencyMap['Microsoft.Graph.Authentication'] | Should -Be '2.38.1' + $dependencyMap['Microsoft.PowerShell.SecretManagement'] | Should -Be '1.1.2' } - It 'imports the isolated artifact and inspects the catalog without loading SecretManagement' { + It 'imports the isolated artifact with its required SecretManagement runtime dependency' { $modulePath = New-IsolatedGraphKitModulePath -Root (Join-Path $TestDrive 'non-vault') $result = Invoke-IsolatedGraphKitProbe -ModulePath $modulePath @@ -105,8 +110,8 @@ Describe 'Packed GraphKit dependency contract' -Tag 'QA' { $result.Data.Imported | Should -BeTrue $result.Data.ModuleBase | Should -Be (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.SecretManagementLoaded | Should -BeTrue -Because 'SecretManagement is restored as a runtime RequiredModule' + $result.Data.SecretManagementAvailable | Should -BeTrue -Because 'SecretManagement is a required runtime package dependency' } } diff --git a/tests/QA/PackageIdentity.tests.ps1 b/tests/QA/PackageIdentity.tests.ps1 index 225d568..c8c8013 100644 --- a/tests/QA/PackageIdentity.tests.ps1 +++ b/tests/QA/PackageIdentity.tests.ps1 @@ -6,19 +6,9 @@ BeforeAll { $script:train = 'r8' $script:sourceManifestPath = Join-Path $script:repoRoot 'source/GraphKit.psd1' - $script:revision = (& git -C $script:repoRoot rev-parse HEAD).Trim().ToLowerInvariant() - $script:diff = (& git -C $script:repoRoot diff --binary HEAD) - $script:dirtySuffix = if ([string]::IsNullOrEmpty($script:diff)) { - '' - } - else { - $bytes = [System.Text.Encoding]::UTF8.GetBytes($script:diff) - $hash = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() - ".d$($hash.Substring(0, 12))" - } - $script:expectedVersion = "$($script:baseVersion)-$($script:train).g$($script:revision.Substring(0, 12))$($script:dirtySuffix)" - $script:expectedPrerelease = $script:expectedVersion.Substring($script:baseVersion.Length + 1) $script:versionScriptPath = Join-Path $script:repoRoot 'scripts/Get-GraphKitTrainVersion.ps1' + $script:expectedVersion = (& $script:versionScriptPath -RepositoryRoot $script:repoRoot).Trim() + $script:expectedPrerelease = $script:expectedVersion.Substring($script:baseVersion.Length + 1) $script:builtManifestPath = Join-Path $script:repoRoot "output/module/GraphKit/$script:baseVersion/GraphKit.psd1" $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$script:expectedVersion.nupkg" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 5bbd51f..fccc139 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -102,13 +102,17 @@ BeforeAll { [int] $Passed = -1, [bool] $Executed = $true, [switch] $ForGenerator, + [string] $BaseVersion = '0.4.0', + [switch] $DirtySource, [int] $Total = 896 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) - $baseVersion = '0.4.0' + $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' @@ -129,9 +133,9 @@ BeforeAll { if ($ForGenerator) { Copy-Item -LiteralPath (Join-Path $script:repoRoot 'scripts/Get-GraphKitTrainVersion.ps1') ` -Destination (Join-Path $scriptsDir 'Get-GraphKitTrainVersion.ps1') - Set-Content -LiteralPath (Join-Path $fixtureRoot '.gitignore') -Value "output/`n" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $fixtureRoot '.gitignore') -Value "output/`nLICENSE`n" -NoNewline -Encoding utf8NoBOM & git -C $fixtureRoot init --quiet - & git -C $fixtureRoot add .gitignore scripts + & git -C $fixtureRoot add .gitignore scripts 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() @@ -254,12 +258,12 @@ BeforeAll { ) $proofPath = Join-Path $resultsDir 'tested-release-proof.json' [pscustomobject] [ordered] @{ - schemaVersion = 2 + schemaVersion = 3 runId = [guid]::NewGuid().ToString('D') source = [pscustomobject] [ordered] @{ revision = $revision - clean = $true - diffSha256 = $null + clean = -not $DirtySource + stateSha256 = if ($DirtySource) { $sourceStateHash } else { ('c' * 64) -join '' } } module = [pscustomobject] [ordered] @{ name = 'GraphKit' @@ -316,17 +320,29 @@ BeforeAll { } function Invoke-GraphKitReleaseProofVerifier { - param([Parameter(Mandatory)] [pscustomobject] $Fixture) + 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 2>&1 | Out-String + -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 } } @@ -507,6 +523,27 @@ Describe 'Canonical tested release proof' { $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' @@ -827,7 +864,7 @@ Describe 'Test workflow release-proof generation' { $result = Invoke-GraphKitReleaseProofGenerator -Fixture $script:fixture -Stage Capture - $result.ExitCode | Should -Be 0 + $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 @@ -842,6 +879,7 @@ Describe 'Test workflow release-proof generation' { (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 @@ -934,6 +972,18 @@ Describe 'Both publisher paths consume the canonical proof verifier' { $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 diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 new file mode 100644 index 0000000..efb04b5 --- /dev/null +++ b/tests/QA/TrainVersion.tests.ps1 @@ -0,0 +1,119 @@ +BeforeAll { + $script:versionScript = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath 'scripts/Get-GraphKitTrainVersion.ps1' + + function Invoke-R8GitBytes { + param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [string[]] $Arguments + ) + + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = 'git' + $start.WorkingDirectory = $RepositoryRoot + $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() + $stream = [IO.MemoryStream]::new() + $process.StandardOutput.BaseStream.CopyTo($stream) + $error = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "git $($Arguments -join ' ') failed: $error" } + return ,$stream.ToArray() + } + + function Get-R8TrainVersionOracle { + param([Parameter(Mandatory)] [string] $RepositoryRoot) + + $revision = [Text.Encoding]::UTF8.GetString((Invoke-R8GitBytes -RepositoryRoot $RepositoryRoot -Arguments @('rev-parse', 'HEAD'))).Trim().ToLowerInvariant() + $patch = Invoke-R8GitBytes -RepositoryRoot $RepositoryRoot -Arguments @( + '-c', 'core.autocrlf=false', '-c', 'core.eol=lf', '-c', 'core.quotePath=true', + 'diff', '--no-ext-diff', '--no-textconv', '--no-renames', '--diff-algorithm=myers', + '--binary', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD' + ) + $pathsRaw = Invoke-R8GitBytes -RepositoryRoot $RepositoryRoot -Arguments @('ls-files', '--others', '--exclude-standard', '-z') + $paths = [Collections.Generic.List[byte[]]]::new() + $offset = 0 + while ($offset -lt $pathsRaw.Length) { + $end = [Array]::IndexOf($pathsRaw, [byte] 0, $offset) + if ($end -lt 0) { throw 'Untracked-path stream is not NUL-terminated.' } + $length = $end - $offset + $path = [byte[]]::new($length) + [Array]::Copy($pathsRaw, $offset, $path, 0, $length) + $paths.Add($path) + $offset = $end + 1 + } + $orderedPaths = @($paths | Sort-Object { [Convert]::ToHexString($_) }) + $stream = [IO.MemoryStream]::new() + $write = { + param([byte[]] $Bytes) + $stream.Write($Bytes, 0, $Bytes.Length) + } + $u64 = { + param([int] $Length) + & $write ([BitConverter]::GetBytes([uint64] $Length)) + } + & $write ([Text.Encoding]::ASCII.GetBytes('GraphKit-R8-source-state-v1')) + & $write ([byte[]] @(0)) + & $write ([Text.Encoding]::ASCII.GetBytes('patch')) + & $u64 $patch.Length + & $write $patch + foreach ($path in $orderedPaths) { + $relativePath = [Text.Encoding]::UTF8.GetString($path) + $content = [IO.File]::ReadAllBytes((Join-Path $RepositoryRoot $relativePath)) + & $write ([Text.Encoding]::ASCII.GetBytes('untracked')) + & $u64 $path.Length + & $write $path + & $u64 $content.Length + & $write $content + } + & $write ([Text.Encoding]::ASCII.GetBytes('end')) + & $write ([byte[]] @(0)) + $stateHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant() + $clean = $patch.Length -eq 0 -and $orderedPaths.Count -eq 0 + $suffix = if ($clean) { '' } else { ".d$($stateHash.Substring(0, 12))" } + return "0.4.0-r8.g$($revision.Substring(0, 12))$suffix" + } + + 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" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked.ps1') -Value "'tracked'`n" -NoNewline -Encoding utf8NoBOM + & git -C $root init --quiet + & 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 + } +} + +Describe 'GraphKit R8 train source-state identity' -Tag 'QA' { + It 'marks a non-ignored untracked package-producing file dirty and binds its bytes' { + $root = New-R8TrainVersionFixture + Set-Content -LiteralPath (Join-Path $root 'source/Private/Untracked.ps1') -Value "'untracked bytes'`n" -NoNewline -Encoding utf8NoBOM + + $actual = (& $script:versionScript -RepositoryRoot $root).Trim() + $expected = Get-R8TrainVersionOracle -RepositoryRoot $root + + $actual | Should -Be $expected + $actual | Should -Match '\.d[0-9a-f]{12}$' + } + + It 'uses the same canonical source-state bytes despite Git diff and line-ending configuration' { + $root = New-R8TrainVersionFixture + Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked.ps1') -Value "'changed'`r`n" -NoNewline -Encoding utf8NoBOM + $expected = Get-R8TrainVersionOracle -RepositoryRoot $root + $first = (& $script:versionScript -RepositoryRoot $root).Trim() + + & git -C $root config diff.noprefix true + & git -C $root config core.autocrlf true + & git -C $root config core.eol crlf + $second = (& $script:versionScript -RepositoryRoot $root).Trim() + + $first | Should -Be $expected + $second | Should -Be $expected + } +} From 208736cb6b56f5098f2900ecf2ccafa213511135 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 21:57:20 -0400 Subject: [PATCH 08/79] fix: canonicalize r8 dirty source entries --- scripts/Get-GraphKitTrainVersion.ps1 | 168 +++++++++++++-------- tests/QA/TrainVersion.tests.ps1 | 218 +++++++++++++++------------ 2 files changed, 227 insertions(+), 159 deletions(-) diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index febb7d0..8f64fef 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -22,7 +22,6 @@ function Invoke-GraphKitGitBytes { $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() @@ -30,91 +29,134 @@ function Invoke-GraphKitGitBytes { $process.StandardOutput.BaseStream.CopyTo($stream) $error = $process.StandardError.ReadToEnd() $process.WaitForExit() - if ($process.ExitCode -ne 0) { - throw "git $($Arguments -join ' ') failed: $error" - } + if ($process.ExitCode -ne 0) { throw "git $($Arguments -join ' ') failed: $error" } return ,$stream.ToArray() } -function Get-GraphKitR8SourceState { - param([Parameter(Mandatory)] [string] $Root) - - # The dirty hash is SHA-256 over this exact, length-framed byte stream: - # ASCII 'GraphKit-R8-source-state-v1' NUL - # ASCII 'patch' UInt64LE(length) normalized git-diff bytes - # zero or more ASCII 'untracked' UInt64LE(path length) raw-path bytes - # UInt64LE(content length) file-content bytes, ordered by raw path bytes - # ASCII 'end' NUL - # The patch disables external/text conversions and fixes path prefixes and diff algorithm. - # `git ls-files --others --exclude-standard -z` includes every non-ignored untracked file. - $patch = Invoke-GraphKitGitBytes -Root $Root -Arguments @( - '-c', 'core.autocrlf=false', '-c', 'core.eol=lf', '-c', 'core.safecrlf=false', - '-c', 'core.quotePath=true', '-c', 'diff.noprefix=false', '-c', 'i18n.logOutputEncoding=UTF-8', - 'diff', '--no-ext-diff', '--no-textconv', '--no-renames', '--diff-algorithm=myers', - '--binary', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD' - ) - $untrackedPathStream = Invoke-GraphKitGitBytes -Root $Root -Arguments @( - 'ls-files', '--others', '--exclude-standard', '-z' +function Get-GraphKitNulPaths { + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]] $Bytes, + [Parameter(Mandatory)] [string] $Source ) - $untrackedPaths = [Collections.Generic.List[byte[]]]::new() + $paths = [Collections.Generic.List[byte[]]]::new() $offset = 0 - while ($offset -lt $untrackedPathStream.Length) { - $end = [Array]::IndexOf($untrackedPathStream, [byte] 0, $offset) - if ($end -lt 0) { throw 'Git returned an unterminated untracked-path stream.' } + while ($offset -lt $Bytes.Length) { + $end = [Array]::IndexOf($Bytes, [byte] 0, $offset) + if ($end -lt 0) { throw "$Source returned an unterminated path stream." } $length = $end - $offset $path = [byte[]]::new($length) - [Array]::Copy($untrackedPathStream, $offset, $path, 0, $length) - $untrackedPaths.Add($path) + [Array]::Copy($Bytes, $offset, $path, 0, $length) + if ($path.Length -eq 0) { throw "$Source returned an empty path." } + $paths.Add($path) $offset = $end + 1 } - $orderedPaths = @($untrackedPaths | Sort-Object { [Convert]::ToHexString($_) }) + return [pscustomobject] @{ paths = @($paths) } +} + +function Get-GraphKitR8SourceState { + param([Parameter(Mandatory)] [string] $Root) + + # SHA-256 is taken over GraphKit-R8-source-entry-state-v2, a domain-separated, + # length-framed byte stream. Each entry is ordered by raw Git path bytes and contains + # its type tag, exact raw path bytes, and exact file bytes. Changed tracked entries are + # reported by `git diff --name-only -z --no-renames HEAD`; non-ignored untracked entries + # come from `git ls-files --others --exclude-standard -z`. The HEAD revision separately + # binds unchanged tracked content. No presentation-form `git diff` bytes participate. + # Symlinks, non-regular entries, invalid UTF-8 paths, and entries that disappear before + # capture fail closed rather than producing an ambiguous source identity. + $trackedPaths = (Get-GraphKitNulPaths -Bytes (Invoke-GraphKitGitBytes -Root $Root -Arguments @( + 'diff', '--name-only', '-z', '--no-renames', 'HEAD' + )) -Source 'git diff --name-only').paths + $untrackedPaths = (Get-GraphKitNulPaths -Bytes (Invoke-GraphKitGitBytes -Root $Root -Arguments @( + 'ls-files', '--others', '--exclude-standard', '-z' + )) -Source 'git ls-files --others').paths + + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + $entries = [Collections.Generic.List[object]]::new() + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + function Add-GraphKitSourceEntry { + param( + [Parameter(Mandatory)] [byte[]] $RawPath, + [Parameter(Mandatory)] [string] $Origin + ) + + $pathKey = [Convert]::ToHexString($RawPath) + if (-not $seen.Add($pathKey)) { throw "Git reported duplicate source path bytes for '$Origin'." } + try { + $relativePath = $strictUtf8.GetString($RawPath) + } + catch { + throw "Git reported a non-strict-UTF-8 source path for '$Origin'." + } + if ([string]::IsNullOrEmpty($relativePath) -or + [IO.Path]::IsPathRooted($relativePath) -or + @($relativePath -split '[\\/]' | Where-Object { $_ -in @('', '.', '..') }).Count -gt 0) { + throw "Git reported an unsafe source path for '$Origin'." + } + $fullPath = Join-Path $Root $relativePath + if ($Origin -eq 'tracked' -and -not (Test-Path -LiteralPath $fullPath)) { + $entries.Add([pscustomobject] @{ path = $RawPath; type = 'tracked-deleted'; content = [byte[]] @() }) + return + } + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + throw "Source entry '$relativePath' disappeared or is not a regular file." + } + try { + $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Source entry '$relativePath' is an unsupported symbolic link." + } + if ($item -isnot [IO.FileInfo]) { + throw "Source entry '$relativePath' is an unsupported non-regular file." + } + $content = [IO.File]::ReadAllBytes($fullPath) + } + catch { + throw "Cannot bind source entry '$relativePath': $($_.Exception.Message)" + } + $entries.Add([pscustomobject] @{ path = $RawPath; type = "$Origin-regular"; content = $content }) + } + + foreach ($path in $trackedPaths) { Add-GraphKitSourceEntry -RawPath $path -Origin 'tracked' } + foreach ($path in $untrackedPaths) { Add-GraphKitSourceEntry -RawPath $path -Origin 'untracked' } + $orderedEntries = @($entries | Sort-Object { [Convert]::ToHexString($_.path) }) - $stateBytes = [IO.MemoryStream]::new() - $writeBytes = { + $stream = [IO.MemoryStream]::new() + $write = { param([Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]] $Bytes) - $stateBytes.Write($Bytes, 0, $Bytes.Length) + $stream.Write($Bytes, 0, $Bytes.Length) } $writeLength = { param([Parameter(Mandatory)] [int] $Length) - & $writeBytes ([BitConverter]::GetBytes([uint64] $Length)) + & $write ([BitConverter]::GetBytes([uint64] $Length)) } - - & $writeBytes ([Text.Encoding]::ASCII.GetBytes('GraphKit-R8-source-state-v1')) - & $writeBytes ([byte[]] @(0)) - & $writeBytes ([Text.Encoding]::ASCII.GetBytes('patch')) - & $writeLength $patch.Length - & $writeBytes $patch - foreach ($path in $orderedPaths) { - $relativePath = [Text.Encoding]::UTF8.GetString($path) - $contentPath = Join-Path $Root $relativePath - if (-not (Test-Path -LiteralPath $contentPath -PathType Leaf)) { - throw "Non-ignored untracked path '$relativePath' is not a file." - } - $content = [IO.File]::ReadAllBytes($contentPath) - & $writeBytes ([Text.Encoding]::ASCII.GetBytes('untracked')) - & $writeLength $path.Length - & $writeBytes $path - & $writeLength $content.Length - & $writeBytes $content + & $write ([Text.Encoding]::ASCII.GetBytes('GraphKit-R8-source-entry-state-v2')) + & $write ([byte[]] @(0)) + foreach ($entry in $orderedEntries) { + $typeBytes = [Text.Encoding]::ASCII.GetBytes([string] $entry.type) + & $write ([Text.Encoding]::ASCII.GetBytes('entry')) + & $writeLength $typeBytes.Length + & $write $typeBytes + & $writeLength $entry.path.Length + & $write $entry.path + & $writeLength $entry.content.Length + & $write $entry.content } - & $writeBytes ([Text.Encoding]::ASCII.GetBytes('end')) - & $writeBytes ([byte[]] @(0)) + & $write ([Text.Encoding]::ASCII.GetBytes('end')) + & $write ([byte[]] @(0)) [pscustomobject] [ordered] @{ - clean = $patch.Length -eq 0 -and $orderedPaths.Count -eq 0 - sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stateBytes.ToArray())).ToLowerInvariant() + clean = $orderedEntries.Count -eq 0 + sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant() } } $RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath $base = '0.4.0' $train = 'r8' -$revisionBytes = Invoke-GraphKitGitBytes -Root $RepositoryRoot -Arguments @('rev-parse', 'HEAD') -$revision = [Text.Encoding]::UTF8.GetString($revisionBytes).Trim().ToLowerInvariant() -if ($revision -notmatch '^[0-9a-f]{40}$') { - throw "Cannot resolve a 40-character source revision for '$RepositoryRoot'." -} +$revision = [Text.Encoding]::UTF8.GetString((Invoke-GraphKitGitBytes -Root $RepositoryRoot -Arguments @('rev-parse', 'HEAD'))).Trim().ToLowerInvariant() +if ($revision -notmatch '^[0-9a-f]{40}$') { throw "Cannot resolve a 40-character source revision for '$RepositoryRoot'." } $sourceState = Get-GraphKitR8SourceState -Root $RepositoryRoot $suffix = if ($sourceState.clean) { '' } else { ".d$($sourceState.sha256.Substring(0, 12))" } $version = "$base-$train.g$($revision.Substring(0, 12))$suffix" @@ -129,6 +171,4 @@ if ($AsObject) { sourceStateSha256 = $sourceState.sha256 } } -else { - $version -} +else { $version } diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index efb04b5..c8dfc30 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -1,119 +1,147 @@ BeforeAll { $script:versionScript = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath 'scripts/Get-GraphKitTrainVersion.ps1' - function Invoke-R8GitBytes { - param( - [Parameter(Mandatory)] [string] $RepositoryRoot, - [Parameter(Mandatory)] [string[]] $Arguments - ) - - $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = 'git' - $start.WorkingDirectory = $RepositoryRoot - $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() - $stream = [IO.MemoryStream]::new() - $process.StandardOutput.BaseStream.CopyTo($stream) - $error = $process.StandardError.ReadToEnd() - $process.WaitForExit() - if ($process.ExitCode -ne 0) { throw "git $($Arguments -join ' ') failed: $error" } - return ,$stream.ToArray() - } - - function Get-R8TrainVersionOracle { - param([Parameter(Mandatory)] [string] $RepositoryRoot) - - $revision = [Text.Encoding]::UTF8.GetString((Invoke-R8GitBytes -RepositoryRoot $RepositoryRoot -Arguments @('rev-parse', 'HEAD'))).Trim().ToLowerInvariant() - $patch = Invoke-R8GitBytes -RepositoryRoot $RepositoryRoot -Arguments @( - '-c', 'core.autocrlf=false', '-c', 'core.eol=lf', '-c', 'core.quotePath=true', - 'diff', '--no-ext-diff', '--no-textconv', '--no-renames', '--diff-algorithm=myers', - '--binary', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD' - ) - $pathsRaw = Invoke-R8GitBytes -RepositoryRoot $RepositoryRoot -Arguments @('ls-files', '--others', '--exclude-standard', '-z') - $paths = [Collections.Generic.List[byte[]]]::new() - $offset = 0 - while ($offset -lt $pathsRaw.Length) { - $end = [Array]::IndexOf($pathsRaw, [byte] 0, $offset) - if ($end -lt 0) { throw 'Untracked-path stream is not NUL-terminated.' } - $length = $end - $offset - $path = [byte[]]::new($length) - [Array]::Copy($pathsRaw, $offset, $path, 0, $length) - $paths.Add($path) - $offset = $end + 1 - } - $orderedPaths = @($paths | Sort-Object { [Convert]::ToHexString($_) }) - $stream = [IO.MemoryStream]::new() - $write = { - param([byte[]] $Bytes) - $stream.Write($Bytes, 0, $Bytes.Length) - } - $u64 = { - param([int] $Length) - & $write ([BitConverter]::GetBytes([uint64] $Length)) - } - & $write ([Text.Encoding]::ASCII.GetBytes('GraphKit-R8-source-state-v1')) - & $write ([byte[]] @(0)) - & $write ([Text.Encoding]::ASCII.GetBytes('patch')) - & $u64 $patch.Length - & $write $patch - foreach ($path in $orderedPaths) { - $relativePath = [Text.Encoding]::UTF8.GetString($path) - $content = [IO.File]::ReadAllBytes((Join-Path $RepositoryRoot $relativePath)) - & $write ([Text.Encoding]::ASCII.GetBytes('untracked')) - & $u64 $path.Length - & $write $path - & $u64 $content.Length - & $write $content - } - & $write ([Text.Encoding]::ASCII.GetBytes('end')) - & $write ([byte[]] @(0)) - $stateHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant() - $clean = $patch.Length -eq 0 -and $orderedPaths.Count -eq 0 - $suffix = if ($clean) { '' } else { ".d$($stateHash.Substring(0, 12))" } - return "0.4.0-r8.g$($revision.Substring(0, 12))$suffix" - } - 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" -NoNewline -Encoding utf8NoBOM - Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked.ps1') -Value "'tracked'`n" -NoNewline -Encoding utf8NoBOM + 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 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) + + $output = & pwsh -NoLogo -NoProfile -File $script:versionScript -RepositoryRoot $RepositoryRoot 2>&1 | Out-String + [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output.Trim() + } + } } -Describe 'GraphKit R8 train source-state identity' -Tag 'QA' { - It 'marks a non-ignored untracked package-producing file dirty and binds its bytes' { +Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { + 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 - $actual = (& $script:versionScript -RepositoryRoot $root).Trim() - $expected = Get-R8TrainVersionOracle -RepositoryRoot $root + $result = Get-R8TrainVersion -RepositoryRoot $root - $actual | Should -Be $expected - $actual | Should -Match '\.d[0-9a-f]{12}$' + $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 'uses the same canonical source-state bytes despite Git diff and line-ending configuration' { + 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.ps1') -Value "'changed'`r`n" -NoNewline -Encoding utf8NoBOM - $expected = Get-R8TrainVersionOracle -RepositoryRoot $root - $first = (& $script:versionScript -RepositoryRoot $root).Trim() + 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 - & git -C $root config diff.noprefix true - & git -C $root config core.autocrlf true - & git -C $root config core.eol crlf - $second = (& $script:versionScript -RepositoryRoot $root).Trim() + $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 + Remove-Item -LiteralPath $firstPath, $secondPath -Force + Set-Content -LiteralPath $firstPath -Value "'a'`n" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath $secondPath -Value "'z'`n" -NoNewline -Encoding utf8NoBOM + $reverse = Get-R8TrainVersion -RepositoryRoot $root + + $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' -Skip:$IsWindows { + $root = New-R8TrainVersionFixture + $shimDirectory = Join-Path $TestDrive ('git-missing-shim-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $shimDirectory -Force + $shimPath = Join-Path $shimDirectory 'git' + Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value @' +#!/bin/sh +if [ "$1" = "ls-files" ]; then + printf 'source/Private/disappeared.ps1\0' + exit 0 +fi +exec /usr/bin/git "$@" +'@ + & /bin/chmod +x $shimPath + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { + $env:PATH = $savedPath + } + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'disappeared|regular file' + } + + It 'fails closed for a non-UTF-8 Unix path' -Skip:$IsWindows { + $root = New-R8TrainVersionFixture + $shimDirectory = Join-Path $TestDrive ('git-shim-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $shimDirectory -Force + $shimPath = Join-Path $shimDirectory 'git' + Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value @' +#!/bin/sh +if [ "$1" = "ls-files" ]; then + printf '\377\0' + exit 0 +fi +exec /usr/bin/git "$@" +'@ + & /bin/chmod +x $shimPath + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { + $env:PATH = $savedPath + } - $first | Should -Be $expected - $second | Should -Be $expected + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'UTF-8|path' } } From c934739170627ce28906700c48e437ef518d174e Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 22:27:38 -0400 Subject: [PATCH 09/79] fix: harden r8 source inventory --- scripts/Get-GraphKitTrainVersion.ps1 | 310 ++++++++++++++------------- tests/QA/TrainVersion.tests.ps1 | 238 +++++++++++++++++++- 2 files changed, 398 insertions(+), 150 deletions(-) diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index 8f64fef..d6c829a 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -1,174 +1,188 @@ [CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $RepositoryRoot, - - [switch] $AsObject -) +param([Parameter(Mandatory)][string] $RepositoryRoot, [switch] $AsObject) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 function Invoke-GraphKitGitBytes { - param( - [Parameter(Mandatory)] [string] $Root, - [Parameter(Mandatory)] [string[]] $Arguments - ) - + 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.RedirectStandardOutput = $true - $start.RedirectStandardError = $true + $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() - $stream = [IO.MemoryStream]::new() - $process.StandardOutput.BaseStream.CopyTo($stream) - $error = $process.StandardError.ReadToEnd() - $process.WaitForExit() - if ($process.ExitCode -ne 0) { throw "git $($Arguments -join ' ') failed: $error" } - return ,$stream.ToArray() + $process = [Diagnostics.Process]::new(); $process.StartInfo = $start; $null = $process.Start() + if ($InputBytes.Length) { $process.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) } + $process.StandardInput.Close(); $output = [IO.MemoryStream]::new(); $process.StandardOutput.BaseStream.CopyTo($output) + $error = $process.StandardError.ReadToEnd(); $process.WaitForExit() + if ($process.ExitCode -notin $AllowedExitCodes) { throw "git $($Arguments -join ' ') failed: $error" } + return ,$output.ToArray() } -function Get-GraphKitNulPaths { - param( - [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]] $Bytes, - [Parameter(Mandatory)] [string] $Source - ) +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 +} - $paths = [Collections.Generic.List[byte[]]]::new() - $offset = 0 +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 path stream." } - $length = $end - $offset - $path = [byte[]]::new($length) - [Array]::Copy($Bytes, $offset, $path, 0, $length) - if ($path.Length -eq 0) { throw "$Source returned an empty path." } - $paths.Add($path) - $offset = $end + 1 + 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] @{ paths = @($paths) } + return [pscustomobject] @{ records = @($records) } } -function Get-GraphKitR8SourceState { - param([Parameter(Mandatory)] [string] $Root) - - # SHA-256 is taken over GraphKit-R8-source-entry-state-v2, a domain-separated, - # length-framed byte stream. Each entry is ordered by raw Git path bytes and contains - # its type tag, exact raw path bytes, and exact file bytes. Changed tracked entries are - # reported by `git diff --name-only -z --no-renames HEAD`; non-ignored untracked entries - # come from `git ls-files --others --exclude-standard -z`. The HEAD revision separately - # binds unchanged tracked content. No presentation-form `git diff` bytes participate. - # Symlinks, non-regular entries, invalid UTF-8 paths, and entries that disappear before - # capture fail closed rather than producing an ambiguous source identity. - $trackedPaths = (Get-GraphKitNulPaths -Bytes (Invoke-GraphKitGitBytes -Root $Root -Arguments @( - 'diff', '--name-only', '-z', '--no-renames', 'HEAD' - )) -Source 'git diff --name-only').paths - $untrackedPaths = (Get-GraphKitNulPaths -Bytes (Invoke-GraphKitGitBytes -Root $Root -Arguments @( - 'ls-files', '--others', '--exclude-standard', '-z' - )) -Source 'git ls-files --others').paths - - $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) - $entries = [Collections.Generic.List[object]]::new() - $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) - function Add-GraphKitSourceEntry { - param( - [Parameter(Mandatory)] [byte[]] $RawPath, - [Parameter(Mandatory)] [string] $Origin - ) - - $pathKey = [Convert]::ToHexString($RawPath) - if (-not $seen.Add($pathKey)) { throw "Git reported duplicate source path bytes for '$Origin'." } - try { - $relativePath = $strictUtf8.GetString($RawPath) - } - catch { - throw "Git reported a non-strict-UTF-8 source path for '$Origin'." - } - if ([string]::IsNullOrEmpty($relativePath) -or - [IO.Path]::IsPathRooted($relativePath) -or - @($relativePath -split '[\\/]' | Where-Object { $_ -in @('', '.', '..') }).Count -gt 0) { - throw "Git reported an unsafe source path for '$Origin'." - } - $fullPath = Join-Path $Root $relativePath - if ($Origin -eq 'tracked' -and -not (Test-Path -LiteralPath $fullPath)) { - $entries.Add([pscustomobject] @{ path = $RawPath; type = 'tracked-deleted'; content = [byte[]] @() }) - return - } - if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { - throw "Source entry '$relativePath' disappeared or is not a regular file." - } - try { - $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop - if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Source entry '$relativePath' is an unsupported symbolic link." - } - if ($item -isnot [IO.FileInfo]) { - throw "Source entry '$relativePath' is an unsupported non-regular file." - } - $content = [IO.File]::ReadAllBytes($fullPath) - } - catch { - throw "Cannot bind source entry '$relativePath': $($_.Exception.Message)" - } - $entries.Add([pscustomobject] @{ path = $RawPath; type = "$Origin-regular"; content = $content }) - } +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 } +} - foreach ($path in $trackedPaths) { Add-GraphKitSourceEntry -RawPath $path -Origin 'tracked' } - foreach ($path in $untrackedPaths) { Add-GraphKitSourceEntry -RawPath $path -Origin 'untracked' } - $orderedEntries = @($entries | Sort-Object { [Convert]::ToHexString($_.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." } +} - $stream = [IO.MemoryStream]::new() - $write = { - param([Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]] $Bytes) - $stream.Write($Bytes, 0, $Bytes.Length) +function ConvertFrom-GraphKitTree { param([byte[]] $Bytes) + $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 -notmatch '^(?[0-7]{6}) (?blob|commit) (?[0-9a-f]{40,64})$') { throw 'git ls-tree returned an unsupported entry header.' } + 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' } - $writeLength = { - param([Parameter(Mandatory)] [int] $Length) - & $write ([BitConverter]::GetBytes([uint64] $Length)) - } - & $write ([Text.Encoding]::ASCII.GetBytes('GraphKit-R8-source-entry-state-v2')) - & $write ([byte[]] @(0)) - foreach ($entry in $orderedEntries) { - $typeBytes = [Text.Encoding]::ASCII.GetBytes([string] $entry.type) - & $write ([Text.Encoding]::ASCII.GetBytes('entry')) - & $writeLength $typeBytes.Length - & $write $typeBytes - & $writeLength $entry.path.Length - & $write $entry.path - & $writeLength $entry.content.Length - & $write $entry.content + return $map +} + +function ConvertFrom-GraphKitIndex { param([byte[]] $Bytes) + $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 -notmatch '^(?[0-7]{6}) (?[0-9a-f]{40,64}) (?[0-3])$') { throw 'git ls-files --stage returned an unsupported entry header.' } + 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' } - & $write ([Text.Encoding]::ASCII.GetBytes('end')) - & $write ([byte[]] @(0)) + 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 +} - [pscustomobject] [ordered] @{ - clean = $orderedEntries.Count -eq 0 - sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant() +function Initialize-GraphKitNoFollowSupport { + if (-not $IsWindows -and -not ('GraphKit.R8.NoFollow' -as [type])) { Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +namespace GraphKit.R8 { + public sealed class Metadata { public int Mode; public string Identity; } + public static class NoFollow { + [DllImport("libc", SetLastError=true)] static extern int lstat(string path, IntPtr buffer); + [DllImport("libc", SetLastError=true)] static extern int fstat(int fd, IntPtr buffer); + [DllImport("libc", SetLastError=true)] static extern int open(string path, int flags); + static bool Mac { get { return RuntimeInformation.IsOSPlatform(OSPlatform.OSX); } } + static Metadata Decode(byte[] b) { + if (Mac) return new Metadata { Mode=BitConverter.ToUInt16(b,4), Identity=BitConverter.ToUInt32(b,0).ToString("x8")+":"+BitConverter.ToUInt64(b,8).ToString("x16") }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return new Metadata { Mode=BitConverter.ToInt32(b,24), Identity=BitConverter.ToUInt64(b,0).ToString("x16")+":"+BitConverter.ToUInt64(b,8).ToString("x16") }; + throw new PlatformNotSupportedException("No-follow source capture is unavailable on this platform."); + } + static Metadata Stat(Func f) { var b=new byte[512]; var h=GCHandle.Alloc(b,GCHandleType.Pinned); try { if(f(h.AddrOfPinnedObject())!=0) throw new Win32Exception(Marshal.GetLastWin32Error()); return Decode(b); } finally { h.Free(); } } + public static Metadata LStat(string path) { return Stat(p => lstat(path,p)); } + public static SafeFileHandle Open(string path, out Metadata metadata) { int flags=Mac ? 0x104 : 0x20800; int fd=open(path,flags); if(fd<0) throw new Win32Exception(Marshal.GetLastWin32Error()); try { metadata=Stat(p => fstat(fd,p)); return new SafeFileHandle((IntPtr)fd,true); } catch { throw; } } + } +} +'@ } +} + +function Get-GraphKitFileType { param([int] $Mode) return ($Mode -band 0xF000) } +function Get-GraphKitMode { param([int] $Mode) return [Convert]::ToString($Mode, 8).PadLeft(6, '0') } + +function Get-GraphKitWorktreeEntry { param([string] $Root, [byte[]] $RawPath, [Text.UTF8Encoding] $Utf8) + $relative = Get-GraphKitRelativePath $RawPath $Utf8; $fullPath = Join-Path $Root $relative + if ($IsWindows) { + if (-not [IO.File]::Exists($fullPath)) { return [pscustomobject] @{ type='missing'; mode=''; identity=''; content=[byte[]] @() } } + $before = Get-Item -LiteralPath $fullPath -Force + if (($before.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $before -isnot [IO.FileInfo]) { throw "Source entry '$relative' is an unsupported symbolic link or special file." } + $content = [IO.File]::ReadAllBytes($fullPath); $after = Get-Item -LiteralPath $fullPath -Force + if (($after.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $after -isnot [IO.FileInfo] -or $before.Length -ne $after.Length -or $before.LastWriteTimeUtc -ne $after.LastWriteTimeUtc -or $content.Length -ne $after.Length) { throw "Source entry '$relative' changed during capture." } + return [pscustomobject] @{ type='regular'; mode='100644'; identity=$before.FullName; content=$content } + } + try { $before = [GraphKit.R8.NoFollow]::LStat($fullPath) } catch [ComponentModel.Win32Exception] { return [pscustomobject] @{ type='missing'; mode=''; identity=''; content=[byte[]] @() } } + $beforeType = Get-GraphKitFileType $before.Mode + if ($beforeType -eq 0xA000) { throw "Source entry '$relative' is an unsupported symbolic link." } + if ($beforeType -ne 0x8000) { throw "Source entry '$relative' is an unsupported special/non-regular file." } + $opened = $null + try { $handle = [GraphKit.R8.NoFollow]::Open($fullPath, [ref] $opened) } catch { throw "Cannot no-follow open source entry '$relative': $($_.Exception.Message)" } + try { + if ((Get-GraphKitFileType $opened.Mode) -ne 0x8000 -or $opened.Mode -ne $before.Mode -or $opened.Identity -ne $before.Identity) { throw "Source entry '$relative' changed during no-follow open." } + $file = [IO.FileStream]::new($handle, [IO.FileAccess]::Read); try { $buffer = [IO.MemoryStream]::new(); $file.CopyTo($buffer); $content = $buffer.ToArray() } finally { $file.Dispose() } } + finally { if ($handle) { $handle.Dispose() } } + try { $after = [GraphKit.R8.NoFollow]::LStat($fullPath) } catch { throw "Source entry '$relative' disappeared during capture." } + if ((Get-GraphKitFileType $after.Mode) -ne 0x8000 -or $after.Mode -ne $before.Mode -or $after.Identity -ne $before.Identity) { throw "Source entry '$relative' changed during no-follow metadata capture." } + return [pscustomobject] @{ type='regular'; mode=(Get-GraphKitMode $before.Mode); identity=$before.Identity; content=$content } +} + +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) + if ($IsWindows) { return [byte[]] @() } + $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 '\\', '/')); $meta = [GraphKit.R8.NoFollow]::LStat($fullPath) + if ((Get-GraphKitFileType $meta.Mode) -eq 0x4000) { $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() } -$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath -$base = '0.4.0' -$train = 'r8' -$revision = [Text.Encoding]::UTF8.GetString((Invoke-GraphKitGitBytes -Root $RepositoryRoot -Arguments @('rev-parse', 'HEAD'))).Trim().ToLowerInvariant() -if ($revision -notmatch '^[0-9a-f]{40}$') { throw "Cannot resolve a 40-character source revision for '$RepositoryRoot'." } -$sourceState = Get-GraphKitR8SourceState -Root $RepositoryRoot -$suffix = if ($sourceState.clean) { '' } else { ".d$($sourceState.sha256.Substring(0, 12))" } -$version = "$base-$train.g$($revision.Substring(0, 12))$suffix" - -if ($AsObject) { - [pscustomobject] [ordered] @{ - version = $version - baseVersion = $base - train = $train - revision = $revision - clean = [bool] $sourceState.clean - sourceStateSha256 = $sourceState.sha256 +function Get-GraphKitInventory { param([string] $Root, [Text.UTF8Encoding] $Utf8) + $headBytes = Invoke-GraphKitGitBytes $Root @('ls-tree','-r','-z','HEAD') + $indexBytes = Invoke-GraphKitGitBytes $Root @('ls-files','--stage','-z') + $untrackedBytes = Invoke-GraphKitGitBytes $Root @('ls-files','--others','--exclude-standard','-z') + $head = ConvertFrom-GraphKitTree $headBytes; $index = ConvertFrom-GraphKitIndex $indexBytes + $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] @{ headBytes=$headBytes; indexBytes=$indexBytes; untrackedBytes=$untrackedBytes; head=$head; index=$index } } -else { $version } + +function Get-GraphKitR8SourceState { param([string] $Root) + # v3: 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/bytes. + # Snapshots before/after reads and a second no-follow content read make source races fatal. + $utf8=[Text.UTF8Encoding]::new($false,$true); Initialize-GraphKitNoFollowSupport + $format=[Text.Encoding]::ASCII.GetString((Invoke-GraphKitGitBytes $Root @('rev-parse','--show-object-format'))).Trim().ToLowerInvariant(); if($format -notin @('sha1','sha256')){throw "Unsupported Git object format '$format'."} + $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})} + $captured=[Collections.Generic.List[object]]::new();foreach($record in @($records.Values|Sort-Object{[Convert]::ToHexString($_.path)})){$worktree=Get-GraphKitWorktreeEntry $Root $record.path $utf8;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 $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.headBytes $after.headBytes) -or -not(Test-GraphKitBytesEqual $before.indexBytes $after.indexBytes) -or -not(Test-GraphKitBytesEqual $before.untrackedBytes $after.untrackedBytes)){throw 'Git source inventory changed during capture; refusing to emit a train version.'} + foreach($entry in $captured){$again=Get-GraphKitWorktreeEntry $Root $entry.path $utf8;if($again.type -ne $entry.worktree.type -or $again.mode -ne $entry.worktree.mode -or $again.identity -ne $entry.worktree.identity -or -not(Test-GraphKitBytesEqual $again.content $entry.worktree.content)){throw 'Source entry changed during capture; refusing to emit a train version.'}} + $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-v3'));&$write([byte[]]@(0));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-content' $entry.worktree.content};&$write([Text.Encoding]::ASCII.GetBytes('end'));&$write([byte[]]@(0)) + [pscustomobject]@{clean=[bool]$clean;sha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant()} +} + +$RepositoryRoot=(Resolve-Path -LiteralPath $RepositoryRoot).ProviderPath;$base='0.4.0';$train='r8';$revision=[Text.Encoding]::UTF8.GetString((Invoke-GraphKitGitBytes $RepositoryRoot @('rev-parse','HEAD'))).Trim().ToLowerInvariant();if($revision -notmatch '^[0-9a-f]{40}$'){throw "Cannot resolve a 40-character source revision for '$RepositoryRoot'."};$state=Get-GraphKitR8SourceState $RepositoryRoot;$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/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index c8dfc30..f6a390a 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -22,6 +22,52 @@ BeforeAll { Output = $output.Trim() } } + + function Get-R8TrainVersionWithTimeout { + param( + [Parameter(Mandatory)] [string] $RepositoryRoot, + [Parameter(Mandatory)] [int] $TimeoutMilliseconds + ) + + $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($script:versionScript) + $null = $start.ArgumentList.Add('-RepositoryRoot') + $null = $start.ArgumentList.Add($RepositoryRoot) + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + $null = $process.Start() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + $process.Kill($true) + $process.WaitForExit() + return [pscustomobject] @{ Running = $true; ExitCode = $null; Output = '' } + } + [pscustomobject] @{ + Running = $false + ExitCode = $process.ExitCode + Output = ($process.StandardOutput.ReadToEnd() + $process.StandardError.ReadToEnd()).Trim() + } + } + + function New-R8GitShim { + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Body + ) + + $shimDirectory = Join-Path $TestDrive ("git-shim-$Name-" + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $shimDirectory -Force + $shimPath = Join-Path $shimDirectory 'git' + Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value $Body + & /bin/chmod +x $shimPath + return $shimDirectory + } } Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { @@ -98,7 +144,7 @@ Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { $shimPath = Join-Path $shimDirectory 'git' Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value @' #!/bin/sh -if [ "$1" = "ls-files" ]; then +if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then printf 'source/Private/disappeared.ps1\0' exit 0 fi @@ -125,7 +171,7 @@ exec /usr/bin/git "$@" $shimPath = Join-Path $shimDirectory 'git' Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value @' #!/bin/sh -if [ "$1" = "ls-files" ]; then +if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then printf '\377\0' exit 0 fi @@ -144,4 +190,192 @@ exec /usr/bin/git "$@" $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'UTF-8|path' } + + It 'marks an executable-mode-only tracked change dirty even when core.filemode is false' -Skip:$IsWindows { + $root = New-R8TrainVersionFixture + $path = Join-Path $root 'source/Private/Tracked-One.ps1' + & /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 valid untracked path containing tabs, newlines, and non-ASCII bytes' -Skip:$IsWindows { + $root = New-R8TrainVersionFixture + $path = Join-Path $root "source/Private/tab`tline`n雪.ps1" + 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' -Skip:$IsWindows { + $root = New-R8TrainVersionFixture + $shimDirectory = New-R8GitShim -Name 'duplicate-index' -Body @' +#!/bin/sh +if [ "$1" = "ls-files" ] && [ "$2" = "--stage" ]; then + item=$(/usr/bin/git "$@") + printf '%s\0%s\0' "$item" "$item" + exit 0 +fi +exec /usr/bin/git "$@" +'@ + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'duplicate' + } + + It 'rejects an untracked FIFO promptly before opening it' -Skip:$IsWindows { + $root = New-R8TrainVersionFixture + $fifo = Join-Path $root 'source/Private/input.fifo' + & /usr/bin/mkfifo $fifo + + $result = Get-R8TrainVersionWithTimeout -RepositoryRoot $root -TimeoutMilliseconds 3000 + + $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' + } + + It 'fails closed when a non-ignored entry appears after initial enumeration' -Skip:$IsWindows { + $root = New-R8TrainVersionFixture + $appeared = Join-Path $root 'source/Private/appeared.ps1' + $counter = Join-Path $TestDrive ('git-appearance-' + [guid]::NewGuid().ToString('N')) + $shimDirectory = New-R8GitShim -Name 'appearance' -Body (@' +#!/bin/sh +if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then + if [ ! -f '__COUNTER__' ]; then + : > '__COUNTER__' + exit 0 + fi + printf "'appeared'\\n" > '__APPEARED__' +fi +exec /usr/bin/git "$@" +'@).Replace('__COUNTER__', $counter).Replace('__APPEARED__', $appeared) + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + $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' -Skip:$IsWindows { + $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-R8GitShim -Name 'mutation' -Body (@' +#!/bin/sh +if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then + if [ -f '__COUNTER__' ]; then + printf "'mutated after read'\\n" > '__PATH__' + else + : > '__COUNTER__' + fi +fi +exec /usr/bin/git "$@" +'@).Replace('__COUNTER__', $counter).Replace('__PATH__', $path) + $savedPath = $env:PATH + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $result = Get-R8TrainVersion -RepositoryRoot $root + } + finally { $env:PATH = $savedPath } + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'changed|race|metadata|content' + } + + It 'matches the fixed R8 source-state known vector' { + $root = Join-Path $TestDrive ('known-vector-' + [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" -NoNewline -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked.ps1') -Value "'tracked'`n" -NoNewline -Encoding utf8NoBOM + & git -C $root init --quiet + & git -C $root add . + $savedAuthorDate = $env:GIT_AUTHOR_DATE + $savedCommitterDate = $env:GIT_COMMITTER_DATE + try { + $env:GIT_AUTHOR_DATE = '2001-02-03T04:05:06Z' + $env:GIT_COMMITTER_DATE = '2001-02-03T04:05:06Z' + & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'fixed vector' + } + finally { + $env:GIT_AUTHOR_DATE = $savedAuthorDate + $env:GIT_COMMITTER_DATE = $savedCommitterDate + } + Set-Content -LiteralPath (Join-Path $root 'source/Private/Untracked.ps1') -Value "'untracked'`n" -NoNewline -Encoding utf8NoBOM + + $state = & $script:versionScript -RepositoryRoot $root -AsObject + + $state.version | Should -Be '0.4.0-r8.g37b8420a67a0.d374f187ae54c' + $state.sourceStateSha256 | Should -Be '374f187ae54cd351758b49728c5a6e4dc342510eb0606c68520f1c32e8331975' + } } From ebd44bf422be7b48184bb68b0c4e5621474e26e3 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 23:03:02 -0400 Subject: [PATCH 10/79] fix: bind r8 identity to safe source handles --- scripts/Get-GraphKitTrainVersion.ps1 | 118 ++-- scripts/private/GraphKit.SourceCapture.cs | 799 ++++++++++++++++++++++ tests/QA/ReleaseProof.tests.ps1 | 5 +- tests/QA/TrainVersion.tests.ps1 | 287 +++++++- 4 files changed, 1106 insertions(+), 103 deletions(-) create mode 100644 scripts/private/GraphKit.SourceCapture.cs diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index d6c829a..1591d8e 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -48,11 +48,11 @@ function Add-GraphKitMapEntry { param($Map, [byte[]] $Path, $Entry, [string] $So if (-not $Map.TryAdd($key, $Entry)) { throw "$Source reported duplicate source path bytes." } } -function ConvertFrom-GraphKitTree { param([byte[]] $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 -notmatch '^(?[0-7]{6}) (?blob|commit) (?[0-9a-f]{40,64})$') { throw 'git ls-tree returned an unsupported entry header.' } + 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' @@ -60,11 +60,11 @@ function ConvertFrom-GraphKitTree { param([byte[]] $Bytes) return $map } -function ConvertFrom-GraphKitIndex { param([byte[]] $Bytes) +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 -notmatch '^(?[0-7]{6}) (?[0-9a-f]{40,64}) (?[0-3])$') { throw 'git ls-files --stage returned an unsupported entry header.' } + 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)'." } @@ -79,59 +79,26 @@ function Get-GraphKitRelativePath { param([byte[]] $RawPath, [Text.UTF8Encoding] return $path } -function Initialize-GraphKitNoFollowSupport { - if (-not $IsWindows -and -not ('GraphKit.R8.NoFollow' -as [type])) { Add-Type -TypeDefinition @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; -using Microsoft.Win32.SafeHandles; -namespace GraphKit.R8 { - public sealed class Metadata { public int Mode; public string Identity; } - public static class NoFollow { - [DllImport("libc", SetLastError=true)] static extern int lstat(string path, IntPtr buffer); - [DllImport("libc", SetLastError=true)] static extern int fstat(int fd, IntPtr buffer); - [DllImport("libc", SetLastError=true)] static extern int open(string path, int flags); - static bool Mac { get { return RuntimeInformation.IsOSPlatform(OSPlatform.OSX); } } - static Metadata Decode(byte[] b) { - if (Mac) return new Metadata { Mode=BitConverter.ToUInt16(b,4), Identity=BitConverter.ToUInt32(b,0).ToString("x8")+":"+BitConverter.ToUInt64(b,8).ToString("x16") }; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return new Metadata { Mode=BitConverter.ToInt32(b,24), Identity=BitConverter.ToUInt64(b,0).ToString("x16")+":"+BitConverter.ToUInt64(b,8).ToString("x16") }; - throw new PlatformNotSupportedException("No-follow source capture is unavailable on this platform."); - } - static Metadata Stat(Func f) { var b=new byte[512]; var h=GCHandle.Alloc(b,GCHandleType.Pinned); try { if(f(h.AddrOfPinnedObject())!=0) throw new Win32Exception(Marshal.GetLastWin32Error()); return Decode(b); } finally { h.Free(); } } - public static Metadata LStat(string path) { return Stat(p => lstat(path,p)); } - public static SafeFileHandle Open(string path, out Metadata metadata) { int flags=Mac ? 0x104 : 0x20800; int fd=open(path,flags); if(fd<0) throw new Win32Exception(Marshal.GetLastWin32Error()); try { metadata=Stat(p => fstat(fd,p)); return new SafeFileHandle((IntPtr)fd,true); } catch { throw; } } - } -} -'@ } -} - -function Get-GraphKitFileType { param([int] $Mode) return ($Mode -band 0xF000) } -function Get-GraphKitMode { param([int] $Mode) return [Convert]::ToString($Mode, 8).PadLeft(6, '0') } - -function Get-GraphKitWorktreeEntry { param([string] $Root, [byte[]] $RawPath, [Text.UTF8Encoding] $Utf8) - $relative = Get-GraphKitRelativePath $RawPath $Utf8; $fullPath = Join-Path $Root $relative - if ($IsWindows) { - if (-not [IO.File]::Exists($fullPath)) { return [pscustomobject] @{ type='missing'; mode=''; identity=''; content=[byte[]] @() } } - $before = Get-Item -LiteralPath $fullPath -Force - if (($before.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $before -isnot [IO.FileInfo]) { throw "Source entry '$relative' is an unsupported symbolic link or special file." } - $content = [IO.File]::ReadAllBytes($fullPath); $after = Get-Item -LiteralPath $fullPath -Force - if (($after.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $after -isnot [IO.FileInfo] -or $before.Length -ne $after.Length -or $before.LastWriteTimeUtc -ne $after.LastWriteTimeUtc -or $content.Length -ne $after.Length) { throw "Source entry '$relative' changed during capture." } - return [pscustomobject] @{ type='regular'; mode='100644'; identity=$before.FullName; content=$content } +function Initialize-GraphKitSourceCapture { + if (-not ('GraphKit.R8.SourceCapture' -as [type])) { + $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'." } + Add-Type -Path $helper } - try { $before = [GraphKit.R8.NoFollow]::LStat($fullPath) } catch [ComponentModel.Win32Exception] { return [pscustomobject] @{ type='missing'; mode=''; identity=''; content=[byte[]] @() } } - $beforeType = Get-GraphKitFileType $before.Mode - if ($beforeType -eq 0xA000) { throw "Source entry '$relative' is an unsupported symbolic link." } - if ($beforeType -ne 0x8000) { throw "Source entry '$relative' is an unsupported special/non-regular file." } - $opened = $null - try { $handle = [GraphKit.R8.NoFollow]::Open($fullPath, [ref] $opened) } catch { throw "Cannot no-follow open source entry '$relative': $($_.Exception.Message)" } - try { - if ((Get-GraphKitFileType $opened.Mode) -ne 0x8000 -or $opened.Mode -ne $before.Mode -or $opened.Identity -ne $before.Identity) { throw "Source entry '$relative' changed during no-follow open." } - $file = [IO.FileStream]::new($handle, [IO.FileAccess]::Read); try { $buffer = [IO.MemoryStream]::new(); $file.CopyTo($buffer); $content = $buffer.ToArray() } finally { $file.Dispose() } +} + +function Get-GraphKitWorktreeEntry { param([string] $Root, [byte[]] $RawPath, [Text.UTF8Encoding] $Utf8, [AllowNull()][string] $IndexMode) + $relative = Get-GraphKitRelativePath $RawPath $Utf8 + try { $capture = [GraphKit.R8.SourceCapture]::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)" } - finally { if ($handle) { $handle.Dispose() } } - try { $after = [GraphKit.R8.NoFollow]::LStat($fullPath) } catch { throw "Source entry '$relative' disappeared during capture." } - if ((Get-GraphKitFileType $after.Mode) -ne 0x8000 -or $after.Mode -ne $before.Mode -or $after.Identity -ne $before.Identity) { throw "Source entry '$relative' changed during no-follow metadata capture." } - return [pscustomobject] @{ type='regular'; mode=(Get-GraphKitMode $before.Mode); identity=$before.Identity; content=$content } + $mode = [GraphKit.R8.SourceCapture]::ResolveEffectiveGitMode($capture.Mode, $capture.HasExecutableMode, $IndexMode) + return [pscustomobject] @{ type='regular'; mode=$mode; identity=$capture.Identity; length=$capture.Length; content=$capture.Content } } function Get-GraphKitBlobId { param([string] $Format, [byte[]] $Content) @@ -142,22 +109,38 @@ function Get-GraphKitBlobId { param([string] $Format, [byte[]] $Content) } function Get-GraphKitFilesystemExtras { param([string] $Root, $Known, [Text.UTF8Encoding] $Utf8) - if ($IsWindows) { return [byte[]] @() } $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 '\\', '/')); $meta = [GraphKit.R8.NoFollow]::LStat($fullPath) - if ((Get-GraphKitFileType $meta.Mode) -eq 0x4000) { $directories.Push($fullPath) } elseif (-not $Known.Contains([Convert]::ToHexString($raw))) { $found.Add($raw) } + $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) - $headBytes = Invoke-GraphKitGitBytes $Root @('ls-tree','-r','-z','HEAD') + $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; $index = ConvertFrom-GraphKitIndex $indexBytes + $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) { @@ -166,23 +149,22 @@ function Get-GraphKitInventory { param([string] $Root, [Text.UTF8Encoding] $Utf8 $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] @{ headBytes=$headBytes; indexBytes=$indexBytes; untrackedBytes=$untrackedBytes; head=$head; index=$index } + [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) # v3: 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/bytes. # Snapshots before/after reads and a second no-follow content read make source races fatal. - $utf8=[Text.UTF8Encoding]::new($false,$true); Initialize-GraphKitNoFollowSupport - $format=[Text.Encoding]::ASCII.GetString((Invoke-GraphKitGitBytes $Root @('rev-parse','--show-object-format'))).Trim().ToLowerInvariant(); if($format -notin @('sha1','sha256')){throw "Unsupported Git object format '$format'."} + $utf8=[Text.UTF8Encoding]::new($false,$true); Initialize-GraphKitSourceCapture $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})} - $captured=[Collections.Generic.List[object]]::new();foreach($record in @($records.Values|Sort-Object{[Convert]::ToHexString($_.path)})){$worktree=Get-GraphKitWorktreeEntry $Root $record.path $utf8;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 $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.headBytes $after.headBytes) -or -not(Test-GraphKitBytesEqual $before.indexBytes $after.indexBytes) -or -not(Test-GraphKitBytesEqual $before.untrackedBytes $after.untrackedBytes)){throw 'Git source inventory changed during capture; refusing to emit a train version.'} - foreach($entry in $captured){$again=Get-GraphKitWorktreeEntry $Root $entry.path $utf8;if($again.type -ne $entry.worktree.type -or $again.mode -ne $entry.worktree.mode -or $again.identity -ne $entry.worktree.identity -or -not(Test-GraphKitBytesEqual $again.content $entry.worktree.content)){throw 'Source entry changed during capture; refusing to emit a train version.'}} + $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;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;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.'}} $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-v3'));&$write([byte[]]@(0));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-content' $entry.worktree.content};&$write([Text.Encoding]::ASCII.GetBytes('end'));&$write([byte[]]@(0)) - [pscustomobject]@{clean=[bool]$clean;sha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant()} + [pscustomobject]@{revision=$before.headOid;clean=[bool]$clean;sha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant()} } -$RepositoryRoot=(Resolve-Path -LiteralPath $RepositoryRoot).ProviderPath;$base='0.4.0';$train='r8';$revision=[Text.Encoding]::UTF8.GetString((Invoke-GraphKitGitBytes $RepositoryRoot @('rev-parse','HEAD'))).Trim().ToLowerInvariant();if($revision -notmatch '^[0-9a-f]{40}$'){throw "Cannot resolve a 40-character source revision for '$RepositoryRoot'."};$state=Get-GraphKitR8SourceState $RepositoryRoot;$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} +$RepositoryRoot=(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/private/GraphKit.SourceCapture.cs b/scripts/private/GraphKit.SourceCapture.cs new file mode 100644 index 0000000..7b2e2c3 --- /dev/null +++ b/scripts/private/GraphKit.SourceCapture.cs @@ -0,0 +1,799 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +#nullable enable + +namespace GraphKit.R8 +{ + 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 + { + 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); + 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)); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && relativePath.IndexOf('\\') >= 0) + { + throw new ArgumentException("A Git source path must use forward-slash separators on Windows.", 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)); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && segment.IndexOf(':') >= 0) + { + throw new ArgumentException("A Windows Git source path cannot select a drive or alternate data stream.", nameof(relativePath)); + } + } + + return segments; + } + + 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 > int.MaxValue) + { + throw new IOException($"Source entry '{displayName}' is too large for deterministic source capture."); + } + + 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 = "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)) + { + if (LinuxStatx(descriptor, string.Empty, AtEmptyPath | AtSymlinkNoFollow, RequiredStatxMask, out Statx metadata) != 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)) + { + if (DarwinFStat(descriptor, out DarwinStat metadata) != 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 DeviceMajor; + internal uint DeviceMinor; + internal uint SpecialDeviceMajor; + internal uint SpecialDeviceMinor; + 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 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 ObjectCaseInsensitive = 0x00000040; + private const uint FileOpen = 1; + + 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("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, + 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 = ObjectCaseInsensitive + }; + uint access = FileReadAttributes | Synchronize | (directory ? FileListDirectory : 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; + return owned; + } + 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); + } + } + } + + 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/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index fccc139..cf3e895 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -117,7 +117,8 @@ BeforeAll { $resultsDir = Join-Path $fixtureRoot 'output/testResults' $gateDir = Join-Path $fixtureRoot 'tests/QA' $scriptsDir = Join-Path $fixtureRoot 'scripts' - New-Item -ItemType Directory -Path $moduleDir, $resultsDir, $gateDir, $scriptsDir -Force | Out-Null + $privateScriptsDir = Join-Path $scriptsDir 'private' + New-Item -ItemType Directory -Path $moduleDir, $resultsDir, $gateDir, $scriptsDir, $privateScriptsDir -Force | Out-Null Copy-Item -LiteralPath (Join-Path $script:repoRoot 'tests/QA/Assert-GateResult.ps1') ` -Destination (Join-Path $gateDir 'Assert-GateResult.ps1') @@ -133,6 +134,8 @@ BeforeAll { 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 add .gitignore scripts tests diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index f6a390a..3233cbb 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -1,5 +1,6 @@ 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')) @@ -68,9 +69,188 @@ BeforeAll { & /bin/chmod +x $shimPath return $shimDirectory } + + function New-R8PortableGitShim { + param( + [Parameter(Mandatory)] [ValidateSet('duplicate-index', 'appearance', 'mutation', 'head-move', 'invalid-tree-oid')] [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 + 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) + +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' +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 + } +} +'@).Replace('__PAYLOAD__', $encodedPayload) + + if ($IsWindows) { + Set-Content -LiteralPath (Join-Path $shimDirectory 'git.cmd') -NoNewline -Encoding ascii -Value '@pwsh.exe -NoLogo -NoProfile -File "%~dp0git-shim.ps1" %*' + } 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 Initialize-R8SourceCaptureHelper { + $script:sourceCaptureHelper | Should -Exist -Because 'the build-time source capture must be independently testable' + if (-not ('GraphKit.R8.SourceCapture' -as [type])) { + Add-Type -Path $script:sourceCaptureHelper + } + } } Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { + 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 } + + $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 } + + $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 = Join-Path $TestDrive ('sha256-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root -Force + & git -C $root init --quiet --object-format=sha256 + if ($LASTEXITCODE -ne 0) { Set-ItResult -Skipped -Because 'the installed Git cannot create SHA-256 repositories'; return } + Set-Content -LiteralPath (Join-Path $root 'tracked.ps1') -Value "'tracked'`n" -NoNewline -Encoding utf8NoBOM + & git -C $root add tracked.ps1 + & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'sha256 fixture' + + $result = Get-R8TrainVersion -RepositoryRoot $root + + $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' { + Initialize-R8SourceCaptureHelper + [GraphKit.R8.SourceCapture]::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 @@ -266,17 +446,9 @@ exec /usr/bin/git "$@" $result.Output | Should -Match '\.d[0-9a-f]{12}$' } - It 'fails closed when a duplicate index path is reported' -Skip:$IsWindows { + It 'fails closed when a duplicate index path is reported' { $root = New-R8TrainVersionFixture - $shimDirectory = New-R8GitShim -Name 'duplicate-index' -Body @' -#!/bin/sh -if [ "$1" = "ls-files" ] && [ "$2" = "--stage" ]; then - item=$(/usr/bin/git "$@") - printf '%s\0%s\0' "$item" "$item" - exit 0 -fi -exec /usr/bin/git "$@" -'@ + $shimDirectory = New-R8PortableGitShim -Mode duplicate-index $savedPath = $env:PATH try { $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" @@ -300,21 +472,14 @@ exec /usr/bin/git "$@" $result.Output | Should -Match 'regular|special|unsupported' } - It 'fails closed when a non-ignored entry appears after initial enumeration' -Skip:$IsWindows { + 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-R8GitShim -Name 'appearance' -Body (@' -#!/bin/sh -if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then - if [ ! -f '__COUNTER__' ]; then - : > '__COUNTER__' - exit 0 - fi - printf "'appeared'\\n" > '__APPEARED__' -fi -exec /usr/bin/git "$@" -'@).Replace('__COUNTER__', $counter).Replace('__APPEARED__', $appeared) + $shimDirectory = New-R8PortableGitShim -Mode appearance -Configuration @{ + Counter = $counter + Path = $appeared + } $savedPath = $env:PATH try { $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" @@ -326,22 +491,15 @@ exec /usr/bin/git "$@" $result.Output | Should -Match 'inventory|changed|race' } - It 'fails closed when content mutates after the first metadata/read pass' -Skip:$IsWindows { + 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-R8GitShim -Name 'mutation' -Body (@' -#!/bin/sh -if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then - if [ -f '__COUNTER__' ]; then - printf "'mutated after read'\\n" > '__PATH__' - else - : > '__COUNTER__' - fi -fi -exec /usr/bin/git "$@" -'@).Replace('__COUNTER__', $counter).Replace('__PATH__', $path) + $shimDirectory = New-R8PortableGitShim -Mode mutation -Configuration @{ + Counter = $counter + Path = $path + } $savedPath = $env:PATH try { $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" @@ -379,3 +537,64 @@ exec /usr/bin/git "$@" $state.sourceStateSha256 | Should -Be '374f187ae54cd351758b49728c5a6e4dc342510eb0606c68520f1c32e8331975' } } + +Describe 'GraphKit R8 root-anchored source capture' -Tag 'QA' { + It 'rejects a Unix symbolic link in an intermediate path segment' -Skip:$IsWindows { + 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 + New-Item -ItemType SymbolicLink -Path (Join-Path $root 'source/Private') -Target $outside | Out-Null + + { [GraphKit.R8.SourceCapture]::Capture($root, 'source/Private/Tracked.ps1') } | + Should -Throw -ExpectedMessage '*symbolic link*' + } + + It 'closes Unix final descriptors when post-open type validation throws' -Skip:$IsWindows { + Initialize-R8SourceCaptureHelper + $root = Join-Path $TestDrive ('handle-ownership-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root -Force + $fifo = Join-Path $root 'unsupported.fifo' + & /usr/bin/mkfifo $fifo + { [GraphKit.R8.SourceCapture]::Capture($root, 'unsupported.fifo') } | Should -Throw + $before = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + + 1..64 | ForEach-Object { + { [GraphKit.R8.SourceCapture]::Capture($root, 'unsupported.fifo') } | Should -Throw + } + + $after = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + ($after - $before) | Should -BeLessOrEqual 2 -Because 'every descriptor returned by openat must immediately gain a safe owner' + } + + It 'rejects a Windows reparse point in an intermediate path segment without retaining handles' { + 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 { + { [GraphKit.R8.SourceCapture]::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 + { [GraphKit.R8.SourceCapture]::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw + $before = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + 1..16 | ForEach-Object { + { [GraphKit.R8.SourceCapture]::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw + } + $after = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + } + ($after - $before) | Should -BeLessOrEqual 2 + } +} From 2051a2080cceb4af593aea6cf018447514814abe Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 30 Aug 2026 23:59:58 -0400 Subject: [PATCH 11/79] fix: harden r8 source identity proof --- .../2026-08-30-r8-graphkit-auth-design.md | 7 + scripts/Get-GraphKitTrainVersion.ps1 | 63 +- scripts/private/GraphKit.SourceCapture.cs | 113 +++- tests/QA/TrainVersion.tests.ps1 | 549 ++++++++++++++---- 4 files changed, 596 insertions(+), 136 deletions(-) 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 index 21e606b..7876a69 100644 --- a/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md +++ b/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md @@ -40,6 +40,13 @@ Only a clean-tree package may become release authority or cross a repository/mac 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/`: diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index 1591d8e..ad28515 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -80,16 +80,35 @@ function Get-GraphKitRelativePath { param([byte[]] $RawPath, [Text.UTF8Encoding] } function Initialize-GraphKitSourceCapture { - if (-not ('GraphKit.R8.SourceCapture' -as [type])) { - $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'." } - Add-Type -Path $helper + $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 } } -function Get-GraphKitWorktreeEntry { param([string] $Root, [byte[]] $RawPath, [Text.UTF8Encoding] $Utf8, [AllowNull()][string] $IndexMode) +function Get-GraphKitWorktreeEntry { param([string] $Root, [byte[]] $RawPath, [Text.UTF8Encoding] $Utf8, [AllowNull()][string] $IndexMode, [type] $CaptureType) $relative = Get-GraphKitRelativePath $RawPath $Utf8 - try { $capture = [GraphKit.R8.SourceCapture]::Capture($Root, $relative) } + 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]) { @@ -97,10 +116,24 @@ function Get-GraphKitWorktreeEntry { param([string] $Root, [byte[]] $RawPath, [T } throw "Cannot root-anchored no-follow capture source entry '$relative': $($failure.Message)" } - $mode = [GraphKit.R8.SourceCapture]::ResolveEffectiveGitMode($capture.Mode, $capture.HasExecutableMode, $IndexMode) + $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) @@ -153,17 +186,21 @@ function Get-GraphKitInventory { param([string] $Root, [Text.UTF8Encoding] $Utf8 } function Get-GraphKitR8SourceState { param([string] $Root) - # v3: 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/bytes. + # 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); Initialize-GraphKitSourceCapture + $utf8=[Text.UTF8Encoding]::new($false,$true); $captureHelper=Initialize-GraphKitSourceCapture; $captureType=$captureHelper.type $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})} - $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;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})} + 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;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.'}} + 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.'}} + $helperPath=$utf8.GetBytes('scripts/private/GraphKit.SourceCapture.cs');$helperRecord=@($captured|Where-Object{Test-GraphKitBytesEqual $_.path $helperPath});if($helperRecord.Count -gt 1 -or ($helperRecord.Count -eq 1 -and -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-v3'));&$write([byte[]]@(0));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-content' $entry.worktree.content};&$write([Text.Encoding]::ASCII.GetBytes('end'));&$write([byte[]]@(0)) + $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()} } diff --git a/scripts/private/GraphKit.SourceCapture.cs b/scripts/private/GraphKit.SourceCapture.cs index 7b2e2c3..5a8b72d 100644 --- a/scripts/private/GraphKit.SourceCapture.cs +++ b/scripts/private/GraphKit.SourceCapture.cs @@ -2,11 +2,13 @@ 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.R8 +namespace __GRAPHKIT_SOURCE_CAPTURE_NAMESPACE__ { internal enum SourceEntryKind { @@ -53,6 +55,8 @@ internal CapturedSourceFile(string mode, bool hasExecutableMode, string identity public static class SourceCapture { + private const long MaxSourceEntryBytes = 16L * 1024L * 1024L; + public static string ResolveEffectiveGitMode(string? capturedMode, bool hasExecutableMode, string? indexMode) { if (!hasExecutableMode) @@ -89,6 +93,10 @@ public static CapturedSourceFile Capture(string repositoryRoot, string relativeP 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); @@ -101,11 +109,6 @@ private static string[] ValidateRelativePath(string relativePath) throw new ArgumentException("The source path must be a non-empty relative Git path.", nameof(relativePath)); } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && relativePath.IndexOf('\\') >= 0) - { - throw new ArgumentException("A Git source path must use forward-slash separators on Windows.", nameof(relativePath)); - } - string[] segments = relativePath.Split('/'); foreach (string segment in segments) { @@ -113,15 +116,48 @@ private static string[] ValidateRelativePath(string relativePath) { throw new ArgumentException("The source path contains an unsafe segment.", nameof(relativePath)); } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && segment.IndexOf(':') >= 0) - { - throw new ArgumentException("A Windows Git source path cannot select a drive or alternate data stream.", 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; @@ -270,9 +306,9 @@ private static CapturedSourceFile CaptureVerifiedHandle( { throw new IOException($"Source entry '{displayName}' is an unsupported special/non-regular file."); } - if (before.Length < 0 || before.Length > int.MaxValue) + if (before.Length < 0 || before.Length > MaxSourceEntryBytes) { - throw new IOException($"Source entry '{displayName}' is too large for deterministic source capture."); + 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); @@ -523,10 +559,10 @@ private struct Statx internal StatxTimestamp BirthTime; internal StatxTimestamp ChangeTime; internal StatxTimestamp ModificationTime; + internal uint RDeviceMajor; + internal uint RDeviceMinor; internal uint DeviceMajor; internal uint DeviceMinor; - internal uint SpecialDeviceMajor; - internal uint SpecialDeviceMinor; internal ulong MountId; internal uint DirectIoMemoryAlignment; internal uint DirectIoOffsetAlignment; @@ -594,8 +630,8 @@ internal static class WindowsNative private const uint FileFlagBackupSemantics = 0x02000000; private const uint FileAttributeReparsePoint = 0x00000400; private const uint FileAttributeDirectory = 0x00000010; - private const uint ObjectCaseInsensitive = 0x00000040; private const uint FileOpen = 1; + private const uint FileNameNormalized = 0; internal const int ErrorFileNotFound = 2; internal const int ErrorPathNotFound = 3; @@ -613,6 +649,13 @@ private static extern SafeFileHandle CreateFileW( [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, @@ -672,7 +715,7 @@ internal static SafeFileHandle OpenRelative(SafeFileHandle parent, string segmen Length = Marshal.SizeOf(), RootDirectory = parent.DangerousGetHandle(), ObjectName = unicodeStringPointer, - Attributes = ObjectCaseInsensitive + Attributes = 0 }; uint access = FileReadAttributes | Synchronize | (directory ? FileListDirectory : GenericRead | FileReadData); uint options = FileOpenReparsePoint | FileSynchronousIoNonAlert | (directory ? FileDirectoryFile : FileNonDirectoryFile); @@ -696,7 +739,16 @@ internal static SafeFileHandle OpenRelative(SafeFileHandle parent, string segmen var owned = new SafeFileHandle(raw, true); raw = IntPtr.Zero; - return owned; + try + { + EnsureExactOpenedSegment(owned, segment); + return owned; + } + catch + { + owned.Dispose(); + throw; + } } finally { @@ -719,6 +771,33 @@ internal static SafeFileHandle OpenRelative(SafeFileHandle parent, string segmen } } + 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; diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index 3233cbb..abc3b7a 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -15,9 +15,24 @@ BeforeAll { } function Get-R8TrainVersion { - param([Parameter(Mandatory)] [string] $RepositoryRoot) + 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() + } + } - $output = & pwsh -NoLogo -NoProfile -File $script:versionScript -RepositoryRoot $RepositoryRoot 2>&1 | Out-String + 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() @@ -56,23 +71,22 @@ BeforeAll { } } - function New-R8GitShim { - param( - [Parameter(Mandatory)] [string] $Name, - [Parameter(Mandatory)] [string] $Body - ) - - $shimDirectory = Join-Path $TestDrive ("git-shim-$Name-" + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType Directory -Path $shimDirectory -Force - $shimPath = Join-Path $shimDirectory 'git' - Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value $Body - & /bin/chmod +x $shimPath - return $shimDirectory - } - function New-R8PortableGitShim { param( - [Parameter(Mandatory)] [ValidateSet('duplicate-index', 'appearance', 'mutation', 'head-move', 'invalid-tree-oid')] [string] $Mode, + [Parameter(Mandatory)] [ValidateSet( + 'duplicate-index', + 'appearance', + 'mutation', + 'head-move', + 'invalid-tree-oid', + 'sha256-format', + 'missing', + 'invalid-path', + 'unmerged-stage', + 'case-collision', + 'normalization-collision', + 'reverse-untracked' + )] [string] $Mode, [hashtable] $Configuration = @{} ) @@ -112,6 +126,7 @@ function Write-Result($Result) { $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' switch ($payload.Mode) { 'duplicate-index' { $result = Invoke-RealGit $gitArguments @@ -160,6 +175,92 @@ switch ($payload.Mode) { } 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 + } + '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 + } + '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) @@ -175,13 +276,181 @@ switch ($payload.Mode) { function Initialize-R8SourceCaptureHelper { $script:sourceCaptureHelper | Should -Exist -Because 'the build-time source capture must be independently testable' - if (-not ('GraphKit.R8.SourceCapture' -as [type])) { - Add-Type -Path $script:sourceCaptureHelper + 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) + $script:sourceCaptureType = @($types | Where-Object FullName -CEQ "$namespace.SourceCapture") + } + 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); +'@ + 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 } + } + + $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 '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 } + + $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 } + + $result.ExitCode | Should -Not -Be 0 + $result.Output | Should -Match 'normalization|collid|ambiguous' + } + + It 'fails on unmerged index stage before invoking worktree capture' -ForEach @( + @{ Stage = 1 } + @{ Stage = 2 } + @{ Stage = 3 } + ) { + $root = New-R8TrainVersionFixture + $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 + $versionLiteral = $script:versionScript.Replace("'", "''") + $rootLiteral = $root.Replace("'", "''") + $source = $script:ambientCaptureSource + try { + $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" + $env:GRAPHKIT_TEST_CAPTURE_SENTINEL = $sentinel + $bootstrap = @" +`$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +$source +'@ +& '$versionLiteral' -RepositoryRoot '$rootLiteral' +"@ + $result = Invoke-R8Bootstrap -Content $bootstrap + } + finally { + $env:PATH = $savedPath + $env:GRAPHKIT_TEST_CAPTURE_SENTINEL = $savedSentinel + } + + $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() @@ -220,23 +489,22 @@ Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { } It 'fails closed with an actionable error for a SHA-256 object-format repository' { - $root = Join-Path $TestDrive ('sha256-' + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType Directory -Path $root -Force - & git -C $root init --quiet --object-format=sha256 - if ($LASTEXITCODE -ne 0) { Set-ItResult -Skipped -Because 'the installed Git cannot create SHA-256 repositories'; return } - Set-Content -LiteralPath (Join-Path $root 'tracked.ps1') -Value "'tracked'`n" -NoNewline -Encoding utf8NoBOM - & git -C $root add tracked.ps1 - & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'sha256 fixture' - - $result = Get-R8TrainVersion -RepositoryRoot $root + $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 } $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' { - Initialize-R8SourceCaptureHelper - [GraphKit.R8.SourceCapture]::ResolveEffectiveGitMode('', $false, '100755') | Should -Be '100755' + $captureType = Initialize-R8SourceCaptureHelper + $captureType::ResolveEffectiveGitMode('', $false, '100755') | Should -Be '100755' if ($IsWindows) { $root = New-R8TrainVersionFixture @@ -295,10 +563,13 @@ Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { 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 - Remove-Item -LiteralPath $firstPath, $secondPath -Force - Set-Content -LiteralPath $firstPath -Value "'a'`n" -NoNewline -Encoding utf8NoBOM - Set-Content -LiteralPath $secondPath -Value "'z'`n" -NoNewline -Encoding utf8NoBOM - $reverse = 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 } $forward.ExitCode | Should -Be 0 -Because $forward.Output $reverse.ExitCode | Should -Be 0 -Because $reverse.Output @@ -317,20 +588,9 @@ Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { $result.Output | Should -Match 'symbolic link|unsupported' } - It 'fails closed when Git reports an entry that disappears before capture' -Skip:$IsWindows { + It 'fails closed when Git reports an entry that disappears before capture' { $root = New-R8TrainVersionFixture - $shimDirectory = Join-Path $TestDrive ('git-missing-shim-' + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType Directory -Path $shimDirectory -Force - $shimPath = Join-Path $shimDirectory 'git' - Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value @' -#!/bin/sh -if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then - printf 'source/Private/disappeared.ps1\0' - exit 0 -fi -exec /usr/bin/git "$@" -'@ - & /bin/chmod +x $shimPath + $shimDirectory = New-R8PortableGitShim -Mode missing $savedPath = $env:PATH try { $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" @@ -344,20 +604,9 @@ exec /usr/bin/git "$@" $result.Output | Should -Match 'disappeared|regular file' } - It 'fails closed for a non-UTF-8 Unix path' -Skip:$IsWindows { + It 'fails closed for a non-strict-UTF-8 raw Git path on every host' { $root = New-R8TrainVersionFixture - $shimDirectory = Join-Path $TestDrive ('git-shim-' + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType Directory -Path $shimDirectory -Force - $shimPath = Join-Path $shimDirectory 'git' - Set-Content -LiteralPath $shimPath -NoNewline -Encoding utf8NoBOM -Value @' -#!/bin/sh -if [ "$1" = "ls-files" ] && printf '%s' "$*" | /usr/bin/grep -q -- '--others'; then - printf '\377\0' - exit 0 -fi -exec /usr/bin/git "$@" -'@ - & /bin/chmod +x $shimPath + $shimDirectory = New-R8PortableGitShim -Mode invalid-path $savedPath = $env:PATH try { $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" @@ -371,10 +620,15 @@ exec /usr/bin/git "$@" $result.Output | Should -Match 'UTF-8|path' } - It 'marks an executable-mode-only tracked change dirty even when core.filemode is false' -Skip:$IsWindows { + 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' - & /bin/chmod +x $path + 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 @@ -435,9 +689,10 @@ exec /usr/bin/git "$@" $result.Output | Should -Match 'gitlink|submodule|unsupported' } - It 'accepts a valid untracked path containing tabs, newlines, and non-ASCII bytes' -Skip:$IsWindows { + It 'accepts a platform-valid untracked path containing special and non-ASCII characters' { $root = New-R8TrainVersionFixture - $path = Join-Path $root "source/Private/tab`tline`n雪.ps1" + $relative = if ($IsWindows) { "source/Private/tab`t雪.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 @@ -460,16 +715,25 @@ exec /usr/bin/git "$@" $result.Output | Should -Match 'duplicate' } - It 'rejects an untracked FIFO promptly before opening it' -Skip:$IsWindows { + It 'rejects an unsupported untracked filesystem entry promptly before reading it' { $root = New-R8TrainVersionFixture - $fifo = Join-Path $root 'source/Private/input.fifo' - & /usr/bin/mkfifo $fifo + 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 + } $result = Get-R8TrainVersionWithTimeout -RepositoryRoot $root -TimeoutMilliseconds 3000 $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' + $result.Output | Should -Match 'regular|special|unsupported|reparse|cannot be opened' } It 'fails closed when a non-ignored entry appears after initial enumeration' { @@ -512,65 +776,138 @@ exec /usr/bin/git "$@" } It 'matches the fixed R8 source-state known vector' { - $root = Join-Path $TestDrive ('known-vector-' + [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" -NoNewline -Encoding utf8NoBOM - Set-Content -LiteralPath (Join-Path $root 'source/Private/Tracked.ps1') -Value "'tracked'`n" -NoNewline -Encoding utf8NoBOM - & git -C $root init --quiet - & git -C $root add . - $savedAuthorDate = $env:GIT_AUTHOR_DATE - $savedCommitterDate = $env:GIT_COMMITTER_DATE + $fixture = New-R8ControlledIdentityFixture + $savedIdentity = $env:GRAPHKIT_TEST_CAPTURE_IDENTITY try { - $env:GIT_AUTHOR_DATE = '2001-02-03T04:05:06Z' - $env:GIT_COMMITTER_DATE = '2001-02-03T04:05:06Z' - & git -C $root -c user.name='GraphKit QA' -c user.email='qa@example.invalid' commit --quiet -m 'fixed vector' - } - finally { - $env:GIT_AUTHOR_DATE = $savedAuthorDate - $env:GIT_COMMITTER_DATE = $savedCommitterDate + $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = 'known-device:00000001:known-file:00000002' + $state = & $fixture.VersionScript -RepositoryRoot $fixture.Root -AsObject } - Set-Content -LiteralPath (Join-Path $root 'source/Private/Untracked.ps1') -Value "'untracked'`n" -NoNewline -Encoding utf8NoBOM - - $state = & $script:versionScript -RepositoryRoot $root -AsObject + finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } - $state.version | Should -Be '0.4.0-r8.g37b8420a67a0.d374f187ae54c' - $state.sourceStateSha256 | Should -Be '374f187ae54cd351758b49728c5a6e4dc342510eb0606c68520f1c32e8331975' + $state.sourceStateSha256 | Should -Be '5effd62748cd587364a3853875d70f9994031ed2aeb08c4c95f9009c0ebb4fbe' + $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d5effd62748cd$' } } Describe 'GraphKit R8 root-anchored source capture' -Tag 'QA' { - It 'rejects a Unix symbolic link in an intermediate path segment' -Skip:$IsWindows { - Initialize-R8SourceCaptureHelper + It 'maps Linux statx device fields in ABI order before formatting ordinary-file identity' { + $captureType = Initialize-R8SourceCaptureHelper + $statxType = $captureType.Assembly.GetType("$($captureType.Namespace).UnixNative+Statx", $true) + + [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 + } + + 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 } + + $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 - New-Item -ItemType SymbolicLink -Path (Join-Path $root 'source/Private') -Target $outside | Out-Null + $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 + } - { [GraphKit.R8.SourceCapture]::Capture($root, 'source/Private/Tracked.ps1') } | - Should -Throw -ExpectedMessage '*symbolic link*' + { $captureType::Capture($root, 'source/Private/Tracked.ps1') } | + Should -Throw -ExpectedMessage $(if ($IsWindows) { '*reparse point*' } else { '*symbolic link*' }) } - It 'closes Unix final descriptors when post-open type validation throws' -Skip:$IsWindows { - Initialize-R8SourceCaptureHelper + 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 - $fifo = Join-Path $root 'unsupported.fifo' - & /usr/bin/mkfifo $fifo - { [GraphKit.R8.SourceCapture]::Capture($root, 'unsupported.fifo') } | Should -Throw - $before = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count + 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 { - { [GraphKit.R8.SourceCapture]::Capture($root, 'unsupported.fifo') } | Should -Throw + { $captureType::Capture($root, $(if ($IsWindows) { 'unsupported.entry' } else { 'unsupported.fifo' })) } | Should -Throw } - $after = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count - ($after - $before) | Should -BeLessOrEqual 2 -Because 'every descriptor returned by openat must immediately gain a safe owner' + $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' { - Initialize-R8SourceCaptureHelper + $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 @@ -582,16 +919,16 @@ Describe 'GraphKit R8 root-anchored source capture' -Tag 'QA' { 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 { - { [GraphKit.R8.SourceCapture]::Capture($root, 'source/Private/Tracked.ps1') } | + { $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 - { [GraphKit.R8.SourceCapture]::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw + { $captureType::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw $before = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count 1..16 | ForEach-Object { - { [GraphKit.R8.SourceCapture]::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw + { $captureType::Capture($root, 'source/Private/Tracked.ps1') } | Should -Throw } $after = @([IO.Directory]::EnumerateFileSystemEntries('/dev/fd')).Count } From 59a79dba66e6fab479a20f965a4cbed3d2541fab Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 00:39:21 -0400 Subject: [PATCH 12/79] fix: close r8 release proof breaker gaps --- scripts/Get-GraphKitTrainVersion.ps1 | 20 ++- tests/QA/TrainVersion.tests.ps1 | 253 +++++++++++++++++++++++++-- 2 files changed, 254 insertions(+), 19 deletions(-) diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index ad28515..132dfa2 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -79,6 +79,20 @@ function Get-GraphKitRelativePath { param([byte[]] $RawPath, [Text.UTF8Encoding] return $path } +function Get-GraphKitProofBoundHelperInventoryPath { param([string] $Root, [string] $Helper, [Text.UTF8Encoding] $Utf8) + $rootPath = [IO.Path]::GetFullPath($Root) + $helperPath = [IO.Path]::GetFullPath($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'." } @@ -103,7 +117,7 @@ function Initialize-GraphKitSourceCapture { 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 } + [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) @@ -191,14 +205,14 @@ function Get-GraphKitR8SourceState { param([string] $Root) # 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 + $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.'}} - $helperPath=$utf8.GetBytes('scripts/private/GraphKit.SourceCapture.cs');$helperRecord=@($captured|Where-Object{Test-GraphKitBytesEqual $_.path $helperPath});if($helperRecord.Count -gt 1 -or ($helperRecord.Count -eq 1 -and -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.'} + 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()} diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index abc3b7a..82d3275 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -83,6 +83,7 @@ BeforeAll { 'missing', 'invalid-path', 'unmerged-stage', + 'helper-case-alias', 'case-collision', 'normalization-collision', 'reverse-untracked' @@ -95,6 +96,7 @@ BeforeAll { $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)) @@ -103,6 +105,11 @@ BeforeAll { $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() @@ -127,6 +134,7 @@ $isStage = $gitArguments.Count -ge 2 -and $gitArguments[0] -eq 'ls-files' -and $ $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 @@ -208,6 +216,24 @@ switch ($payload.Mode) { } 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) { @@ -265,7 +291,100 @@ switch ($payload.Mode) { '@).Replace('__PAYLOAD__', $encodedPayload) if ($IsWindows) { - Set-Content -LiteralPath (Join-Path $shimDirectory 'git.cmd') -NoNewline -Encoding ascii -Value '@pwsh.exe -NoLogo -NoProfile -File "%~dp0git-shim.ps1" %*' + $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" @@ -274,6 +393,14 @@ switch ($payload.Mode) { 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) { @@ -317,6 +444,44 @@ string proofIdentity = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE [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) + { +'@ + $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); + } +'@ + 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 + } + $script:ambientCaptureSource = @' using System; using System.IO; @@ -348,6 +513,35 @@ namespace GraphKit.R8 } 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' + } + 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) @@ -382,6 +576,7 @@ $source } finally { $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'case|collid|ambiguous' } @@ -396,40 +591,56 @@ $source } 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' + $result.Output | Should -Match 'exactly one exact raw inventory record' + } + It 'fails on unmerged index stage before invoking worktree capture' -ForEach @( @{ Stage = 1 } @{ Stage = 2 } @{ Stage = 3 } ) { - $root = New-R8TrainVersionFixture + $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 - $versionLiteral = $script:versionScript.Replace("'", "''") - $rootLiteral = $root.Replace("'", "''") - $source = $script:ambientCaptureSource try { - $env:PATH = "$shimDirectory$([IO.Path]::PathSeparator)$savedPath" $env:GRAPHKIT_TEST_CAPTURE_SENTINEL = $sentinel - $bootstrap = @" -`$ErrorActionPreference = 'Stop' -Add-Type -TypeDefinition @' -$source -'@ -& '$versionLiteral' -RepositoryRoot '$rootLiteral' -"@ - $result = Invoke-R8Bootstrap -Content $bootstrap + $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 @@ -470,6 +681,7 @@ $source } finally { $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'HEAD|revision|commit.*changed' } @@ -484,6 +696,7 @@ $source } finally { $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'invalid object identity|unsupported entry header' } @@ -498,6 +711,7 @@ $source } 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' } @@ -571,6 +785,7 @@ $source } 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 @@ -600,6 +815,7 @@ $source $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'disappeared|regular file' } @@ -616,6 +832,7 @@ $source $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'UTF-8|path' } @@ -711,6 +928,7 @@ $source } finally { $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'duplicate' } @@ -751,6 +969,7 @@ $source } finally { $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'inventory|changed|race' } @@ -771,6 +990,7 @@ $source } finally { $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'changed|race|metadata|content' } @@ -784,8 +1004,8 @@ $source } finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } - $state.sourceStateSha256 | Should -Be '5effd62748cd587364a3853875d70f9994031ed2aeb08c4c95f9009c0ebb4fbe' - $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d5effd62748cd$' + $state.sourceStateSha256 | Should -Be '8acdbeded33e8b41799dadced367401212cdcd6cb6ed3424ea9c82a003326bd0' + $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d8acdbeded33e$' } } @@ -834,6 +1054,7 @@ Describe 'GraphKit R8 root-anchored source capture' -Tag 'QA' { } finally { $env:PATH = $savedPath } + Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 $result.Output | Should -Match 'case|collid|ambiguous' } From 0aba617d01e2066b8e94992d7fdcabe3b345d34d Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 00:51:38 -0400 Subject: [PATCH 13/79] test: define the GraphKit Auth package boundary --- tests/QA/GraphKitAuthPackage.tests.ps1 | 53 +++++++++++ tests/QA/PackageDependencies.tests.ps1 | 18 ++-- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 122 +++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 tests/QA/GraphKitAuthPackage.tests.ps1 create mode 100644 tests/Unit/Auth/GraphKitAuth.Tests.ps1 diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 new file mode 100644 index 0000000..500a72a --- /dev/null +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -0,0 +1,53 @@ +$requiredGraphKitAuthCases = @( + @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' } + @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.dll' } + @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.deps.json' } + @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' } + @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.IdentityModel.Abstractions.dll' } +) + +BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $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:packagePath = $null + $script:packageEntries = @() + + if (Test-Path -LiteralPath $script:builtManifestPath -PathType Leaf) { + $builtManifest = Import-PowerShellDataFile -Path $script:builtManifestPath + $prerelease = [string] $builtManifest.PrivateData.PSData.Prerelease + $fullVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { + $script:baseVersion + } + else { + "$script:baseVersion-$prerelease" + } + $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$fullVersion.nupkg" + } + + if ($script:packagePath -and (Test-Path -LiteralPath $script:packagePath -PathType Leaf)) { + $archive = [System.IO.Compression.ZipFile]::OpenRead($script:packagePath) + try { + $script:packageEntries = @($archive.Entries.FullName) + } + finally { + $archive.Dispose() + } + } + +} + +Describe 'Packed GraphKit.Auth boundary' -Tag 'QA' { + It 'contains the exact required path ' -ForEach $requiredGraphKitAuthCases { + $script:packagePath | Should -Not -BeNullOrEmpty -Because 'pack must produce a versioned GraphKit candidate' + Test-Path -LiteralPath $script:packagePath -PathType Leaf | Should -BeTrue -Because 'the package boundary is tested against the packed candidate' + + @($script:packageEntries | Where-Object { $_ -ceq $Path }).Count | Should -Be 1 -Because "the archive must contain '$Path' exactly once" + Test-Path -LiteralPath (Join-Path $script:builtModuleRoot $Path) -PathType Leaf | + Should -BeTrue -Because "the packed path '$Path' must originate in the built module" + } +} diff --git a/tests/QA/PackageDependencies.tests.ps1 b/tests/QA/PackageDependencies.tests.ps1 index 2f35112..68164bd 100644 --- a/tests/QA/PackageDependencies.tests.ps1 +++ b/tests/QA/PackageDependencies.tests.ps1 @@ -54,18 +54,22 @@ BeforeAll { `$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 "@ @@ -102,7 +106,7 @@ Describe 'Packed GraphKit dependency contract' -Tag 'QA' { $dependencyMap['Microsoft.PowerShell.SecretManagement'] | Should -Be '1.1.2' } - It 'imports the isolated artifact with its required SecretManagement runtime dependency' { + It 'imports the isolated artifact with both required runtime dependencies' { $modulePath = New-IsolatedGraphKitModulePath -Root (Join-Path $TestDrive 'non-vault') $result = Invoke-IsolatedGraphKitProbe -ModulePath $modulePath @@ -110,6 +114,8 @@ Describe 'Packed GraphKit dependency contract' -Tag 'QA' { $result.Data.Imported | Should -BeTrue $result.Data.ModuleBase | Should -Be (Join-Path $modulePath "GraphKit/$script:baseVersion") $result.Data.OperationName | Should -Be 'ManagedDevice.List' + $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 -BeTrue -Because 'SecretManagement is restored as a runtime RequiredModule' $result.Data.SecretManagementAvailable | Should -BeTrue -Because 'SecretManagement is a required runtime package dependency' } diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 new file mode 100644 index 0000000..b196680 --- /dev/null +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -0,0 +1,122 @@ +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' + $script:contractsAssembly = $null + + if (Test-Path -LiteralPath $script:contractsPath -PathType Leaf) { + $resolvedContractsPath = (Resolve-Path -LiteralPath $script:contractsPath).ProviderPath + $loadedContracts = @( + [System.Runtime.Loader.AssemblyLoadContext]::Default.Assemblies | + Where-Object { $_.GetName().Name -ceq 'GraphKit.Auth.Contracts' } + ) + + if ($loadedContracts.Count -eq 1) { + $script:contractsAssembly = $loadedContracts[0] + } + elseif ($loadedContracts.Count -eq 0) { + $script:contractsAssembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + $resolvedContractsPath + ) + } + } + + function Get-GraphKitAuthPublicSignatureTypes { + param( + [Parameter(Mandatory)] + [System.Reflection.Assembly] $Assembly + ) + + $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 + } + } + } + + return @($seen) + } +} + +Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { + It 'loads the contract assembly with the exact ABI marker and Acquire result' { + $script:contractsPath | Should -Exist -Because 'Task 3 must build the dependency-free GraphKit.Auth contract assembly' + $script:contractsAssembly | Should -Not -BeNullOrEmpty -Because 'exactly one contracts assembly must load in the default AssemblyLoadContext' + + [GraphKit.Auth.GraphAuthHost]::ContractMarker | Should -Be 'GraphKit.Auth.Abi/1' + [GraphKit.Auth.IGraphTokenSource].GetMethod('Acquire').ReturnType.FullName | + Should -Be 'GraphKit.Auth.GraphTokenResult' + } + + It 'keeps Microsoft.Identity.Client out of every public contract signature' { + $script:contractsPath | Should -Exist -Because 'the public GraphKit.Auth surface can only be inspected after Task 3 builds it' + $script:contractsAssembly | Should -Not -BeNullOrEmpty -Because 'the complete public ABI must be available for reflection' + + $signatureTypes = @(Get-GraphKitAuthPublicSignatureTypes -Assembly $script:contractsAssembly) + $leaks = @( + $signatureTypes | Where-Object { + [string] $_.FullName -like '*Microsoft.Identity.Client*' -or + [string] $_.Assembly.FullName -like '*Microsoft.Identity.Client*' + } + ) + + $leaks.FullName | Should -BeNullOrEmpty -Because 'no MSAL type may cross the GraphKit-owned ABI boundary' + } +} From 614f7567d5edd00602c1bf052d59ded63137ca15 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 01:12:09 -0400 Subject: [PATCH 14/79] fix: bind r8 helper through physical root aliases --- scripts/Get-GraphKitTrainVersion.ps1 | 36 ++++++++++++++++++-- tests/QA/TrainVersion.tests.ps1 | 50 ++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index 132dfa2..b1872ba 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -79,9 +79,39 @@ function Get-GraphKitRelativePath { param([byte[]] $RawPath, [Text.UTF8Encoding] 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 = [IO.Path]::GetFullPath($Root) - $helperPath = [IO.Path]::GetFullPath($Helper) + $rootPath = Resolve-GraphKitPhysicalPath $Root + $helperPath = Resolve-GraphKitPhysicalPath $Helper $relative = [IO.Path]::GetRelativePath($rootPath, $helperPath) if ([IO.Path]::IsPathRooted($relative) -or $relative -eq '..' -or @@ -218,4 +248,4 @@ function Get-GraphKitR8SourceState { param([string] $Root) [pscustomobject]@{revision=$before.headOid;clean=[bool]$clean;sha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream.ToArray())).ToLowerInvariant()} } -$RepositoryRoot=(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} +$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/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index 82d3275..49b566d 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -482,6 +482,22 @@ string proofIdentity = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE 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; @@ -615,6 +631,36 @@ $source $result.Output | Should -Match 'exactly one exact raw inventory record' } + 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' + $result.Output | Should -Match 'exactly one exact raw inventory record' + } + + 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 } @@ -1004,8 +1050,8 @@ $source } finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } - $state.sourceStateSha256 | Should -Be '8acdbeded33e8b41799dadced367401212cdcd6cb6ed3424ea9c82a003326bd0' - $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d8acdbeded33e$' + $state.sourceStateSha256 | Should -Be '514a2ebe272e5d6e099617f784559b6056f8f8b2600a203ad777df08cbe605ec' + $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d514a2ebe272e$' } } From 3f404c2fefc25de65e5e1fb96cd27a7aa41caf96 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 01:21:44 -0400 Subject: [PATCH 15/79] test: stage clean r8 repair proof point --- tests/QA/GraphKitAuthPackage.tests.ps1 | 53 ----------- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 122 ------------------------- 2 files changed, 175 deletions(-) delete mode 100644 tests/QA/GraphKitAuthPackage.tests.ps1 delete mode 100644 tests/Unit/Auth/GraphKitAuth.Tests.ps1 diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 deleted file mode 100644 index 500a72a..0000000 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -$requiredGraphKitAuthCases = @( - @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' } - @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.dll' } - @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.deps.json' } - @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' } - @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.IdentityModel.Abstractions.dll' } -) - -BeforeAll { - Add-Type -AssemblyName System.IO.Compression.FileSystem - - $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath - $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:packagePath = $null - $script:packageEntries = @() - - if (Test-Path -LiteralPath $script:builtManifestPath -PathType Leaf) { - $builtManifest = Import-PowerShellDataFile -Path $script:builtManifestPath - $prerelease = [string] $builtManifest.PrivateData.PSData.Prerelease - $fullVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { - $script:baseVersion - } - else { - "$script:baseVersion-$prerelease" - } - $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$fullVersion.nupkg" - } - - if ($script:packagePath -and (Test-Path -LiteralPath $script:packagePath -PathType Leaf)) { - $archive = [System.IO.Compression.ZipFile]::OpenRead($script:packagePath) - try { - $script:packageEntries = @($archive.Entries.FullName) - } - finally { - $archive.Dispose() - } - } - -} - -Describe 'Packed GraphKit.Auth boundary' -Tag 'QA' { - It 'contains the exact required path ' -ForEach $requiredGraphKitAuthCases { - $script:packagePath | Should -Not -BeNullOrEmpty -Because 'pack must produce a versioned GraphKit candidate' - Test-Path -LiteralPath $script:packagePath -PathType Leaf | Should -BeTrue -Because 'the package boundary is tested against the packed candidate' - - @($script:packageEntries | Where-Object { $_ -ceq $Path }).Count | Should -Be 1 -Because "the archive must contain '$Path' exactly once" - Test-Path -LiteralPath (Join-Path $script:builtModuleRoot $Path) -PathType Leaf | - Should -BeTrue -Because "the packed path '$Path' must originate in the built module" - } -} diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 deleted file mode 100644 index b196680..0000000 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ /dev/null @@ -1,122 +0,0 @@ -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' - $script:contractsAssembly = $null - - if (Test-Path -LiteralPath $script:contractsPath -PathType Leaf) { - $resolvedContractsPath = (Resolve-Path -LiteralPath $script:contractsPath).ProviderPath - $loadedContracts = @( - [System.Runtime.Loader.AssemblyLoadContext]::Default.Assemblies | - Where-Object { $_.GetName().Name -ceq 'GraphKit.Auth.Contracts' } - ) - - if ($loadedContracts.Count -eq 1) { - $script:contractsAssembly = $loadedContracts[0] - } - elseif ($loadedContracts.Count -eq 0) { - $script:contractsAssembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( - $resolvedContractsPath - ) - } - } - - function Get-GraphKitAuthPublicSignatureTypes { - param( - [Parameter(Mandatory)] - [System.Reflection.Assembly] $Assembly - ) - - $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 - } - } - } - - return @($seen) - } -} - -Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { - It 'loads the contract assembly with the exact ABI marker and Acquire result' { - $script:contractsPath | Should -Exist -Because 'Task 3 must build the dependency-free GraphKit.Auth contract assembly' - $script:contractsAssembly | Should -Not -BeNullOrEmpty -Because 'exactly one contracts assembly must load in the default AssemblyLoadContext' - - [GraphKit.Auth.GraphAuthHost]::ContractMarker | Should -Be 'GraphKit.Auth.Abi/1' - [GraphKit.Auth.IGraphTokenSource].GetMethod('Acquire').ReturnType.FullName | - Should -Be 'GraphKit.Auth.GraphTokenResult' - } - - It 'keeps Microsoft.Identity.Client out of every public contract signature' { - $script:contractsPath | Should -Exist -Because 'the public GraphKit.Auth surface can only be inspected after Task 3 builds it' - $script:contractsAssembly | Should -Not -BeNullOrEmpty -Because 'the complete public ABI must be available for reflection' - - $signatureTypes = @(Get-GraphKitAuthPublicSignatureTypes -Assembly $script:contractsAssembly) - $leaks = @( - $signatureTypes | Where-Object { - [string] $_.FullName -like '*Microsoft.Identity.Client*' -or - [string] $_.Assembly.FullName -like '*Microsoft.Identity.Client*' - } - ) - - $leaks.FullName | Should -BeNullOrEmpty -Because 'no MSAL type may cross the GraphKit-owned ABI boundary' - } -} From bc060f12274a366de5e2846daf7c46b91a06d72b Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 01:29:19 -0400 Subject: [PATCH 16/79] test: harden the GraphKit Auth boundary --- tests/QA/GraphKitAuthPackage.tests.ps1 | 128 ++++++++++++ tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 269 +++++++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 tests/QA/GraphKitAuthPackage.tests.ps1 create mode 100644 tests/Unit/Auth/GraphKitAuth.Tests.ps1 diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 new file mode 100644 index 0000000..5160a52 --- /dev/null +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -0,0 +1,128 @@ +$requiredGraphKitAuthCases = @( + @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' } + @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.dll' } + @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.deps.json' } + @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' } + @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.IdentityModel.Abstractions.dll' } +) + +$graphKitAuthArchiveAliasCases = @( + @{ + Kind = 'case alias' + Entries = @( + 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + 'assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + ) + } + @{ + Kind = 'backslash 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' + ) + } +) + +BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $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:packagePath = $null + $script:packageEntries = @() + + if (Test-Path -LiteralPath $script:builtManifestPath -PathType Leaf) { + $builtManifest = Import-PowerShellDataFile -Path $script:builtManifestPath + $prerelease = [string] $builtManifest.PrivateData.PSData.Prerelease + $fullVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { + $script:baseVersion + } + else { + "$script:baseVersion-$prerelease" + } + $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$fullVersion.nupkg" + } + + if ($script:packagePath -and (Test-Path -LiteralPath $script:packagePath -PathType Leaf)) { + $archive = [System.IO.Compression.ZipFile]::OpenRead($script:packagePath) + try { + $script:packageEntries = @($archive.Entries.FullName) + } + finally { + $archive.Dispose() + } + } + + function Assert-GraphKitAuthArchiveEntry { + param( + [Parameter(Mandatory)] [string[]] $Entries, + [Parameter(Mandatory)] [string] $RequiredPath + ) + + $exact = @($Entries | Where-Object { $_ -ceq $RequiredPath }) + $normalizedRequired = $RequiredPath.Replace('\', '/') + $equivalent = @( + $Entries | Where-Object { + $normalized = $_.Replace('\', '/') + [string]::Equals($normalized, $normalizedRequired, [StringComparison]::OrdinalIgnoreCase) + } + ) + if ($exact.Count -ne 1 -or $equivalent.Count -ne 1) { + throw "The archive must contain '$RequiredPath' exactly once with no case, separator, or duplicate equivalent; found $($exact.Count) exact and $($equivalent.Count) equivalent entries." + } + } + + function New-GraphKitAuthArchiveFixture { + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [string[]] $Entries + ) + + $archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($entryName in $Entries) { + $null = $archive.CreateEntry($entryName) + } + } + finally { + $archive.Dispose() + } + + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + return @($archive.Entries.FullName) + } + finally { + $archive.Dispose() + } + } +} + +Describe 'Packed GraphKit.Auth boundary' -Tag 'QA' { + It 'rejects an exact path plus a ' -ForEach $graphKitAuthArchiveAliasCases { + $fixturePath = Join-Path $TestDrive ("graphkit-auth-$($Kind.Replace(' ', '-')).zip") + $entries = @(New-GraphKitAuthArchiveFixture -Path $fixturePath -Entries $Entries) + + { Assert-GraphKitAuthArchiveEntry -Entries $entries -RequiredPath 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' } | + Should -Throw + } + + It 'contains the exact required path ' -ForEach $requiredGraphKitAuthCases { + $script:packagePath | Should -Not -BeNullOrEmpty -Because 'pack must produce a versioned GraphKit candidate' + Test-Path -LiteralPath $script:packagePath -PathType Leaf | Should -BeTrue -Because 'the package boundary is tested against the packed candidate' + + Assert-GraphKitAuthArchiveEntry -Entries $script:packageEntries -RequiredPath $Path + Test-Path -LiteralPath (Join-Path $script:builtModuleRoot $Path) -PathType Leaf | + Should -BeTrue -Because "the packed path '$Path' must originate in the built module" + } +} diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 new file mode 100644 index 0000000..ee6a273 --- /dev/null +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -0,0 +1,269 @@ +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() + } + } + + $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 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' + } +} From 5392dd7bb98ae71f6e9bb5626aac7f6c827e3a29 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 01:56:21 -0400 Subject: [PATCH 17/79] feat: define the GraphKit Auth ABI --- global.json | 7 + src/GraphKit.Auth/Directory.Build.props | 12 + .../GraphKit.Auth.Contracts/Contracts.cs | 308 +++++++++++ .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 485 ++++++++++++++++++ .../GraphAuthLoadContext.cs | 262 ++++++++++ .../GraphKit.Auth.Contracts.csproj | 9 + .../GraphTokenSourceProxy.cs | 177 +++++++ .../packages.lock.json | 6 + tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 360 +++++++++++++ 9 files changed, 1626 insertions(+) create mode 100644 global.json create mode 100644 src/GraphKit.Auth/Directory.Build.props create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Contracts/packages.lock.json 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/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..dc5b102 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs @@ -0,0 +1,308 @@ +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; } + + 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..e6ddde7 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -0,0 +1,485 @@ +using System.Reflection; +using System.Runtime.Loader; +using System.Security.Cryptography; + +namespace GraphKit.Auth; + +public sealed class GraphAuthHost : IDisposable +{ + public const string ContractMarker = "GraphKit.Auth.Abi/1"; + + private const string ExpectedContractMarker = "GraphKit.Auth.Abi/1"; + private const string FactoryTypeName = "GraphKit.Auth.GraphTokenSourceFactory"; + private static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan MaximumShutdownTimeout = TimeSpan.FromMinutes(2); + + private readonly object _gate = new(); + private readonly HashSet _sources = []; + private readonly CancellationTokenSource _shutdown = new(); + private readonly ManualResetEventSlim _drained = new(initialState: true); + private readonly TimeSpan _shutdownTimeout; + private IGraphTokenSourceFactory? _factory; + private GraphAuthLoadContext? _loadContext; + private Assembly? _providerAssembly; + private Type? _factoryType; + 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 = Activator.CreateInstance(factoryType); + 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); + lock (_gate) + { + ThrowIfStopping(); + IGraphTokenSourceFactory factory = _factory ?? + throw new ObjectDisposedException(nameof(GraphAuthHost)); + IGraphTokenSource source = factory.Create(request) ?? + throw new InvalidOperationException("The GraphKit.Auth provider factory returned a null token source."); + + try + { + ValidateProviderSource(source); + GraphTokenSourceProxy proxy = new(this, source); + _sources.Add(proxy); + return proxy; + } + catch + { + source.Dispose(); + throw; + } + } + } + + public void Dispose() + { + List? failures = null; + bool ownsShutdown = Interlocked.CompareExchange(ref _state, 1, 0) == 0; + if (ownsShutdown) + { + _shutdown.Cancel(); + GraphTokenSourceProxy[] sources; + lock (_gate) + { + sources = [.. _sources]; + } + + foreach (GraphTokenSourceProxy source in sources) + { + try + { + source.Dispose(); + } + catch (Exception exception) + { + failures ??= []; + failures.Add(exception); + } + } + } + + if (Volatile.Read(ref _state) == 1) + { + _drained.Wait(_shutdownTimeout); + TryFinalizeUnload(); + } + + if (failures is not null) + { + throw new AggregateException( + "One or more GraphKit.Auth provider sources failed while the host was shutting down.", + failures); + } + } + + internal GraphAuthOperationLease EnterOperation(CancellationToken callerCancellation) + { + ThrowIfStopping(); + int active = Interlocked.Increment(ref _activeOperations); + if (active == 1) + { + _drained.Reset(); + } + + if (Volatile.Read(ref _state) != 0) + { + ExitOperation(); + throw new ObjectDisposedException(nameof(GraphAuthHost)); + } + + try + { + CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + callerCancellation, + _shutdown.Token); + return new GraphAuthOperationLease(this, linked); + } + catch + { + ExitOperation(); + throw; + } + } + + internal void Unregister(GraphTokenSourceProxy source) + { + lock (_gate) + { + _sources.Remove(source); + } + } + + 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}'."); + } + + if (!string.Equals(ContractMarker, ExpectedContractMarker, StringComparison.Ordinal)) + { + throw IncompatibleContracts( + $"the loaded contract marker is '{ContractMarker}', not '{ExpectedContractMarker}'."); + } + + 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}"); + } + StringComparison pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!string.Equals(physicalLoaded, physicalCandidate, pathComparison)) + { + throw IncompatibleContracts( + $"the default context contains contracts from '{physicalLoaded}', not package candidate '{physicalCandidate}'."); + } + + AssemblyName candidateIdentity = AssemblyName.GetAssemblyName(physicalCandidate); + if (!AssemblyIdentity.EqualsExactReference(loadedIdentity, candidateIdentity) || + !FilesHaveSameSha256(physicalLoaded, physicalCandidate)) + { + throw IncompatibleContracts( + $"the loaded contracts identity or bytes do not match package candidate '{physicalCandidate}'."); + } + + 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.IsFrameworkAssembly(typeAssembly.GetName())) + { + 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 bool FilesHaveSameSha256(string firstPath, string secondPath) + { + using FileStream first = File.OpenRead(firstPath); + using FileStream second = File.OpenRead(secondPath); + byte[] firstHash = SHA256.HashData(first); + byte[] secondHash = SHA256.HashData(second); + return CryptographicOperations.FixedTimeEquals(firstHash, secondHash); + } + + 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) != 0) + { + 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) + { + _drained.Set(); + if (Volatile.Read(ref _state) == 1) + { + TryFinalizeUnload(); + } + } + } + + private void TryFinalizeUnload() + { + if (Volatile.Read(ref _activeOperations) != 0 || + Interlocked.CompareExchange(ref _state, 2, 1) != 1) + { + return; + } + + GraphAuthLoadContext? loadContext; + lock (_gate) + { + _sources.Clear(); + _factory = null; + _factoryType = null; + _providerAssembly = null; + loadContext = _loadContext; + _loadContext = null; + } + + loadContext?.Unload(); + _shutdown.Dispose(); + } + + 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..e368630 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs @@ -0,0 +1,262 @@ +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) + { + if (AssemblyIdentity.IsFrameworkAssembly(assemblyName)) + { + return null; + } + + 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 +{ + 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 IsFrameworkAssembly(AssemblyName identity) + { + string name = identity.Name ?? string.Empty; + return name is "mscorlib" or "netstandard" or "Microsoft.CSharp" or "System" or "System.Private.CoreLib" || + name.StartsWith("System.", StringComparison.Ordinal); + } +} + +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 = pathRoot; + string remainder = fullPath[pathRoot.Length..]; + string[] components = remainder.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + foreach (string component in components) + { + current = Path.Combine(current, component); + FileSystemInfo info = Directory.Exists(current) + ? new DirectoryInfo(current) + : new FileInfo(current); + if (!info.Exists) + { + throw new FileNotFoundException( + $"Cannot resolve physical path because '{current}' does not exist.", + current); + } + + 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) + { + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string normalizedRoot = Path.TrimEndingDirectorySeparator(root); + if (string.Equals(candidate, normalizedRoot, comparison)) + { + return false; + } + + string prefix = normalizedRoot + Path.DirectorySeparatorChar; + return candidate.StartsWith(prefix, comparison); + } +} 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..12ffad7 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -0,0 +1,177 @@ +namespace GraphKit.Auth; + +public sealed class GraphTokenSourceProxy : IGraphTokenSource +{ + private IGraphTokenSource? _inner; + private IGraphTokenSource? _retiredInner; + private GraphAuthHost? _owner; + 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); + return operation.Inner.Acquire(forceRefresh, operation.Cancellation); + } + + public void AdoptSharedResult(GraphTokenResult result, bool forceRefresh) + { + using ProxyOperation operation = BeginOperation(CancellationToken.None); + operation.Inner.AdoptSharedResult(result, forceRefresh); + } + + public void Dispose() + { + if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) + { + return; + } + + GraphAuthHost? owner = Interlocked.Exchange(ref _owner, null); + IGraphTokenSource? inner = Interlocked.Exchange(ref _inner, null); + Volatile.Write(ref _retiredInner, inner); + try + { + DisposeRetiredInnerWhenIdle(); + } + finally + { + if (owner is not null && + Interlocked.CompareExchange(ref _hostNotificationState, 1, 0) == 0) + { + owner.Unregister(this); + } + } + } + + private TResult Read(Func reader) + { + using ProxyOperation operation = BeginOperation(CancellationToken.None); + return reader(operation.Inner); + } + + 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; + } + + Interlocked.Exchange(ref _retiredInner, null)?.Dispose(); + } + + 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(); + } + } + } +} 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/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index ee6a273..cee9fe4 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -205,6 +205,275 @@ $sourceType = $assembly.GetType('GraphKit.Auth.IGraphTokenSource', $true, $false } } + function New-GraphKitAuthProviderFixtureAssembly { + param( + [Parameter(Mandatory)] [string] $Root, + [string] $AssemblyName = 'GraphKit.Auth', + [string] $AssemblyVersion = '1.0.0.0' + ) + + $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.Threading; +using GraphKit.Auth; + +namespace GraphKit.Auth; + +public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory +{ + public IGraphTokenSource Create(GraphTokenRequest request) => new FixtureTokenSource(request); +} + +internal sealed class FixtureTokenSource : IGraphTokenSource +{ + private readonly GraphTokenRequest _request; + private readonly string? _disposeMarker = Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_MARKER"); + private int _disposed; + + public FixtureTokenSource(GraphTokenRequest request) => _request = request; + + public bool CanRefresh => true; + public string AuthMode => _request.AuthMode.ToString(); + public string Audience => _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 (forceRefresh) + { + throw new GraphAuthException("fixture", "Fixture", "provider failure", TimeSpan.FromSeconds(7), "fixture-correlation"); + } + + 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); + 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); + } + } +} +'@ + Set-Content -LiteralPath $projectPath -NoNewline -Encoding utf8NoBOM -Value @" + + + net8.0 + $AssemblyName + $AssemblyVersion + enable + enable + true + true + none + + + + $escapedContractsPath + false + + + +"@ + + $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 Invoke-GraphKitAuthRuntimeProbe { + param( + [Parameter(Mandatory)] [string] $ContractsPath, + [string] $PayloadRoot, + [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure')] [string] $Scenario, + [string] $DisposeMarker + ) + + $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 +) +$ErrorActionPreference = 'Stop' +$null = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath( + (Resolve-Path -LiteralPath $ContractsPath).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 Get-Rejection { + param([scriptblock] $Action) + try { + & $Action + return $null + } + catch { + return $_.Exception.GetBaseException().Message + } +} + +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) }) + $second = $authHost.CreateSource((New-ValidRequest)) + $authHost.Dispose() + $secondRejected = $null -ne (Get-Rejection { $second.Acquire($false, [Threading.CancellationToken]::None) }) + $createRejected = $null -ne (Get-Rejection { $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 + SecondRejected = $secondRejected + CreateRejected = $createRejected + 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)) + try { + $null = $source.Acquire($true, [Threading.CancellationToken]::None) + throw 'The provider fixture did not fail.' + } + catch [GraphKit.Auth.GraphAuthException] { + [pscustomobject]@{ + Type = $_.Exception.GetType().FullName + Code = $_.Exception.Code + Category = $_.Exception.Category + Message = $_.Exception.Message + RetryAfterSeconds = $_.Exception.RetryAfter.TotalSeconds + CorrelationId = $_.Exception.CorrelationId + InnerIsNull = $null -eq $_.Exception.InnerException + } | ConvertTo-Json -Compress + } + finally { + $source.Dispose() + $authHost.Dispose() + } + } + '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 + } +} +'@ + + $arguments = @( + '-NoLogo', '-NoProfile', '-File', $probePath, + '-ContractsPath', $ContractsPath, + '-Scenario', $Scenario + ) + if ($PayloadRoot) { $arguments += @('-PayloadRoot', $PayloadRoot) } + if ($DisposeMarker) { $arguments += @('-DisposeMarker', $DisposeMarker) } + $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() + } + } + $script:contractsInspection = if (Test-Path -LiteralPath $script:contractsPath -PathType Leaf) { Invoke-GraphKitAuthContractsCandidateProbe -CandidatePath $script:contractsPath } @@ -267,3 +536,94 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { @($script:contractsInspection.Data.Leaks) | Should -BeNullOrEmpty -Because 'no MSAL type may cross the GraphKit-owned ABI boundary' } } + +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.SecondRejected | Should -BeTrue + $result.Data.CreateRejected | Should -BeTrue + $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 'preserves GraphAuthException failures without catching and relabeling them' { + $payloadRoot = Join-Path $TestDrive 'failing-provider' + $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot + 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 ProviderFailure + + $result.ExitCode | Should -Be 0 -Because $result.Output + $result.Data.Type | Should -BeExactly 'GraphKit.Auth.GraphAuthException' + $result.Data.Code | Should -BeExactly 'fixture' + $result.Data.Category | Should -BeExactly 'Fixture' + $result.Data.Message | Should -BeExactly 'provider failure' + $result.Data.RetryAfterSeconds | Should -Be 7 + $result.Data.CorrelationId | Should -BeExactly 'fixture-correlation' + $result.Data.InnerIsNull | Should -BeTrue + } + + 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'" + } +} From 73d9bb11cda0c04f4db05f13d18cd0c562382098 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 02:38:07 -0400 Subject: [PATCH 18/79] fix: harden GraphKit Auth contract isolation --- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 141 +++-- .../GraphAuthLoadContext.cs | 132 ++++- .../GraphTokenSourceProxy.cs | 2 +- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 499 +++++++++++++++++- 4 files changed, 710 insertions(+), 64 deletions(-) diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index e6ddde7..61a44a7 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -1,6 +1,7 @@ using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; using System.Runtime.Loader; -using System.Security.Cryptography; namespace GraphKit.Auth; @@ -10,6 +11,10 @@ public sealed class GraphAuthHost : IDisposable private const string ExpectedContractMarker = "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); @@ -17,6 +22,7 @@ public sealed class GraphAuthHost : IDisposable private readonly HashSet _sources = []; private readonly CancellationTokenSource _shutdown = new(); private readonly ManualResetEventSlim _drained = new(initialState: true); + private readonly ManualResetEventSlim _shutdownCompleted = new(initialState: false); private readonly TimeSpan _shutdownTimeout; private IGraphTokenSourceFactory? _factory; private GraphAuthLoadContext? _loadContext; @@ -110,11 +116,28 @@ public IGraphTokenSource CreateSource(GraphTokenRequest request) public void Dispose() { + bool ownsShutdown = Interlocked.CompareExchange( + ref _state, + ShutdownOwnerDisposingSources, + Running) == Running; + if (!ownsShutdown) + { + _shutdownCompleted.Wait(_shutdownTimeout); + return; + } + List? failures = null; - bool ownsShutdown = Interlocked.CompareExchange(ref _state, 1, 0) == 0; - if (ownsShutdown) + try { - _shutdown.Cancel(); + try + { + _shutdown.Cancel(); + } + catch (Exception exception) + { + failures = [exception]; + } + GraphTokenSourceProxy[] sources; lock (_gate) { @@ -134,9 +157,9 @@ public void Dispose() } } } - - if (Volatile.Read(ref _state) == 1) + finally { + Volatile.Write(ref _state, SourcesDisposedAwaitingDrain); _drained.Wait(_shutdownTimeout); TryFinalizeUnload(); } @@ -158,7 +181,7 @@ internal GraphAuthOperationLease EnterOperation(CancellationToken callerCancella _drained.Reset(); } - if (Volatile.Read(ref _state) != 0) + if (Volatile.Read(ref _state) != Running) { ExitOperation(); throw new ObjectDisposedException(nameof(GraphAuthHost)); @@ -236,21 +259,33 @@ private static Assembly ValidateDefaultContractsAssembly(string physicalPayloadR throw IncompatibleContracts( $"the loaded contracts location is not the declared package candidate: {exception.Message}"); } - StringComparison pathComparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - if (!string.Equals(physicalLoaded, physicalCandidate, pathComparison)) + if (!string.Equals(physicalLoaded, physicalCandidate, StringComparison.Ordinal)) { throw IncompatibleContracts( $"the default context contains contracts from '{physicalLoaded}', not package candidate '{physicalCandidate}'."); } - AssemblyName candidateIdentity = AssemblyName.GetAssemblyName(physicalCandidate); - if (!AssemblyIdentity.EqualsExactReference(loadedIdentity, candidateIdentity) || - !FilesHaveSameSha256(physicalLoaded, 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 loaded contracts identity or bytes do not match package candidate '{physicalCandidate}'."); + $"the resident contracts identity or MVID does not match package candidate '{physicalCandidate}' " + + $"(resident MVID '{loadedMvid:D}', candidate MVID '{candidateMetadata.CandidateMvid:D}')."); } return contractsAssembly; @@ -361,7 +396,7 @@ private static void ValidateSignatureType( Assembly typeAssembly = type.Assembly; if (ReferenceEquals(typeAssembly, contractsAssembly) || - AssemblyIdentity.IsFrameworkAssembly(typeAssembly.GetName())) + AssemblyIdentity.IsTrustedPlatformAssembly(typeAssembly)) { return; } @@ -389,13 +424,37 @@ loadContext is null || } } - private static bool FilesHaveSameSha256(string firstPath, string secondPath) + private static (AssemblyName Identity, Guid ModuleVersionId) ReadManagedAssemblyMetadata( + string assemblyPath) { - using FileStream first = File.OpenRead(firstPath); - using FileStream second = File.OpenRead(secondPath); - byte[] firstHash = SHA256.HashData(first); - byte[] secondHash = SHA256.HashData(second); - return CryptographicOperations.FixedTimeEquals(firstHash, secondHash); + 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) @@ -407,7 +466,7 @@ private static InvalidOperationException IncompatibleContracts(string detail) private void ThrowIfStopping() { - if (Volatile.Read(ref _state) != 0) + if (Volatile.Read(ref _state) != Running) { throw new ObjectDisposedException( nameof(GraphAuthHost), @@ -420,7 +479,7 @@ private void ExitOperation() if (Interlocked.Decrement(ref _activeOperations) == 0) { _drained.Set(); - if (Volatile.Read(ref _state) == 1) + if (Volatile.Read(ref _state) == SourcesDisposedAwaitingDrain) { TryFinalizeUnload(); } @@ -430,24 +489,34 @@ private void ExitOperation() private void TryFinalizeUnload() { if (Volatile.Read(ref _activeOperations) != 0 || - Interlocked.CompareExchange(ref _state, 2, 1) != 1) + Interlocked.CompareExchange( + ref _state, + Finalized, + SourcesDisposedAwaitingDrain) != SourcesDisposedAwaitingDrain) { return; } - GraphAuthLoadContext? loadContext; - lock (_gate) + try { - _sources.Clear(); - _factory = null; - _factoryType = null; - _providerAssembly = null; - loadContext = _loadContext; - _loadContext = null; - } + GraphAuthLoadContext? loadContext; + lock (_gate) + { + _sources.Clear(); + _factory = null; + _factoryType = null; + _providerAssembly = null; + loadContext = _loadContext; + _loadContext = null; + } - loadContext?.Unload(); - _shutdown.Dispose(); + loadContext?.Unload(); + _shutdown.Dispose(); + } + finally + { + _shutdownCompleted.Set(); + } } internal sealed class GraphAuthOperationLease : IDisposable diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs index e368630..621e847 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs @@ -79,9 +79,11 @@ internal Assembly LoadProviderAssembly() string? resolvedPath = _resolver.ResolveAssemblyToPath(assemblyName); if (resolvedPath is null) { - if (AssemblyIdentity.IsFrameworkAssembly(assemblyName)) + Assembly? trustedPlatformAssembly = + AssemblyIdentity.ResolveTrustedPlatformAssemblyReference(assemblyName); + if (trustedPlatformAssembly is not null) { - return null; + return trustedPlatformAssembly; } throw new FileNotFoundException( @@ -152,6 +154,9 @@ private void ValidateProviderIdentity(AssemblyName identity) 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) && @@ -163,11 +168,70 @@ internal static bool EqualsExactReference(AssemblyName requested, AssemblyName a requested.GetPublicKeyToken().AsSpan().SequenceEqual(actual.GetPublicKeyToken()); } - internal static bool IsFrameworkAssembly(AssemblyName identity) + internal static bool IsTrustedPlatformAssemblyReference(AssemblyName identity) + { + return TrustedPlatformAssemblyIdentities.Value.Any( + trusted => EqualsExactReference(identity, trusted)); + } + + internal static Assembly? ResolveTrustedPlatformAssemblyReference(AssemblyName reference) { - string name = identity.Name ?? string.Empty; - return name is "mscorlib" or "netstandard" or "Microsoft.CSharp" or "System" or "System.Private.CoreLib" || - name.StartsWith("System.", StringComparison.Ordinal); + 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]; } } @@ -216,7 +280,7 @@ private static string ResolveExistingPath(string path) throw new IOException($"Path '{fullPath}' has no filesystem root."); } - string current = pathRoot; + string current = new DirectoryInfo(pathRoot).FullName; string remainder = fullPath[pathRoot.Length..]; string[] components = remainder.Split( [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], @@ -224,16 +288,8 @@ private static string ResolveExistingPath(string path) foreach (string component in components) { - current = Path.Combine(current, component); - FileSystemInfo info = Directory.Exists(current) - ? new DirectoryInfo(current) - : new FileInfo(current); - if (!info.Exists) - { - throw new FileNotFoundException( - $"Cannot resolve physical path because '{current}' does not exist.", - current); - } + FileSystemInfo info = ResolveActualChild(current, component); + current = info.FullName; FileSystemInfo? target = info.ResolveLinkTarget(returnFinalTarget: true); if (target is not null) @@ -247,16 +303,48 @@ private static string ResolveExistingPath(string path) private static bool IsDescendant(string candidate, string root) { - StringComparison comparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; string normalizedRoot = Path.TrimEndingDirectorySeparator(root); - if (string.Equals(candidate, normalizedRoot, comparison)) + if (string.Equals(candidate, normalizedRoot, StringComparison.Ordinal)) { return false; } string prefix = normalizedRoot + Path.DirectorySeparatorChar; - return candidate.StartsWith(prefix, comparison); + 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/GraphTokenSourceProxy.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs index 12ffad7..8ed22a8 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -1,6 +1,6 @@ namespace GraphKit.Auth; -public sealed class GraphTokenSourceProxy : IGraphTokenSource +internal sealed class GraphTokenSourceProxy : IGraphTokenSource { private IGraphTokenSource? _inner; private IGraphTokenSource? _retiredInner; diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index cee9fe4..c545e51 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -209,7 +209,9 @@ $sourceType = $assembly.GetType('GraphKit.Auth.IGraphTokenSource', $true, $false param( [Parameter(Mandatory)] [string] $Root, [string] $AssemblyName = 'GraphKit.Auth', - [string] $AssemblyVersion = '1.0.0.0' + [string] $AssemblyVersion = '1.0.0.0', + [string] $AdditionalReferencePath, + [string] $PublicSurfaceDeclaration ) $null = New-Item -ItemType Directory -Path $Root -Force @@ -226,7 +228,9 @@ namespace GraphKit.Auth; public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory { + public Uri FrameworkUri => new("https://graph.microsoft.com"); public IGraphTokenSource Create(GraphTokenRequest request) => new FixtureTokenSource(request); + // TEST_PUBLIC_SURFACE } internal sealed class FixtureTokenSource : IGraphTokenSource @@ -288,6 +292,24 @@ internal sealed class FixtureTokenSource : IGraphTokenSource } } '@ + 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 @" @@ -305,6 +327,7 @@ internal sealed class FixtureTokenSource : IGraphTokenSource $escapedContractsPath false +$additionalReference "@ @@ -318,12 +341,264 @@ internal sealed class FixtureTokenSource : IGraphTokenSource 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.IO; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GraphKit.Auth; + +public static class GraphKitAuthRuntimeHarness +{ + 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 + }); + } +} +'@ + 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 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)" +} + +$lines = [Collections.Generic.List[string]]::new() +$flags = [Reflection.BindingFlags]'Public,Instance,Static,DeclaredOnly' +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") + + 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)") + } + + 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 ',')") + } + + 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)") + } + + 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 ',')") + } + + 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 | 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', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure')] [string] $Scenario, - [string] $DisposeMarker + [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'SamePathReplacement')] [string] $Scenario, + [string] $DisposeMarker, + [string] $ReplacementContractsPath, + [string] $PreloadPath, + [string] $HarnessPath ) $probePath = Join-Path $TestDrive ('Probe-Runtime-' + [guid]::NewGuid().ToString('N') + '.ps1') @@ -332,12 +607,25 @@ param( [Parameter(Mandatory)] [string] $ContractsPath, [string] $PayloadRoot, [Parameter(Mandatory)] [string] $Scenario, - [string] $DisposeMarker + [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( @@ -355,7 +643,7 @@ function New-ValidRequest { function Get-Rejection { param([scriptblock] $Action) try { - & $Action + $null = & $Action return $null } catch { @@ -454,6 +742,36 @@ switch ($Scenario) { $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 + } + 'SamePathReplacement' { + $residentMvid = [GraphKit.Auth.GraphAuthHost].Assembly.ManifestModule.ModuleVersionId.ToString('D') + [IO.File]::Copy( + (Resolve-Path -LiteralPath $ReplacementContractsPath).ProviderPath, + (Resolve-Path -LiteralPath $ContractsPath).ProviderPath, + $true + ) + $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 + } } '@ @@ -464,6 +782,9 @@ switch ($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 @@ -535,6 +856,82 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { $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 'matches the literal ABI-v1 public surface without extra exported types or members' { + $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|' + ) | Sort-Object + + $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $script:contractsPath + $differences = @(Compare-Object -ReferenceObject $expectedSurface -DifferenceObject @($result.Data) -SyncWindow 10000) + + $result.ExitCode | Should -Be 0 -Because $result.Output + $differences | Should -BeNullOrEmpty -Because "ABI-v1 is literal, not inferred from the candidate:`n$($differences | Format-Table | Out-String)" + } } Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { @@ -567,6 +964,98 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $result.Data.LoadContextAlive | Should -BeFalse } + It 'keeps one shutdown owner under concurrent Dispose callers and releases every collectible reference' { + $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 '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 'preserves GraphAuthException failures without catching and relabeling them' { $payloadRoot = Join-Path $TestDrive 'failing-provider' $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot From c5bc803628ffad479c4d20fe9bcd84a1e78cd295 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 03:11:44 -0400 Subject: [PATCH 19/79] fix: bound GraphKit Auth shutdown --- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 106 ++-- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 517 +++++++++++++++++- 2 files changed, 591 insertions(+), 32 deletions(-) diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index 61a44a7..0d245a0 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Reflection; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; @@ -28,6 +29,7 @@ public sealed class GraphAuthHost : IDisposable private GraphAuthLoadContext? _loadContext; private Assembly? _providerAssembly; private Type? _factoryType; + private Task? _shutdownTask; private int _activeOperations; private int _state; @@ -116,58 +118,100 @@ public IGraphTokenSource CreateSource(GraphTokenRequest request) public void Dispose() { - bool ownsShutdown = Interlocked.CompareExchange( - ref _state, - ShutdownOwnerDisposingSources, - Running) == Running; - if (!ownsShutdown) + Stopwatch deadline = Stopwatch.StartNew(); + Task shutdownTask = GetOrStartShutdown(); + bool shutdownStageCompleted; + try + { + shutdownStageCompleted = shutdownTask.Wait(_shutdownTimeout); + } + catch (AggregateException) + { + shutdownStageCompleted = true; + } + + if (!shutdownStageCompleted) { - _shutdownCompleted.Wait(_shutdownTimeout); return; } + TimeSpan remaining = _shutdownTimeout - deadline.Elapsed; + if (remaining > TimeSpan.Zero) + { + _shutdownCompleted.Wait(remaining); + } + + shutdownTask.GetAwaiter().GetResult(); + } + + private Task GetOrStartShutdown() + { + lock (_gate) + { + if (_shutdownTask is not null) + { + return _shutdownTask; + } + + Volatile.Write(ref _state, ShutdownOwnerDisposingSources); + Task shutdownTask = CancelAndDisposeSourcesAsync(); + _shutdownTask = shutdownTask; + _ = shutdownTask.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return shutdownTask; + } + } + + private async Task CancelAndDisposeSourcesAsync() + { List? failures = null; try + { + await _shutdown.CancelAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + failures = [exception]; + } + + GraphTokenSourceProxy[] sources; + lock (_gate) + { + sources = [.. _sources]; + } + + foreach (GraphTokenSourceProxy source in sources) { try { - _shutdown.Cancel(); + source.Dispose(); } catch (Exception exception) { - failures = [exception]; - } - - GraphTokenSourceProxy[] sources; - lock (_gate) - { - sources = [.. _sources]; - } - - foreach (GraphTokenSourceProxy source in sources) - { - try - { - source.Dispose(); - } - catch (Exception exception) - { - failures ??= []; - failures.Add(exception); - } + failures ??= []; + failures.Add(exception); } } - finally + + Volatile.Write(ref _state, SourcesDisposedAwaitingDrain); + try { - Volatile.Write(ref _state, SourcesDisposedAwaitingDrain); - _drained.Wait(_shutdownTimeout); TryFinalizeUnload(); } + catch (Exception exception) + { + failures ??= []; + failures.Add(exception); + } if (failures is not null) { throw new AggregateException( - "One or more GraphKit.Auth provider sources failed while the host was shutting down.", + "One or more GraphKit.Auth cancellation callbacks or provider sources failed while the host was shutting down.", failures); } } diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index c545e51..b24284f 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -235,6 +235,8 @@ public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory 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 string? _disposeMarker = Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_MARKER"); private int _disposed; @@ -253,6 +255,18 @@ internal sealed class FixtureTokenSource : IGraphTokenSource { 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 new GraphAuthException("fixture", "Fixture", "provider failure", TimeSpan.FromSeconds(7), "fixture-correlation"); @@ -290,6 +304,11 @@ internal sealed class FixtureTokenSource : IGraphTokenSource File.AppendAllText(_disposeMarker, "disposed" + Environment.NewLine); } } + + internal static bool WaitForBlockedAcquire(TimeSpan timeout) => + BlockedAcquireEntered.Wait(timeout); + + internal static void ReleaseBlockedAcquire() => BlockedAcquireRelease.Set(); } '@ if (-not [string]::IsNullOrWhiteSpace($PublicSurfaceDeclaration)) { @@ -387,6 +406,7 @@ public sealed class Counterfeit $escapedContractsPath = [System.Security.SecurityElement]::Escape($script:contractsPath) Set-Content -LiteralPath $sourcePath -NoNewline -Encoding utf8NoBOM -Value @' using System; +using System.Diagnostics; using System.IO; using System.Reflection; using System.Text.Json; @@ -470,6 +490,143 @@ public static class GraphKitAuthRuntimeHarness 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."); + 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); + using CancellationTokenRegistration registration = shutdown.Token.Register(() => + { + callbackEntered.Set(); + 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, + 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 + }); + } } '@ Set-Content -LiteralPath $projectPath -NoNewline -Encoding utf8NoBOM -Value @" @@ -499,6 +656,60 @@ public static class GraphKitAuthRuntimeHarness 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 + 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) @@ -529,13 +740,109 @@ function Get-ParameterDisplay { "$(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)) { @@ -547,6 +854,11 @@ foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { 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)) { @@ -559,11 +871,38 @@ foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { $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)) { @@ -571,11 +910,18 @@ foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { 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 @@ -594,7 +940,7 @@ foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { param( [Parameter(Mandatory)] [string] $ContractsPath, [string] $PayloadRoot, - [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'SamePathReplacement')] [string] $Scenario, + [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'BlockedCancellationCallback', 'SamePathReplacement')] [string] $Scenario, [string] $DisposeMarker, [string] $ReplacementContractsPath, [string] $PreloadPath, @@ -756,6 +1102,27 @@ switch ($Scenario) { $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 + } 'SamePathReplacement' { $residentMvid = [GraphKit.Auth.GraphAuthHost].Assembly.ManifestModule.ModuleVersionId.ToString('D') [IO.File]::Copy( @@ -857,6 +1224,30 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { @($script:contractsInspection.Data.Leaks) | Should -BeNullOrEmpty -Because 'no MSAL type may cross the GraphKit-owned ABI boundary' } + It 'detects an enum underlying-type mutation in the ABI snapshot metadata' { + $mutatedPath = New-GraphKitAuthAbiMutationAssembly ` + -Root (Join-Path $TestDrive 'abi-enum-byte') -Mutation EnumUnderlyingByte + + $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $mutatedPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + @($result.Data) | Should -Contain ` + 'TYPE-META|GraphKit.Auth.GraphAuthMode|staticType=false|enumUnderlying=System.Byte|genericArity=0' ` + -Because 'the ABI gate must distinguish the frozen Int32 enum from an otherwise identical byte enum' + } + + It 'detects a nullable-reference mutation in constructor parameter metadata' { + $mutatedPath = New-GraphKitAuthAbiMutationAssembly ` + -Root (Join-Path $TestDrive 'abi-correlation-nonnullable') -Mutation CorrelationIdNonNullable + + $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $mutatedPath + + $result.ExitCode | Should -Be 0 -Because $result.Output + @($result.Data) | Should -Contain ` + '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=NotNull/NotNull' ` + -Because 'the ABI gate must distinguish a non-null correlationId parameter from the frozen nullable parameter' + } + It 'matches the literal ABI-v1 public surface without extra exported types or members' { $expectedSurface = @( 'CTOR|GraphKit.Auth.CertificateCredential|(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate,System.Boolean ownsMaterial)' @@ -924,6 +1315,102 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { '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 $script:contractsPath @@ -984,6 +1471,34 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $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.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 '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') From 69ec2a2153ded88abcbe00dec5256d2501d1f0b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 03:46:03 -0400 Subject: [PATCH 20/79] fix: sanitize GraphKit Auth shutdown failures --- .../plans/2026-08-30-r8-graphkit-auth.md | 27 +- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 204 +++++--- .../GraphTokenSourceProxy.cs | 80 +++- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 447 +++++++++++++++++- 4 files changed, 658 insertions(+), 100 deletions(-) diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md index c87a29a..e23892d 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -342,7 +342,26 @@ git commit -m "feat: add the isolated GraphKit Auth provider" - Test: `tests/QA/GraphKitAuthPackage.tests.ps1` - Test: `tests/QA/BuiltModule.tests.ps1` -- [ ] **Step 1: Add the locked build and allowlisted copy tasks** +- [ ] **Step 1: Require digest-bound immutable staging before package copy or import** + +Treat the archive/package digest as an input assertion only; an archive digest by itself is +insufficient to bind the bytes that the module later copies or imports. After locked publish, +derive an exact manifest of the permitted runtime closure and SHA-256 digest of every staged file. +Create a new permission-restricted immutable-per-version staging directory with create-new +semantics: it must never reuse, merge with, or overwrite an existing version directory. Copy only +the manifest-bound bytes into that directory, reject symbolic links, hard links, junctions, +reparse-point aliases, path escapes, and any directory or file that retains a writable mutation +route, then seal the complete directory and files against mutation. + +Immediately before `Copy_GraphKitAuth_Into_BuiltModule` and again before the package import proof, +re-open the sealed staging root, verify its exact file closure and every digest against the bound +manifest, verify that the staging root is still permission-restricted and immutable, and fail closed +if any entry is missing, extra, linked/aliased, writable, replaced, or changed. Tests must prove that +an existing version directory cannot be reused or overwritten, a post-digest mutation is rejected, +links/reparse aliases and writable routes are rejected, and only the freshly created sealed staged +bytes can reach copy/import. + +- [ ] **Step 2: Add the locked build and allowlisted copy tasks** `Build_GraphKitAuth` runs locked restore, .NET tests, and Release publish into `output/GraphKit.Auth/stage`. `Copy_GraphKitAuth_Into_BuiltModule` accepts only: @@ -360,7 +379,7 @@ $allowed = @( If MSAL 4.82.1's locked runtime closure adds another managed dependency, add that exact filename to the allowlist and package test in the same commit; never use `Copy-Item *`. -- [ ] **Step 2: Wire the workflow and built manifest** +- [ ] **Step 3: Wire the workflow and built manifest** Insert the two build tasks in the order fixed by the R8 design. Set `RequiredAssemblies = @('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')` only in the built @@ -369,7 +388,7 @@ built path. Keep source `RequiredAssemblies` empty so source validation never po file absent from `source/`. Add `actions/setup-dotnet@v4` with `10.0.400` before dependency restore in each existing matrix row. -- [ ] **Step 3: Pack and run package tests** +- [ ] **Step 4: Pack and run package tests** Run: @@ -382,7 +401,7 @@ Invoke-Pester ./tests/QA/GraphKitAuthPackage.tests.ps1,./tests/QA/BuiltModule.te Expected: contracts load in Default ALC; provider and exact MSAL load in the named non-default ALC; every packaged runtime file is allowlisted; no PDB/ref/native file exists. -- [ ] **Step 4: Commit** +- [ ] **Step 5: Commit** ```bash git add .build/GraphKitAuth.tasks.ps1 build.yaml source/GraphKit.psd1 .github/workflows/ci.yml tests/QA/GraphKitAuthPackage.tests.ps1 tests/QA/BuiltModule.tests.ps1 diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index 0d245a0..3dd83f0 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Reflection; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; @@ -21,9 +20,12 @@ public sealed class GraphAuthHost : IDisposable private readonly object _gate = new(); private readonly HashSet _sources = []; + private readonly List _sourceDisposalFailures = []; private readonly CancellationTokenSource _shutdown = new(); private readonly ManualResetEventSlim _drained = new(initialState: true); private readonly ManualResetEventSlim _shutdownCompleted = new(initialState: false); + private readonly TaskCompletionSource _finalizationCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TimeSpan _shutdownTimeout; private IGraphTokenSourceFactory? _factory; private GraphAuthLoadContext? _loadContext; @@ -110,7 +112,15 @@ public IGraphTokenSource CreateSource(GraphTokenRequest request) } catch { - source.Dispose(); + try + { + source.Dispose(); + } + catch + { + throw CreateProviderDisposalFailure(); + } + throw; } } @@ -118,27 +128,16 @@ public IGraphTokenSource CreateSource(GraphTokenRequest request) public void Dispose() { - Stopwatch deadline = Stopwatch.StartNew(); Task shutdownTask = GetOrStartShutdown(); - bool shutdownStageCompleted; try { - shutdownStageCompleted = shutdownTask.Wait(_shutdownTimeout); + if (!shutdownTask.Wait(_shutdownTimeout)) + { + return; + } } catch (AggregateException) { - shutdownStageCompleted = true; - } - - if (!shutdownStageCompleted) - { - return; - } - - TimeSpan remaining = _shutdownTimeout - deadline.Elapsed; - if (remaining > TimeSpan.Zero) - { - _shutdownCompleted.Wait(remaining); } shutdownTask.GetAwaiter().GetResult(); @@ -146,6 +145,8 @@ public void Dispose() private Task GetOrStartShutdown() { + TaskCompletionSource shutdownCompletion; + Task shutdownTask; lock (_gate) { if (_shutdownTask is not null) @@ -153,66 +154,89 @@ private Task GetOrStartShutdown() return _shutdownTask; } - Volatile.Write(ref _state, ShutdownOwnerDisposingSources); - Task shutdownTask = CancelAndDisposeSourcesAsync(); + 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); - return shutdownTask; } + + 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 CancelAndDisposeSourcesAsync() + private async Task RunShutdownAsync(TaskCompletionSource shutdownCompletion) { - List? failures = null; + List failures = []; try - { - await _shutdown.CancelAsync().ConfigureAwait(false); - } - catch (Exception exception) - { - failures = [exception]; - } - - GraphTokenSourceProxy[] sources; - lock (_gate) - { - sources = [.. _sources]; - } - - foreach (GraphTokenSourceProxy source in sources) { try { - source.Dispose(); + await _shutdown.CancelAsync().ConfigureAwait(false); } - catch (Exception exception) + catch { - failures ??= []; - failures.Add(exception); + failures.Add(CreateCancellationFailure()); } - } - Volatile.Write(ref _state, SourcesDisposedAwaitingDrain); - try - { + 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(); + } + + Volatile.Write(ref _state, SourcesDisposedAwaitingDrain); TryFinalizeUnload(); + GraphAuthException? finalizationFailure = + await _finalizationCompletion.Task.ConfigureAwait(false); + if (finalizationFailure is not null) + { + failures.Add(finalizationFailure); + } } - catch (Exception exception) + catch { - failures ??= []; - failures.Add(exception); + failures.Add(CreateHostShutdownFailure()); } - - if (failures is not null) + finally { - throw new AggregateException( - "One or more GraphKit.Auth cancellation callbacks or provider sources failed while the host was shutting down.", - failures); + 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)); + } } } @@ -245,11 +269,17 @@ internal GraphAuthOperationLease EnterOperation(CancellationToken callerCancella } } - internal void Unregister(GraphTokenSourceProxy source) + internal void CompleteSourceDisposal( + GraphTokenSourceProxy source, + GraphAuthException? failure) { lock (_gate) { _sources.Remove(source); + if (failure is not null) + { + _sourceDisposalFailures.Add(failure); + } } } @@ -541,28 +571,72 @@ private void TryFinalizeUnload() return; } - try + GraphAuthException? failure = null; + GraphAuthLoadContext? loadContext; + lock (_gate) { - GraphAuthLoadContext? loadContext; - lock (_gate) - { - _sources.Clear(); - _factory = null; - _factoryType = null; - _providerAssembly = null; - loadContext = _loadContext; - _loadContext = null; - } + _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 { _shutdownCompleted.Set(); + _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; diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs index 8ed22a8..227f979 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -2,9 +2,12 @@ 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; @@ -47,26 +50,33 @@ public void AdoptSharedResult(GraphTokenResult result, bool forceRefresh) public void Dispose() { - if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) + Task completion = StartDisposal(); + if (completion.IsCompletedSuccessfully && + completion.Result is GraphAuthException failure) { - return; + throw failure; } + } - GraphAuthHost? owner = Interlocked.Exchange(ref _owner, null); - IGraphTokenSource? inner = Interlocked.Exchange(ref _inner, null); - Volatile.Write(ref _retiredInner, inner); - try + internal Task DisposeForHostAsync() => StartDisposal(); + + private Task StartDisposal() + { + if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) { - DisposeRetiredInnerWhenIdle(); + return _disposalCompletion.Task; } - finally + + GraphAuthHost? owner = Interlocked.Exchange(ref _owner, null); + if (owner is not null) { - if (owner is not null && - Interlocked.CompareExchange(ref _hostNotificationState, 1, 0) == 0) - { - owner.Unregister(this); - } + 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) @@ -129,7 +139,49 @@ private void DisposeRetiredInnerWhenIdle() return; } - Interlocked.Exchange(ref _retiredInner, null)?.Dispose(); + 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 diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index b24284f..3410b0f 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -303,6 +303,14 @@ internal sealed class FixtureTokenSource : IGraphTokenSource { File.AppendAllText(_disposeMarker, "disposed" + Environment.NewLine); } + + if (string.Equals( + Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_FAILURE"), + "1", + StringComparison.Ordinal)) + { + throw new ProviderOwnedDisposeException(); + } } internal static bool WaitForBlockedAcquire(TimeSpan timeout) => @@ -310,6 +318,30 @@ internal sealed class FixtureTokenSource : IGraphTokenSource internal static void ReleaseBlockedAcquire() => BlockedAcquireRelease.Set(); } + +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 @@ -406,9 +438,14 @@ public sealed class Counterfeit $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.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -502,6 +539,8 @@ public static class GraphKitAuthRuntimeHarness ?? 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."); @@ -532,9 +571,15 @@ public static class GraphKitAuthRuntimeHarness 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."); @@ -615,6 +660,8 @@ public static class GraphKitAuthRuntimeHarness OwnerCompletedBeforeCallbackRelease = ownerCompletedBeforeCallbackRelease, NonOwnerCompletedBeforeCallbackRelease = nonOwnerCompletedBeforeCallbackRelease, OwnerElapsedMilliseconds = ownerElapsedMilliseconds, + CompletionPlaceholderPublishedBeforeCallback = completionPlaceholderPublishedBeforeCallback, + ReentrantDisposeReturned = reentrantDisposeReturned, StateWhileCallbackBlocked = stateWhileCallbackBlocked, DisposeCountWhileCallbackBlocked = disposeCountWhileCallbackBlocked, ProxyInnerPresentWhileCallbackBlocked = proxyInnerPresentWhileCallbackBlocked, @@ -627,6 +674,258 @@ public static class GraphKitAuthRuntimeHarness 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 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 @" @@ -697,6 +996,7 @@ public static class GraphKitAuthRuntimeHarness enable enable true + CS8625 true none @@ -940,7 +1240,7 @@ foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { param( [Parameter(Mandatory)] [string] $ContractsPath, [string] $PayloadRoot, - [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'BlockedCancellationCallback', 'SamePathReplacement')] [string] $Scenario, + [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'BlockedCancellationCallback', 'ImmediateDisposalFailure', 'DeferredDisposalFailure', 'SamePathReplacement')] [string] $Scenario, [string] $DisposeMarker, [string] $ReplacementContractsPath, [string] $PreloadPath, @@ -1123,6 +1423,33 @@ switch ($Scenario) { $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') [IO.File]::Copy( @@ -1224,31 +1551,44 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { @($script:contractsInspection.Data.Leaks) | Should -BeNullOrEmpty -Because 'no MSAL type may cross the GraphKit-owned ABI boundary' } - It 'detects an enum underlying-type mutation in the ABI snapshot metadata' { + 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 - $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $mutatedPath + $rejection = try { + Assert-GraphKitAuthAbiV1Surface -ContractsPath $mutatedPath + $null + } + catch { + $_.Exception.Message + } - $result.ExitCode | Should -Be 0 -Because $result.Output - @($result.Data) | Should -Contain ` - 'TYPE-META|GraphKit.Auth.GraphAuthMode|staticType=false|enumUnderlying=System.Byte|genericArity=0' ` - -Because 'the ABI gate must distinguish the frozen Int32 enum from an otherwise identical byte enum' + $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 'detects a nullable-reference mutation in constructor parameter metadata' { + It 'rejects a nullable-reference mutation through the literal ABI-v1 gate' { $mutatedPath = New-GraphKitAuthAbiMutationAssembly ` -Root (Join-Path $TestDrive 'abi-correlation-nonnullable') -Mutation CorrelationIdNonNullable - $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $mutatedPath + $rejection = try { + Assert-GraphKitAuthAbiV1Surface -ContractsPath $mutatedPath + $null + } + catch { + $_.Exception.Message + } - $result.ExitCode | Should -Be 0 -Because $result.Output - @($result.Data) | Should -Contain ` - '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=NotNull/NotNull' ` - -Because 'the ABI gate must distinguish a non-null correlationId parameter from the frozen nullable parameter' + $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' } - It 'matches the literal ABI-v1 public surface without extra exported types or members' { + 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)' @@ -1413,11 +1753,24 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { 'TYPE-META|GraphKit.Auth.ManagedIdentityCredential|staticType=false|enumUnderlying=|genericArity=0' ) | Sort-Object - $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $script:contractsPath + $result = Invoke-GraphKitAuthAbiSurfaceProbe -ContractsPath $ContractsPath $differences = @(Compare-Object -ReferenceObject $expectedSurface -DifferenceObject @($result.Data) -SyncWindow 10000) - $result.ExitCode | Should -Be 0 -Because $result.Output - $differences | Should -BeNullOrEmpty -Because "ABI-v1 is literal, not inferred from the candidate:`n$($differences | Format-Table | Out-String)" + 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 } } @@ -1486,6 +1839,9 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $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 @@ -1499,6 +1855,63 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $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') From cb0acd9ee22e0202a127ec48d941ffd4773e5417 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 04:27:51 -0400 Subject: [PATCH 21/79] feat: add the isolated GraphKit Auth provider --- .../GraphKit.Auth.Tests.csproj | 21 + .../GraphTokenSourceTests.cs | 627 ++++++++++++++++++ .../GraphKit.Auth.Tests/OwnershipTests.cs | 425 ++++++++++++ .../GraphKit.Auth.Tests/packages.lock.json | 157 +++++ src/GraphKit.Auth/GraphKit.Auth.sln | 30 + .../GraphKit.Auth/GraphKit.Auth.csproj | 21 + .../GraphKit.Auth/GraphTokenSource.cs | 540 +++++++++++++++ .../GraphKit.Auth/GraphTokenSourceFactory.cs | 114 ++++ .../GraphKit.Auth/MsalTokenClient.cs | 363 ++++++++++ .../GraphKit.Auth/packages.lock.json | 44 ++ 10 files changed, 2342 insertions(+) create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Tests/packages.lock.json create mode 100644 src/GraphKit.Auth/GraphKit.Auth.sln create mode 100644 src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj create mode 100644 src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs create mode 100644 src/GraphKit.Auth/GraphKit.Auth/packages.lock.json 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..88c1e94 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj @@ -0,0 +1,21 @@ + + + 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/GraphTokenSourceTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs new file mode 100644 index 0000000..62d1d72 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs @@ -0,0 +1,627 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using Xunit; + +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)] + [InlineData(3600, 300)] + [InlineData(7200, 300)] + public void AdaptiveRefreshUsesTheBoundedLifetimeSkew( + int lifetimeSeconds, + int expectedBaseSkewSeconds) + { + 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); + double spread = EarlySpreadSeconds(first.TokenFingerprint, expectedBaseSkewSeconds); + clock.UtcNow = first.ExpiresOnUtc + .AddSeconds(-(expectedBaseSkewSeconds + spread)) + .AddMilliseconds(-1); + Assert.Equal("adaptive", source.Acquire(false, CancellationToken.None).AccessToken); + + clock.UtcNow = first.ExpiresOnUtc + .AddSeconds(-(expectedBaseSkewSeconds + spread)) + .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); + double spread = EarlySpreadSeconds(first.TokenFingerprint, 60); + Assert.InRange(spread, 0, 6); + clock.UtcNow = first.ExpiresOnUtc.AddSeconds(-(60 + spread)); + + 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 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(); + TextWriter originalOutput = Console.Out; + TextWriter originalError = Console.Error; + try + { + Console.SetOut(consoleOutput); + Console.SetError(consoleError); + source.Acquire(false, CancellationToken.None); + _ = new ClientSecretCredential(SecureStringFixture.Create(secretValue), false); + } + finally + { + Console.SetOut(originalOutput); + Console.SetError(originalError); + } + + string emitted = consoleOutput + consoleError.ToString(); + 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(); + } + + private static double EarlySpreadSeconds(string fingerprint, double baseSkewSeconds) + { + int bucket = Convert.ToInt32(fingerprint[..2], 16); + return baseSkewSeconds * 0.1 * (bucket / 255d); + } + + 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..b4bcd02 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs @@ -0,0 +1,425 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Identity.Client; +using Xunit; + +namespace GraphKit.Auth.Tests; + +public sealed class OwnershipTests +{ + 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); + Assert.NotNull(factory.GetConstructor(Type.EmptyTypes)); + Assert.Contains(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(); + } + } + } + + [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)); + + using IGraphTokenSource source = factory.Create(request); + + Assert.Equal(0, disposalCount); + Assert.True(certificate.HasPrivateKey); + Assert.True(secret.Length > 0); + } + + [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 async Task DisposalWaitsForActiveAcquisitionBeforeDisposingOwnedMaterial() + { + var clock = new GraphTokenSourceTests.FakeClock(InitialNow); + using X509Certificate2 certificate = CertificateFixture.Create(); + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + var order = new List(); + var client = new GraphTokenSourceTests.FakeTokenClient((_, _) => + { + entered.Set(); + release.Wait(); + lock (order) + { + order.Add("acquire-complete"); + } + + return GraphTokenSourceTests.Result("token", InitialNow, InitialNow.AddHours(1)); + }); + var source = new GraphTokenSource( + CertificateRequest(certificate, ownsMaterial: true), + client, + clock.GetUtcNow, + material => + { + lock (order) + { + order.Add("material-disposed"); + } + + material.Dispose(); + }); + Task acquire = Task.Run(() => + source.Acquire(false, CancellationToken.None)); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + + Task dispose = Task.Run(source.Dispose); + Assert.NotSame(dispose, await Task.WhenAny(dispose, Task.Delay(100))); + release.Set(); + await Task.WhenAll(acquire, dispose); + + Assert.Equal(new[] { "acquire-complete", "material-disposed" }, order); + } + + [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 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 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..ab29e1e --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs @@ -0,0 +1,540 @@ +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 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. + } + + _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(); + _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")); + 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) + { + ThrowIfDisposed(); + int active = Interlocked.Increment(ref _activeOperations); + if (active == 1) + { + _operationsDrained.Reset(); + } + + try + { + ThrowIfDisposed(); + CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + callerCancellation, + _disposalCancellation.Token); + return new OperationLease(this, linked); + } + catch + { + ExitOperation(); + throw; + } + } + + private void ExitOperation() + { + if (Interlocked.Decrement(ref _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..4c6f5af --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs @@ -0,0 +1,114 @@ +namespace GraphKit.Auth; + +public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory +{ + 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 = GetTransferredMaterial(request.Credential); + 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) + { + CleanupFailedTransfer(client, transferredMaterial); + throw; + } + catch (GraphAuthException) + { + CleanupFailedTransfer(client, transferredMaterial); + throw; + } + catch (Exception exception) + { + CleanupFailedTransfer(client, transferredMaterial); + throw ProviderFailureSanitizer.Create(exception, "provider_construction_failed", "Provider"); + } + } + + private void 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; + } + } + + if (cleanupFailed) + { + throw new GraphAuthException( + "provider_construction_cleanup_failed", + "ProviderLifecycle", + "The isolated authentication provider could not clean up a failed source construction.", + retryAfter: null, + correlationId: null); + } + } + + 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..f18273e --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs @@ -0,0 +1,363 @@ +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"); + } + } + + 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(), + _scope, + _credentialGeneration); + } + catch (OperationCanceledException) + { + throw; + } + catch (GraphAuthException) + { + throw; + } + catch (Exception exception) + { + throw ProviderFailureSanitizer.Create(exception, "provider_failure", "Provider"); + } + } + + 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accessToken); + 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 = [scope], + VerifiedTenantId = null, + TokenFingerprint = fingerprint, + CredentialGeneration = credentialGeneration + }; + } + finally + { + CryptographicOperations.ZeroMemory(bearerBytes); + } + } +} + +internal static class ProviderFailureSanitizer +{ + internal static GraphAuthException Create( + Exception exception, + string defaultCode, + string defaultCategory) + { + 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); + } + } + + return new GraphAuthException( + code, + category, + "The isolated authentication provider could not complete token acquisition.", + retryAfter, + correlationId); + } + + private static TimeSpan? GetRetryAfter(MsalServiceException exception) + { + 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 - DateTimeOffset.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 From 1e01420e686ea8d1ed468c291f8cc17d1eeab169 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 15:37:25 -0400 Subject: [PATCH 22/79] fix: harden GraphKit Auth provider boundaries --- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 36 +- .../GraphTokenSourceProxy.cs | 113 +++++- .../GraphTokenSourceTests.cs | 33 +- .../GraphKit.Auth.Tests/OwnershipTests.cs | 263 +++++++++++- .../GraphKit.Auth/GraphTokenSource.cs | 6 +- .../GraphKit.Auth/GraphTokenSourceFactory.cs | 39 +- .../GraphKit.Auth/MsalTokenClient.cs | 22 +- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 377 ++++++++++++++++-- 8 files changed, 833 insertions(+), 56 deletions(-) diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index 3dd83f0..5d2ba02 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -70,7 +70,20 @@ public GraphAuthHost( { Assembly providerAssembly = loadContext.LoadProviderAssembly(); Type factoryType = ValidateProvider(providerAssembly, loadContext, contractsAssembly); - object? factoryObject = Activator.CreateInstance(factoryType); + 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( @@ -100,8 +113,25 @@ public IGraphTokenSource CreateSource(GraphTokenRequest request) ThrowIfStopping(); IGraphTokenSourceFactory factory = _factory ?? throw new ObjectDisposedException(nameof(GraphAuthHost)); - IGraphTokenSource source = factory.Create(request) ?? - throw new InvalidOperationException("The GraphKit.Auth provider factory returned a null token source."); + IGraphTokenSource? source; + try + { + 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 { diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs index 227f979..84e7a0f 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -39,13 +39,35 @@ public GraphTokenResult Acquire( CancellationToken cancellation) { using ProxyOperation operation = BeginOperation(cancellation); - return operation.Inner.Acquire(forceRefresh, operation.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); - operation.Inner.AdoptSharedResult(result, forceRefresh); + try + { + operation.Inner.AdoptSharedResult(result, forceRefresh); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + operation.Cancellation, + "provider_failure", + "Provider"); + } } public void Dispose() @@ -82,7 +104,18 @@ public void Dispose() private TResult Read(Func reader) { using ProxyOperation operation = BeginOperation(CancellationToken.None); - return reader(operation.Inner); + try + { + return reader(operation.Inner); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + operation.Cancellation, + "provider_failure", + "Provider"); + } } private ProxyOperation BeginOperation(CancellationToken callerCancellation) @@ -227,3 +260,77 @@ public void Dispose() } } } + +internal static class ProviderBoundaryFailure +{ + 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 new OperationCanceledException( + CancellationMessage, + innerException: null, + effectiveCancellation); + } + + if (providerFailure is GraphAuthException graphFailure) + { + return 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 new GraphAuthException( + unexpectedCode, + unexpectedCategory, + SafeMessage, + retryAfter: null, + correlationId: null); + } + + 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.Tests/GraphTokenSourceTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs index 62d1d72..903d541 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs @@ -1,8 +1,12 @@ 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 @@ -471,6 +475,28 @@ public void SourceRejectsEveryUseAfterDisposalAndClearsReferences() 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() { @@ -483,22 +509,27 @@ public void ProviderWritesNoTokenOrSecretToConsoleOrTrace() 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 + consoleError.ToString(); + string emitted = consoleOutput.ToString() + consoleError + traceOutput; Assert.DoesNotContain(secretValue, emitted, StringComparison.Ordinal); Assert.DoesNotContain(tokenValue, emitted, StringComparison.Ordinal); Assert.Equal(string.Empty, emitted); diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs index b4bcd02..5d03eea 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs @@ -1,4 +1,6 @@ +using System.Net.Http.Headers; using System.Reflection; +using System.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Identity.Client; @@ -73,8 +75,23 @@ public void PublicProviderSurfaceContainsOnlyTheParameterlessFactory() Type factory = Assert.Single(exported); Assert.Equal("GraphKit.Auth.GraphTokenSourceFactory", factory.FullName); - Assert.NotNull(factory.GetConstructor(Type.EmptyTypes)); - Assert.Contains(typeof(IGraphTokenSourceFactory), factory.GetInterfaces()); + 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); } @@ -117,6 +134,19 @@ public void FactoryCreatesOneRealMsalApplicationPerRefreshableSource() 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] @@ -209,11 +239,142 @@ public void CallerOwnedCredentialMaterialIsNeverDisposed(GraphAuthMode mode) clock.GetUtcNow, _ => Interlocked.Increment(ref disposalCount)); - using IGraphTokenSource source = factory.Create(request); + 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)); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + Task second = Task.Run(() => CaptureCreate(secondFactory, duplicateRequest)); + bool duplicateRejectedBeforeWinnerCompleted = ReferenceEquals( + await Task.WhenAny(second, Task.Delay(TimeSpan.FromMilliseconds(500))), + second); + release.Set(); + CreateOutcome[] outcomes = await Task.WhenAll(first, second); + foreach (IGraphTokenSource source in outcomes + .Where(outcome => outcome.Source is not null) + .Select(outcome => outcome.Source!)) + { + source.Dispose(); + } + + 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] @@ -327,6 +488,46 @@ public void MsalFailureIsConvertedToSanitizedGraphAuthException() 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() { @@ -394,6 +595,62 @@ private static GraphTokenRequest ManagedIdentityRequest(string? userAssignedClie "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 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 { } diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs index ab29e1e..3131428 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs @@ -331,7 +331,11 @@ private void ExecuteFlight( catch (Exception exception) { flight.Completion.TrySetException( - ProviderFailureSanitizer.Create(exception, "provider_failure", "Provider")); + ProviderFailureSanitizer.Create( + exception, + "provider_failure", + "Provider", + _utcNow)); return; } diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs index 4c6f5af..4bb8131 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs @@ -1,7 +1,11 @@ +using System.Runtime.CompilerServices; + namespace GraphKit.Auth; public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory { + private static readonly ConditionalWeakTable + ConsumedOwnedMaterials = new(); private readonly Func, ITokenClient> _clientFactory; private readonly Func _utcNow; private readonly Action _disposeMaterial; @@ -28,7 +32,29 @@ public IGraphTokenSource Create(GraphTokenRequest request) { ArgumentNullException.ThrowIfNull(request); - IDisposable? transferredMaterial = GetTransferredMaterial(request.Credential); + IDisposable? transferredMaterial = null; + IDisposable? requestedTransfer = GetTransferredMaterial(request.Credential); + if (requestedTransfer is not null) + { + try + { + ConsumedOwnedMaterials.Add( + requestedTransfer, + ConsumedMaterialMarker.Instance); + } + 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 { @@ -61,7 +87,11 @@ public IGraphTokenSource Create(GraphTokenRequest request) catch (Exception exception) { CleanupFailedTransfer(client, transferredMaterial); - throw ProviderFailureSanitizer.Create(exception, "provider_construction_failed", "Provider"); + throw ProviderFailureSanitizer.Create( + exception, + "provider_construction_failed", + "Provider", + _utcNow); } } @@ -111,4 +141,9 @@ private void CleanupFailedTransfer( _ => null }; } + + private sealed class ConsumedMaterialMarker + { + internal static ConsumedMaterialMarker Instance { get; } = new(); + } } diff --git a/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs b/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs index f18273e..5d0885b 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs @@ -96,7 +96,8 @@ internal static MsalTokenClient Create( throw ProviderFailureSanitizer.Create( exception, "provider_construction_failed", - "Provider"); + "Provider", + utcNow); } } @@ -153,7 +154,11 @@ public GraphTokenResult Acquire( } catch (Exception exception) { - throw ProviderFailureSanitizer.Create(exception, "provider_failure", "Provider"); + throw ProviderFailureSanitizer.Create( + exception, + "provider_failure", + "Provider", + _utcNow); } } @@ -298,7 +303,8 @@ internal static class ProviderFailureSanitizer internal static GraphAuthException Create( Exception exception, string defaultCode, - string defaultCategory) + string defaultCategory, + Func? utcNow = null) { ArgumentNullException.ThrowIfNull(exception); if (exception is GraphAuthException graphAuthException) @@ -328,7 +334,9 @@ internal static GraphAuthException Create( if (msalException is MsalServiceException serviceException) { - retryAfter = GetRetryAfter(serviceException); + retryAfter = GetRetryAfter( + serviceException, + utcNow ?? (static () => DateTimeOffset.UtcNow)); } } @@ -340,7 +348,9 @@ internal static GraphAuthException Create( correlationId); } - private static TimeSpan? GetRetryAfter(MsalServiceException exception) + private static TimeSpan? GetRetryAfter( + MsalServiceException exception, + Func utcNow) { if (exception.Headers?.RetryAfter is null) { @@ -350,7 +360,7 @@ internal static GraphAuthException Create( TimeSpan? retryAfter = exception.Headers.RetryAfter.Delta; if (retryAfter is null && exception.Headers.RetryAfter.Date is DateTimeOffset date) { - retryAfter = date - DateTimeOffset.UtcNow; + retryAfter = date - utcNow(); } if (retryAfter < TimeSpan.Zero) diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index 3410b0f..cd8aa9a 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -228,8 +228,30 @@ 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) => new FixtureTokenSource(request); + public IGraphTokenSource Create(GraphTokenRequest request) + { + if (string.Equals( + Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_SOURCE_CONSTRUCTION_FAILURE"), + "1", + StringComparison.Ordinal)) + { + throw ProviderFailure.Create("source-construction"); + } + + return new FixtureTokenSource(request); + } // TEST_PUBLIC_SURFACE } @@ -244,8 +266,14 @@ internal sealed class FixtureTokenSource : IGraphTokenSource public FixtureTokenSource(GraphTokenRequest request) => _request = request; public bool CanRefresh => true; - public string AuthMode => _request.AuthMode.ToString(); - public string Audience => _request.Resource.AbsoluteUri; + 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; } @@ -269,7 +297,7 @@ internal sealed class FixtureTokenSource : IGraphTokenSource if (forceRefresh) { - throw new GraphAuthException("fixture", "Fixture", "provider failure", TimeSpan.FromSeconds(7), "fixture-correlation"); + throw ProviderFailure.Create("acquire"); } ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(5); @@ -288,6 +316,11 @@ internal sealed class FixtureTokenSource : IGraphTokenSource 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; } @@ -317,6 +350,58 @@ internal sealed class FixtureTokenSource : IGraphTokenSource 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 @@ -453,6 +538,143 @@ using GraphKit.Auth; public static class GraphKitAuthRuntimeHarness { + 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, @@ -818,6 +1040,21 @@ public static class GraphKitAuthRuntimeHarness }; } + [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) { @@ -1240,7 +1477,7 @@ foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { param( [Parameter(Mandatory)] [string] $ContractsPath, [string] $PayloadRoot, - [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'BlockedCancellationCallback', 'ImmediateDisposalFailure', 'DeferredDisposalFailure', 'SamePathReplacement')] [string] $Scenario, + [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'FactoryConstructionFailure', 'SourceConstructionFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'BlockedCancellationCallback', 'ImmediateDisposalFailure', 'DeferredDisposalFailure', 'SamePathReplacement')] [string] $Scenario, [string] $DisposeMarker, [string] $ReplacementContractsPath, [string] $PreloadPath, @@ -1297,6 +1534,17 @@ function Get-Rejection { } } +function Get-RejectionType { + param([scriptblock] $Action) + try { + $null = & $Action + return $null + } + catch { + return $_.Exception.GetBaseException().GetType().FullName + } +} + switch ($Scenario) { 'Validation' { $emptySecret = [Security.SecureString]::new() @@ -1332,10 +1580,13 @@ switch ($Scenario) { $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 @@ -1347,8 +1598,11 @@ switch ($Scenario) { [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 @@ -1356,25 +1610,21 @@ switch ($Scenario) { 'ProviderFailure' { $authHost = [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(2)) $source = $authHost.CreateSource((New-ValidRequest)) - try { - $null = $source.Acquire($true, [Threading.CancellationToken]::None) - throw 'The provider fixture did not fail.' - } - catch [GraphKit.Auth.GraphAuthException] { - [pscustomobject]@{ - Type = $_.Exception.GetType().FullName - Code = $_.Exception.Code - Category = $_.Exception.Category - Message = $_.Exception.Message - RetryAfterSeconds = $_.Exception.RetryAfter.TotalSeconds - CorrelationId = $_.Exception.CorrelationId - InnerIsNull = $null -eq $_.Exception.InnerException - } | ConvertTo-Json -Compress - } - finally { - $source.Dispose() - $authHost.Dispose() - } + [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)) } @@ -1798,8 +2048,11 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $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 } @@ -1984,22 +2237,72 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { } } - It 'preserves GraphAuthException failures without catching and relabeling them' { + 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 - 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 ProviderFailure + $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 - $result.ExitCode | Should -Be 0 -Because $result.Output - $result.Data.Type | Should -BeExactly 'GraphKit.Auth.GraphAuthException' - $result.Data.Code | Should -BeExactly 'fixture' - $result.Data.Category | Should -BeExactly 'Fixture' - $result.Data.Message | Should -BeExactly 'provider failure' - $result.Data.RetryAfterSeconds | Should -Be 7 - $result.Data.CorrelationId | Should -BeExactly 'fixture-correlation' - $result.Data.InnerIsNull | Should -BeTrue + $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' { From a8b74d8df692e70bb89d1645796c6cecae31aafc Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 16:25:15 -0400 Subject: [PATCH 23/79] fix: keep provider ownership markers collectible --- .../GraphKit.Auth/GraphTokenSourceFactory.cs | 10 +- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 215 ++++++++++++++++++ 2 files changed, 218 insertions(+), 7 deletions(-) diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs index 4bb8131..1243f7d 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs @@ -4,8 +4,9 @@ namespace GraphKit.Auth; public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory { - private static readonly ConditionalWeakTable + 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; @@ -40,7 +41,7 @@ public IGraphTokenSource Create(GraphTokenRequest request) { ConsumedOwnedMaterials.Add( requestedTransfer, - ConsumedMaterialMarker.Instance); + ConsumedMaterialMarker); } catch (ArgumentException) { @@ -141,9 +142,4 @@ private void CleanupFailedTransfer( _ => null }; } - - private sealed class ConsumedMaterialMarker - { - internal static ConsumedMaterialMarker Instance { get; } = new(); - } } diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index cd8aa9a..a73a2f0 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -1739,6 +1739,190 @@ switch ($Scenario) { } } + 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 } @@ -2057,6 +2241,37 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $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' { $payloadRoot = Join-Path $TestDrive 'concurrent-dispose-provider' $providerPath = New-GraphKitAuthProviderFixtureAssembly -Root $payloadRoot From a204d85950a7d65eaefcdb0ccdce24d62aac9cf3 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 19:13:13 -0400 Subject: [PATCH 24/79] build: package the isolated GraphKit Auth runtime --- .build/GraphKitAuth.tasks.ps1 | 1471 +++++++++++++ .github/workflows/ci.yml | 35 +- build.yaml | 10 +- .../plans/2026-08-30-r8-graphkit-auth.md | 227 +- scripts/Test-GraphKitReleaseProof.ps1 | 80 +- scripts/private/GraphKit.AuthStageCapture.cs | 979 +++++++++ source/GraphKit.psd1 | 2 +- tests/QA/BuiltModule.tests.ps1 | 9 + tests/QA/GraphKitAuthPackage.tests.ps1 | 1835 ++++++++++++++++- tests/QA/ReleaseProof.tests.ps1 | 121 +- 10 files changed, 4637 insertions(+), 132 deletions(-) create mode 100644 .build/GraphKitAuth.tasks.ps1 create mode 100644 scripts/private/GraphKit.AuthStageCapture.cs diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 new file mode 100644 index 0000000..143f797 --- /dev/null +++ b/.build/GraphKitAuth.tasks.ps1 @@ -0,0 +1,1471 @@ +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 + +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) + $matches = @($compiled | Where-Object FullName -CEQ $expectedTypeName) + $loaded = @([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ }) + if ($matches.Count -ne 1 -or $loaded.Count -ne 1 -or -not [object]::ReferenceEquals($matches[0], $loaded[0])) { + throw 'The proof-bound GraphKit.Auth capture helper collided during compilation.' + } + $script:GraphKitAuthStageCaptureType = $matches[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 + ) + 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) { + 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) + $authEntryBefore = Get-GraphKitAuthPortableChildEntry -ParentPath $output ` + -ChildName 'GraphKit.Auth' -Kind 'build auth root' + $authCreated = -not $authEntryBefore.Exists + $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $output ` + -ParentEvidence $outputEvidence -ChildName 'GraphKit.Auth' ` + -Kind 'build auth root' -AfterChildInspection $AfterChildInspection + $authRoot = Join-Path $output 'GraphKit.Auth' + try { + $null = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'capture' ` + -Kind 'build capture root' -AfterChildInspection $AfterChildInspection + return $authEvidence + } + catch { + $primary = $_ + if ($authCreated) { + try { + Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $output ` + -ParentEvidence $outputEvidence -ChildName 'GraphKit.Auth' ` + -ChildEvidence $authEvidence -Kind 'incomplete build authority root cleanup' + } + catch { + throw "GraphKit.Auth build authority initialization failed and ambiguous cleanup was refused: $($_.Exception.Message) Original failure: $($primary.Exception.Message)" + } + } + throw $primary + } +} + +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 [string]$Evidence.OwnerSid -ceq $currentSid -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 +} + +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' } 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) + $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authParent ` + -ParentEvidence $authParentEvidence -ChildName ([IO.Path]::GetFileName($authRoot)) ` + -Kind 'auth root' -AfterChildInspection $AfterOwnedDirectoryCreate + $captureRoot = Join-Path $authRoot 'capture' + $stageRoot = Join-Path $authRoot 'stage' + $versionRoot = Join-Path $stageRoot $FullVersion + $captureRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'capture' -Kind 'capture root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate + $stageRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'stage' -Kind 'stage root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate + $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' } 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) { + throw 'The GraphKit.Auth Prepare capture root is missing from a partial output tree.' + } + $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) { return @() } + $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 Invoke-GraphKitAuthLiteralQuarantine { + param([Parameter(Mandatory)][string] $RepositoryRoot) + $quarantine = Join-Path ([IO.Path]::GetTempPath()) ('graphkit-auth-task5-' + [guid]::NewGuid().ToString('N')) + $null = [IO.Directory]::CreateDirectory($quarantine) + $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 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 + $actual = $script:GraphKitAuthStageCaptureType::InspectFile($RepositoryRoot, $RelativePath) + if (-not $actual.IsRegularFile -or $actual.IsReparsePoint -or [long]$actual.LinkCount -ne 1 -or + -not (Test-GraphKitAuthContainedPhysicalPath $RepositoryRoot $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() + Completed = $false + ExpectedEvidence = [ordered]@{} + } + try { + foreach ($entry in $destinations.GetEnumerator()) { + $relativeFile = [string]$entry.Value + $destinationFile = Join-Path $RepositoryRoot $relativeFile + $destination = Split-Path $destinationFile -Parent + $null = [IO.Directory]::CreateDirectory($destination) + $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $verified.PayloadPath, $entry.Key, $destination, $entry.Key + ) + $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." + } + $script:GraphKitAuthAbiFixtureState.CreatedPaths.Add($destinationFile) + $script:GraphKitAuthAbiFixtureState.ExpectedEvidence[$destinationFile] = $copy.Destination + & 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.' } + $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 $_ } + $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 = @($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 + } | Sort-Object Length -Descending) + foreach ($directory in $createdParents) { + 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() + $authOutput = Join-Path $BuildRoot 'output/GraphKit.Auth' + $publishRoot = Join-Path $authOutput "publish/$runId" + $providerPublish = Join-Path $publishRoot 'provider' + $payloadSource = Join-Path $publishRoot 'payload' + $resultRoot = Join-Path $authOutput "dotnet-test/$runId" + $quarantine = $null + try { + $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot (Join-Path $BuildRoot 'output') + if ((& dotnet --version) -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 + $outcomes = @($trx.TestRun.Results.UnitTestResult | ForEach-Object { [string]$_.outcome }) + $counters = $trx.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 -lt 48 -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: total=$($outcomes.Count), passed=$(@($outcomes | Where-Object { $_ -ceq 'Passed' }).Count)." + } + $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 + } + finally { + if ($null -eq $quarantine) { + $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot + $script:GraphKitAuthQuarantine = $quarantine + Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'." + } + } + } + + 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc4fd1f..dccfcb0 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,6 +22,28 @@ 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 + + - 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. @@ -63,11 +86,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 +96,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 diff --git a/build.yaml b/build.yaml index 8e2bbf2..d1dc8d7 100644 --- a/build.yaml +++ b/build.yaml @@ -50,8 +50,11 @@ BuildWorkflow: - test build: + - Prepare_GraphKitAuth_Clean - Clean + - Build_GraphKitAuth - Build_Module_ModuleBuilder + - Copy_GraphKitAuth_Into_BuiltModule - Build_NestedModules_ModuleBuilder - Create_changelog_release_output @@ -69,7 +72,10 @@ BuildWorkflow: #- Set_PSModulePath # Invalidate stale proof and capture the exact package/module candidate before Pester. - Capture_Tested_Release_Proof_Candidate - - Pester_Tests_Stop_On_Fail + # 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 @@ -183,5 +189,3 @@ GitConfig: # FilesToAdd: # - 'CHANGELOG.md' # UpdateChangelogOnPrerelease: false # Set to true to update changelog on pre-releases too - - diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md index e23892d..fb5a2b1 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -333,81 +333,214 @@ 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: `source/GraphKit.psd1` - 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. -- [ ] **Step 1: Require digest-bound immutable staging before package copy or import** +#### Controller rulings -Treat the archive/package digest as an input assertion only; an archive digest by itself is -insufficient to bind the bytes that the module later copies or imports. After locked publish, -derive an exact manifest of the permitted runtime closure and SHA-256 digest of every staged file. -Create a new permission-restricted immutable-per-version staging directory with create-new -semantics: it must never reuse, merge with, or overwrite an existing version directory. Copy only -the manifest-bound bytes into that directory, reject symbolic links, hard links, junctions, -reparse-point aliases, path escapes, and any directory or file that retains a writable mutation -route, then seal the complete directory and files against mutation. +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. -Immediately before `Copy_GraphKitAuth_Into_BuiltModule` and again before the package import proof, -re-open the sealed staging root, verify its exact file closure and every digest against the bound -manifest, verify that the staging root is still permission-restricted and immutable, and fail closed -if any entry is missing, extra, linked/aliased, writable, replaced, or changed. Tests must prove that -an existing version directory cannot be reused or overwritten, a post-digest mutation is rejected, -links/reparse aliases and writable routes are rejected, and only the freshly created sealed staged -bytes can reach copy/import. +Keep mutable compiler output and authorized stage bytes separate: -- [ ] **Step 2: Add the locked build and allowlisted copy tasks** +```text +output/GraphKit.Auth/publish// +output/GraphKit.Auth/capture//{manifest.json,payload/} +output/GraphKit.Auth/stage///{manifest.json,payload/} +``` -`Build_GraphKitAuth` runs locked restore, .NET tests, and Release publish into -`output/GraphKit.Auth/stage`. `Copy_GraphKitAuth_Into_BuiltModule` accepts only: +`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 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. + +- [ ] **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. + +- [ ] **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: -```powershell -$allowed = @( - 'GraphKit.Auth.Contracts.dll', - 'GraphKit.Auth.dll', - 'GraphKit.Auth.deps.json', - 'Microsoft.Identity.Client.dll', - 'Microsoft.IdentityModel.Abstractions.dll' -) +```text +GraphKit.Auth.dll +GraphKit.Auth.deps.json +Microsoft.Identity.Client.dll +Microsoft.IdentityModel.Abstractions.dll ``` -If MSAL 4.82.1's locked runtime closure adds another managed dependency, add that exact filename to -the allowlist and package test in the same commit; never use `Copy-Item *`. +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 live only below a unique +`output/GraphKit.Auth/dotnet-test//`. After capture and before the build task returns, move +only these literal generated roots intact into a recoverable task-specific temporary quarantine, +including on failure: -- [ ] **Step 3: Wire the workflow and built manifest** +```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 +``` -Insert the two build tasks in the order fixed by the R8 design. Set -`RequiredAssemblies = @('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')` only in the built -manifest after the allowlisted contracts DLL exists, then run `Test-ModuleManifest` against that -built path. Keep source `RequiredAssemblies` empty so source validation never points at a generated -file absent from `source/`. Add `actions/setup-dotnet@v4` with `10.0.400` before dependency restore -in each existing matrix row. +Do not resolve these through recursion, wildcard expansion, or discovery. Quarantine must finish +before module version or proof state is recaptured. -- [ ] **Step 4: Pack and run package tests** +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. -Run: +- [ ] **Step 3: Copy only a freshly reverified stage into the built module** -```powershell -./build.ps1 -ResolveDependency -Tasks noop -./build.ps1 -Tasks pack -Invoke-Pester ./tests/QA/GraphKitAuthPackage.tests.ps1,./tests/QA/BuiltModule.tests.ps1 -Output Detailed +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 ``` -Expected: contracts load in Default ALC; provider and exact MSAL load in the named non-default ALC; -every packaged runtime file is allowlisted; no PDB/ref/native file exists. +- [ ] **Step 4: Reverify before import and harden canonical proof** -- [ ] **Step 5: Commit** +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. + +- [ ] **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. + +- [ ] **Step 6: Commit and report** ```bash -git add .build/GraphKitAuth.tasks.ps1 build.yaml source/GraphKit.psd1 .github/workflows/ci.yml tests/QA/GraphKitAuthPackage.tests.ps1 tests/QA/BuiltModule.tests.ps1 +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:** diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index e6d144a..8dc095e 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -149,6 +149,7 @@ if ($proofMinimumTests -ne $minimumTests -or } $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 @@ -161,7 +162,9 @@ foreach ($fileRecord in $proofModuleFiles) { $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}$') { @@ -170,6 +173,11 @@ foreach ($fileRecord in $proofModuleFiles) { 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.' @@ -188,9 +196,13 @@ if (-not (Test-Path -LiteralPath $builtModuleDirectory -PathType Container)) { ) [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) { - if (-not $currentPathMap.TryAdd($relativePath, $relativePath)) { - throw "The built module contains case-colliding paths for '$relativePath'." + $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'." } } @@ -232,6 +244,53 @@ if ([string] $builtManifest.PrivateData.PSData.Prerelease -cne $expectedPrerelea 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." @@ -252,6 +311,9 @@ try { $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 '/') @@ -269,6 +331,20 @@ try { 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) { diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs new file mode 100644 index 0000000..91869f4 --- /dev/null +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -0,0 +1,979 @@ +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 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 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 ShareRead = 0x00000001; + private const uint ShareWrite = 0x00000002; + private const uint ShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileFlagOpenReparsePoint = 0x00200000; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileAttributeReparsePoint = 0x00000400; + + public static GraphKitAuthPathEvidence InspectFile(string rootPath, string relativePath) + => Inspect(rootPath, relativePath, expectDirectory: false); + + public static GraphKitAuthPathEvidence InspectDirectory(string rootPath, string relativePath) + => Inspect(rootPath, relativePath, expectDirectory: true); + + public static bool HasInitialOwnerOnlyAccess(GraphKitAuthPathEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + return OperatingSystem.IsWindows() + ? evidence.OwnerOnlyAccess && + !string.IsNullOrWhiteSpace(evidence.OwnerSid) && + string.Equals(evidence.OwnerSid, evidence.CurrentIdentitySid, StringComparison.Ordinal) + : evidence.UnixMode == 0x180; + } + + 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.CurrentIdentitySid, StringComparison.Ordinal) + : evidence.IsDirectory && evidence.UnixMode == 0x1C0; + } + + 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 owner = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + owner, + 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(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) + { + 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."); + } + + using FileStream destinationStream = OpenDestinationCreateNew(destinationPath); + SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; + 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."); + } + SetOwnerOnly(destinationPath, directory: false, writable: true); + byte[] buffer = new byte[131072]; + long offset = 0; + while (offset < sourceBefore.Length) + { + int read = RandomAccess.Read(sourceHandle, buffer, offset); + if (read == 0) + { + throw new EndOfStreamException($"Source '{sourceRelativePath}' ended during capture."); + } + 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 + }; + } + + public static GraphKitAuthWriteEvidence WriteFileCreateNew( + string destinationRoot, + string destinationRelativePath, + byte[] content, + bool requireInitialOwnerOnly = false) + { + ArgumentNullException.ThrowIfNull(content); + string destinationPath = ResolveRelative(destinationRoot, destinationRelativePath); + EnsureAncestors(destinationRoot, destinationRelativePath); + + using FileStream destinationStream = OpenDestinationCreateNew(destinationPath); + SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; + 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."); + } + SetOwnerOnly(destinationPath, directory: false, writable: true); + 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 + }; + } + + 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); + } + + 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(source, 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) + { + string fullPath = ResolveRelative(rootPath, relativePath); + EnsureAncestors(rootPath, relativePath); + using SafeFileHandle handle = OpenReadNoFollow(fullPath, expectDirectory); + return EvidenceFromHandle(handle, fullPath, relativePath, expectDirectory); + } + + private static GraphKitAuthPathEvidence EvidenceFromHandle( + SafeFileHandle handle, + string fullPath, + string relativePath, + bool expectDirectory) + { + 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) + { + 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, + PermissionEvidence = after.PermissionEvidence, + IsDirectory = after.IsDirectory, + IsRegularFile = after.IsRegularFile, + IsReparsePoint = after.IsReparsePoint, + OwnerWritable = after.OwnerWritable, + OwnerSid = after.OwnerSid, + CurrentIdentitySid = after.CurrentIdentitySid, + 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( + 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) + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.ReadWrite, + Share = FileShare.Read, + Options = FileOptions.WriteThrough + }; + if (!OperatingSystem.IsWindows()) + { + 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(path, directory); + return new NativeFacts(identity, physical, windowsLength, info.NumberOfLinks, 0, directory, + !directory && !reparse, reparse, permissions.OwnerWritable, permissions.Sddl, + permissions.OwnerSid, permissions.CurrentIdentitySid, permissions.AccessRulesProtected, + permissions.HasInheritedAccessRules, permissions.OwnerOnlyAccess, + permissions.ExactOwnerOnlyAccess, + permissions.ExactWritableOwnerOnlyDirectoryAccess, + permissions.FileReadOnly); + } + + byte[] stat = new byte[256]; + if (fstat(handle.DangerousGetHandle().ToInt32(), stat) != 0) + { + throw new IOException($"Could not fstat '{path}' (errno {Marshal.GetLastWin32Error()})."); + } + + ulong device; + ulong inode; + ulong links; + uint mode; + 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); + length = BitConverter.ToInt64(stat, 96); + } + else + { + device = BitConverter.ToUInt64(stat, 0); + inode = BitConverter.ToUInt64(stat, 8); + links = BitConverter.ToUInt64(stat, 16); + mode = BitConverter.ToUInt32(stat, 24); + length = BitConverter.ToInt64(stat, 48); + } + uint fileType = mode & 0xF000; + bool isDirectory = fileType == 0x4000; + bool isRegular = fileType == 0x8000; + bool isLink = fileType == 0xA000; + int unixMode = (int)(mode & 0x0FFF); + string unixIdentity = $"{device:x}:{inode:x}"; + string physicalPath = GetUnixPhysicalPath(path, unixIdentity, isDirectory); + return new NativeFacts( + unixIdentity, + physicalPath, + length, + checked((long)links), + unixMode, + isDirectory, + isRegular, + isLink, + (unixMode & 0x80) != 0, + Convert.ToString(unixMode, 8).PadLeft(4, '0'), + 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 (fstat(rebound.DangerousGetHandle().ToInt32(), stat) != 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 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()})."); + } + string value = builder.ToString(); + return value.StartsWith(@"\\?\", StringComparison.Ordinal) ? value.Substring(4) : value; + } + + private static WindowsPermissionFacts GetWindowsPermissionFacts(string path, bool directory) + { + FileSystemSecurity security = directory + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access | AccessControlSections.Owner) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access | AccessControlSections.Owner); + SecurityIdentifier current = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + 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 = + (directory ? FileSystemRights.ReadAndExecute : FileSystemRights.Read) | + FileSystemRights.Synchronize; + bool ownerWritable = false; + bool hasInheritedAccessRules = false; + bool ownerOnlyAccess = owner.Equals(current) && rules.Count >= 1; + bool exactOwnerOnlyAccess = security.AreAccessRulesProtected && + owner.Equals(current) && rules.Count == 1; + bool exactWritableOwnerOnlyDirectoryAccess = directory && + security.AreAccessRulesProtected && owner.Equals(current) && rules.Count == 1; + foreach (FileSystemAccessRule rule in rules) + { + hasInheritedAccessRules |= rule.IsInherited; + ownerOnlyAccess &= rule.IdentityReference.Equals(current) && + rule.AccessControlType == AccessControlType.Allow; + if (rule.AccessControlType == AccessControlType.Allow && (rule.FileSystemRights & writeMask) != 0) + { + ownerWritable = true; + } + exactOwnerOnlyAccess &= rule.IdentityReference.Equals(current) && + !rule.IsInherited && + rule.AccessControlType == AccessControlType.Allow && + rule.FileSystemRights == expectedRights && + rule.InheritanceFlags == InheritanceFlags.None && + rule.PropagationFlags == PropagationFlags.None; + exactWritableOwnerOnlyDirectoryAccess &= rule.IdentityReference.Equals(current) && + !rule.IsInherited && + rule.AccessControlType == AccessControlType.Allow && + rule.FileSystemRights == FileSystemRights.FullControl && + rule.InheritanceFlags == (InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit) && + rule.PropagationFlags == PropagationFlags.None; + } + bool fileReadOnly = directory || + (File.GetAttributes(path) & FileAttributes.ReadOnly) == FileAttributes.ReadOnly; + return new WindowsPermissionFacts( + security.GetSecurityDescriptorSddlForm(AccessControlSections.Access | AccessControlSections.Owner), + ownerWritable, + owner.Value, + current.Value, + security.AreAccessRulesProtected, + hasInheritedAccessRules, + ownerOnlyAccess, + exactOwnerOnlyAccess, + exactWritableOwnerOnlyDirectoryAccess, + fileReadOnly); + } + + private static void SetOwnerOnlyWindows(string path, bool directory, bool writable) + { + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier owner = identity.User ?? throw new IOException("The current Windows identity has no SID."); + FileSystemSecurity security = directory ? new DirectorySecurity() : new FileSecurity(); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + FileSystemRights rights = writable + ? FileSystemRights.FullControl + : (directory ? FileSystemRights.ReadAndExecute : FileSystemRights.Read); + InheritanceFlags inheritance = directory && writable ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None; + security.AddAccessRule(new FileSystemAccessRule(owner, rights, inheritance, + PropagationFlags.None, AccessControlType.Allow)); + if (directory) + FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), (DirectorySecurity)security); + else + FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); + if (!directory) + { + File.SetAttributes(path, writable ? FileAttributes.Normal : FileAttributes.ReadOnly); + } + } + + private sealed class WindowsPermissionFacts + { + internal WindowsPermissionFacts(string sddl, bool ownerWritable, string ownerSid, + string currentIdentitySid, bool accessRulesProtected, bool hasInheritedAccessRules, + bool ownerOnlyAccess, bool exactOwnerOnlyAccess, + bool exactWritableOwnerOnlyDirectoryAccess, bool fileReadOnly) + { + Sddl = sddl; + OwnerWritable = ownerWritable; + OwnerSid = ownerSid; + CurrentIdentitySid = currentIdentitySid; + 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 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 NativeFacts + { + internal NativeFacts(string identity, string physicalPath, long length, long linkCount, + int unixMode, bool isDirectory, bool isRegularFile, bool isReparsePoint, + bool ownerWritable, string permissionEvidence, string ownerSid, + string currentIdentitySid, bool accessRulesProtected, bool hasInheritedAccessRules, + bool ownerOnlyAccess, bool exactOwnerOnlyAccess, + bool exactWritableOwnerOnlyDirectoryAccess, bool fileReadOnly) + { + Identity = identity; + PhysicalPath = physicalPath; + Length = length; + LinkCount = linkCount; + UnixMode = unixMode; + IsDirectory = isDirectory; + IsRegularFile = isRegularFile; + IsReparsePoint = isReparsePoint; + OwnerWritable = ownerWritable; + PermissionEvidence = permissionEvidence; + OwnerSid = ownerSid; + CurrentIdentitySid = currentIdentitySid; + 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 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 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)] + private struct SecurityAttributes + { + public int Length; + public IntPtr SecurityDescriptor; + public int InheritHandle; + } + + [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)] + 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("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 fstat(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/source/GraphKit.psd1 b/source/GraphKit.psd1 index 08223db..0a154e8 100644 --- a/source/GraphKit.psd1 +++ b/source/GraphKit.psd1 @@ -58,7 +58,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 = @() diff --git a/tests/QA/BuiltModule.tests.ps1 b/tests/QA/BuiltModule.tests.ps1 index f4e25b2..9a5b344 100644 --- a/tests/QA/BuiltModule.tests.ps1 +++ b/tests/QA/BuiltModule.tests.ps1 @@ -44,6 +44,15 @@ Describe 'Built module' -Skip:($null -eq $script:BuiltBase) { $names | Should -Contain 'Microsoft.PowerShell.SecretManagement' } + 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' { # CopyPaths packages it; only FormatsToProcess makes the views apply. (Import-PowerShellDataFile $script:Manifest).FormatsToProcess | diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 5160a52..93e3e12 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -1,128 +1,1829 @@ -$requiredGraphKitAuthCases = @( - @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' } - @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.dll' } - @{ Path = 'Assemblies/GraphKit.Auth/GraphKit.Auth.deps.json' } - @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll' } - @{ Path = 'Assemblies/GraphKit.Auth/Microsoft.IdentityModel.Abstractions.dll' } +$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é.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 alias' - Entries = @( - 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' - 'assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' - ) + Kind = 'case' + ExpectedName = '0.4.0-r8.fixture.version-alias' + AliasName = '0.4.0-r8.fixture.VERSION-ALIAS' } @{ - Kind = 'backslash 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 = 'NFC' + ExpectedName = '0.4.0-r8.fixture.vérsion-alias' + AliasName = '0.4.0-r8.fixture.vérsion-alias'.Normalize([Text.NormalizationForm]::FormD) } ) +$linuxAtomicRenameCases = if ($IsLinux) { @(@{}) } else { @() } +$linuxCaseSensitiveStageAliasCases = if ($IsLinux) { @(@{}) } else { @() } BeforeAll { Add-Type -AssemblyName System.IO.Compression.FileSystem - + $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 - $fullVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { - $script:baseVersion + $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 } } - else { - "$script:baseVersion-$prerelease" + } + 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." + } } - $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$fullVersion.nupkg" } - if ($script:packagePath -and (Test-Path -LiteralPath $script:packagePath -PathType Leaf)) { - $archive = [System.IO.Compression.ZipFile]::OpenRead($script:packagePath) + 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 { - $script:packageEntries = @($archive.Entries.FullName) + $matches = @($archive.Entries | Where-Object FullName -CEQ $EntryPath) + if ($matches.Count -ne 1) { throw "Expected one '$EntryPath' archive entry." } + $stream = $matches[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() + finally { $archive.Dispose() } + } + + function Set-GraphKitAuthTestStageWritable { + param([Parameter(Mandatory)] [string] $StagePath) + $payloadPath = Join-Path $StagePath 'payload' + $versionPath = Split-Path $StagePath -Parent + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + foreach ($directoryPath in @($versionPath, $StagePath, $payloadPath)) { + $acl = Get-Acl -LiteralPath $directoryPath + $acl.SetAccessRuleProtection($true, $false) + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $identity, [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.SetAccessRule($rule) + Set-Acl -LiteralPath $directoryPath -AclObject $acl + } + Get-ChildItem -LiteralPath $StagePath -File -Recurse -Force | ForEach-Object { $_.IsReadOnly = $false } + } + else { + & chmod 0700 $versionPath $StagePath $payloadPath + if ($LASTEXITCODE -ne 0) { throw 'Could not open the stage fixture for mutation.' } + Get-ChildItem -LiteralPath $StagePath -File -Recurse -Force | ForEach-Object { & chmod 0600 $_.FullName } + if ($LASTEXITCODE -ne 0) { throw 'Could not open the stage files for mutation.' } } } - function Assert-GraphKitAuthArchiveEntry { + function Set-GraphKitAuthTestStageSealed { param( - [Parameter(Mandatory)] [string[]] $Entries, - [Parameter(Mandatory)] [string] $RequiredPath + [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) + } - $exact = @($Entries | Where-Object { $_ -ceq $RequiredPath }) - $normalizedRequired = $RequiredPath.Replace('\', '/') - $equivalent = @( - $Entries | Where-Object { - $normalized = $_.Replace('\', '/') - [string]::Equals($normalized, $normalizedRequired, [StringComparison]::OrdinalIgnoreCase) + function Get-GraphKitAuthTestDirectorySecurity { + param([Parameter(Mandatory)][string] $Path) + if ($IsWindows) { return (Get-Acl -LiteralPath $Path).Sddl } + return [int][IO.File]::GetUnixFileMode($Path) + } + + function Set-GraphKitAuthTestTreeWritable { + param([Parameter(Mandatory)][string] $Path) + if (-not (Test-Path -LiteralPath $Path)) { return } + if ($IsWindows) { + Get-ChildItem -LiteralPath $Path -File -Recurse -Force -ErrorAction SilentlyContinue | + ForEach-Object { $_.IsReadOnly = $false } + foreach ($directory in @(Get-ChildItem -LiteralPath $Path -Directory -Recurse -Force -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Length } -Descending) + @(Get-Item -LiteralPath $Path -Force)) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($directory.FullName, $true, $true) } - ) - if ($exact.Count -ne 1 -or $equivalent.Count -ne 1) { - throw "The archive must contain '$RequiredPath' exactly once with no case, separator, or duplicate equivalent; found $($exact.Count) exact and $($equivalent.Count) equivalent entries." } + else { + & chmod -R u+rwX $Path + if ($LASTEXITCODE -ne 0) { throw "Could not make test tree '$Path' writable." } + } + } + + 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')) + New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` + -FullVersion ("0.4.0-r8.fixture.$Name." + [guid]::NewGuid().ToString('N')) ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') } - function New-GraphKitAuthArchiveFixture { + function Invoke-GraphKitAuthStageMutation { + param([string] $Kind, [string] $StagePath) + 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); [IO.File]::Delete($targetPath); [IO.File]::WriteAllBytes($targetPath, $bytes) } + 'hard-link' { + $outsideLink = Join-Path $TestDrive ('GraphKit.Auth.hardlink-' + [guid]::NewGuid().ToString('N') + '.dll') + $null = New-Item -ItemType HardLink -Path $outsideLink -Target $targetPath -ErrorAction Stop + } + 'escaped-link' { + $outsidePath = Join-Path $TestDrive ('outside-' + [guid]::NewGuid().ToString('N') + '.dll') + [IO.File]::WriteAllText($outsidePath, 'outside'); [IO.File]::Delete($targetPath) + $null = New-Item -ItemType SymbolicLink -Path $targetPath -Target $outsidePath -ErrorAction Stop + } + '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é.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')) + $null = New-Item -ItemType Directory -Path $outsidePayload + 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' } + $null = New-Item -ItemType $kind -Path $payloadPath -Target $outsidePayload -ErrorAction Stop + } + 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] $Path, - [Parameter(Mandatory)] [string[]] $Entries + [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 + Test-Path -LiteralPath (Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs') -PathType Leaf | Should -BeTrue + { Assert-GraphKitAuthStageCommands } | Should -Not -Throw + } + + 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 { + Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput | Out-Null + } + } + + 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 (Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) } | + 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 { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + } + } + + 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 + try { + Invoke-GraphKitAuthStageMutation -Kind $Kind -StagePath $fixture.StagePath + $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 { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + } + } + + 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 { + Invoke-GraphKitAuthPrepareClean -OutputRoot ( + Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) | Out-Null + } + } + + It 'rejects a manifest hard link without an extra stage entry masking link count' { + $fixture = New-GraphKitAuthStageFixture -Name 'manifest-hard-link' + 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') + $null = New-Item -ItemType HardLink -Path $outsideLink -Target $manifestPath -ErrorAction Stop + Set-GraphKitAuthTestStageSealed -StagePath $fixture.StagePath + + { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | + Should -Throw '*manifest is not link-count one*' + } + finally { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + } + } + + 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 'removes identity-bound owned state after injected creation failure' -ForEach @( + @{ 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' + foreach ($rootName in @('capture','stage')) { + $root = Join-Path $fixtureOutput "GraphKit.Auth/$rootName" + if (Test-Path -LiteralPath $root -PathType Container) { + @([IO.Directory]::EnumerateFileSystemEntries($root)).Count | Should -Be 0 + } + } + } + + 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].CurrentIdentitySid + $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 + } + } - $archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create) + 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 { - foreach ($entryName in $Entries) { - $null = $archive.CreateEntry($entryName) + 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 { - $archive.Dispose() + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + Remove-Item -LiteralPath $fixtureOutput -Recurse -Force -ErrorAction SilentlyContinue } + } - $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + 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 { - return @($archive.Entries.FullName) + $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 { - $archive.Dispose() + 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) + $authRoot = Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent + $outputRoot = Split-Path $authRoot -Parent + $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 { + Set-GraphKitAuthTestTreeWritable -Path $outputRoot + Remove-Item -LiteralPath $outputRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + 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' + $versionRootA = Split-Path $first.StagePath -Parent + $versionRootB = Split-Path $second.StagePath -Parent + [IO.Directory]::Move($versionRootB, (Join-Path $stageRootA $upperVersion)) + $movedSecondStage = Join-Path (Join-Path $stageRootA $upperVersion) ([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 'handles unavailable Linux renameat2 as actionable fail-closed without a fallback' { + $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' + } + + 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' + } + } + $null = New-Item -ItemType Junction -Path $linkPath -Target $external + try { + { New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput -FullVersion "0.4.0-r8.fixture.junction-$RootKind" ` + -PayloadSourceRoot (Join-Path $script:stagePath 'payload') } | Should -Throw '*without following*' + + (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 { + $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 + } + } + } + + 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.CurrentIdentitySid + } + else { + $fixture.ManifestInitialEvidence.UnixMode | Should -Be 0x180 + } + } + finally { + Invoke-GraphKitAuthPrepareClean -OutputRoot ( + Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) | Out-Null + } + } + + It 'does not call wrong-owner Windows evidence owner-only at initial creation' { + 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-222' + + $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyAccess($evidence) | 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 '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 and consumes the exact Windows owner-only ACL evidence schema' { + $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', 'AccessRulesProtected', + 'HasInheritedAccessRules', 'ExactOwnerOnlyAccess' + )) { + $helper | Should -Match ([regex]::Escape($property + ' { get; init; }')) + $task | Should -Match ([regex]::Escape('$Evidence.' + $property)) + } + $helper | Should -Match 'directory \? FileSystemRights\.ReadAndExecute : FileSystemRights\.Read' + $helper | Should -Match 'InheritanceFlags\.None' + } + + 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' + $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 + $copy.DestinationInitial.UnixMode | Should -Be 0x180 + $copy.Destination.UnixMode | Should -Be 0x180 + } + + It 'creates a Windows child with only current-identity access before explicit reseal' -ForEach $windowsInitialAccessCases -AllowNullOrEmptyForEach { + Initialize-GraphKitAuthStageCapture + $root = Join-Path $TestDrive ('windows-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') + + $copy.DestinationInitial.OwnerOnlyAccess | Should -BeTrue + $copy.DestinationInitial.CurrentIdentitySid | + Should -BeExactly ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + } + + It 'allows an ordinary Windows inherited-ACL copy only when the sealed initial gate is false' -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 + } + + { $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $destination, 'candidate.dll', $false) } | Should -Not -Throw + { $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $sealedDestination, 'candidate.dll', $true) } | Should -Throw '*owner-only*' + } + + It 'rejects a sealed stage after Windows ACL mutation' -ForEach $windowsAclMutationCases -AllowNullOrEmptyForEach { + $fixture = New-GraphKitAuthStageFixture -Name ('windows-acl-' + $Kind.Replace(' ', '-')) + Set-GraphKitAuthWindowsAclMutation -StagePath $fixture.StagePath -Kind $Kind + { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | + Should -Throw + } + + 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' + $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 + } + + 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 + $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 plus a ' -ForEach $graphKitAuthArchiveAliasCases { - $fixturePath = Join-Path $TestDrive ("graphkit-auth-$($Kind.Replace(' ', '-')).zip") - $entries = @(New-GraphKitAuthArchiveFixture -Path $fixturePath -Entries $Entries) + It 'rejects an exact path set containing a ' -ForEach $graphKitAuthArchiveAliasCases { + { Assert-GraphKitAuthArchivePaths -Entries $Entries } | Should -Throw + } - { Assert-GraphKitAuthArchiveEntry -Entries $entries -RequiredPath 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' } | - 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 'contains the exact required path ' -ForEach $requiredGraphKitAuthCases { - $script:packagePath | Should -Not -BeNullOrEmpty -Because 'pack must produce a versioned GraphKit candidate' - Test-Path -LiteralPath $script:packagePath -PathType Leaf | Should -BeTrue -Because 'the package boundary is tested against the packed candidate' + 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 + } +} - Assert-GraphKitAuthArchiveEntry -Entries $script:packageEntries -RequiredPath $Path - Test-Path -LiteralPath (Join-Path $script:builtModuleRoot $Path) -PathType Leaf | - Should -BeTrue -Because "the packed path '$Path' must originate in the built module" +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/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index cf3e895..1e0d547 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -102,6 +102,7 @@ BeforeAll { [int] $Passed = -1, [bool] $Executed = $true, [switch] $ForGenerator, + [switch] $IncludeGraphKitAuth, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, [int] $Total = 896 @@ -118,7 +119,8 @@ BeforeAll { $gateDir = Join-Path $fixtureRoot 'tests/QA' $scriptsDir = Join-Path $fixtureRoot 'scripts' $privateScriptsDir = Join-Path $scriptsDir 'private' - New-Item -ItemType Directory -Path $moduleDir, $resultsDir, $gateDir, $scriptsDir, $privateScriptsDir -Force | Out-Null + $buildDir = Join-Path $fixtureRoot '.build' + New-Item -ItemType Directory -Path $moduleDir, $resultsDir, $gateDir, $scriptsDir, $privateScriptsDir, $buildDir -Force | Out-Null Copy-Item -LiteralPath (Join-Path $script:repoRoot 'tests/QA/Assert-GateResult.ps1') ` -Destination (Join-Path $gateDir 'Assert-GateResult.ps1') @@ -130,6 +132,12 @@ BeforeAll { -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') + 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') ` @@ -144,6 +152,10 @@ BeforeAll { $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 { '' } $payloads = [ordered] @{ 'Data/Operations/Probe.List.psd1' = "@{ SchemaVersion = 1; Type = 'Probe'; Operation = 'List' }`n" @@ -158,7 +170,7 @@ BeforeAll { Copyright = '(c) Fixture Author' Description = 'Fixture GraphKit release-proof module package.' FunctionsToExport = @('Get-GraphProbe') - RequiredModules = @(@{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' }) +$requiredAssembliesLine RequiredModules = @(@{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' }) PrivateData = @{ PSData = @{ Tags = @('Fixture', 'Graph') LicenseUri = 'https://opensource.org/licenses/MIT' @@ -170,6 +182,13 @@ BeforeAll { '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 @@ -177,6 +196,12 @@ BeforeAll { 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 @@ -500,6 +525,11 @@ function Test-ModuleManifest { 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 } @@ -515,6 +545,16 @@ Describe 'Canonical tested release proof' { $result.Output | Should -Match '5 shipped file' } + 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 @@ -682,11 +722,60 @@ Describe 'Canonical tested release proof' { $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é.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' } ) { @@ -723,6 +812,23 @@ Describe 'Canonical tested release proof' { $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é.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' } @@ -937,16 +1043,21 @@ Describe 'Test workflow release-proof generation' { $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_Stop_On_Fail\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_Stop_On_Fail') - $testWorkflow.IndexOf('Pester_Tests_Stop_On_Fail') | Should -BeLessThan $testWorkflow.IndexOf('Record_Tested_Release_Proof') + $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' From d16ca572f3746a596456dc8421d4b821f8bcc583 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 23:42:59 -0400 Subject: [PATCH 25/79] feat: use compiled token sources for built-in auth --- source/Private/Get-GraphVaultCredential.ps1 | 14 +- .../Initialize-GraphModuleLifecycle.ps1 | 16 + .../Private/TokenSources/GraphTokenSource.ps1 | 14 +- .../TokenSources/New-GraphAuthTokenSource.ps1 | 266 ++++++++ .../Transport/Send-GraphHttpRequest.ps1 | 10 + source/Public/Get-GraphContext.ps1 | 94 ++- source/Public/Register-GraphTenant.ps1 | 57 +- source/Public/Test-GraphTenant.ps1 | 18 +- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 112 +++- tests/Adapter/Send-GraphHttpRequest.Tests.ps1 | 169 ++++- .../Auth/Get-GraphVaultCredential.Tests.ps1 | 39 +- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 629 +++++++++++++++++- .../Unit/Profiles/Get-GraphContext.Tests.ps1 | 411 +++++++++++- .../Import-GraphLegacyProfile.Tests.ps1 | 3 +- .../Profiles/Register-GraphTenant.Tests.ps1 | 159 ++++- .../Unit/Profiles/Test-GraphTenant.Tests.ps1 | 140 +++- tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 | 14 +- .../TokenSources/GraphTokenSource.Tests.ps1 | 509 +++++++++++++- .../Transport/GraphModuleLifecycle.Tests.ps1 | 64 +- 19 files changed, 2602 insertions(+), 136 deletions(-) create mode 100644 source/Private/TokenSources/New-GraphAuthTokenSource.ps1 diff --git a/source/Private/Get-GraphVaultCredential.ps1 b/source/Private/Get-GraphVaultCredential.ps1 index 86a7937..51ed6d5 100644 --- a/source/Private/Get-GraphVaultCredential.ps1 +++ b/source/Private/Get-GraphVaultCredential.ps1 @@ -87,7 +87,12 @@ 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' { @@ -177,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 } } } diff --git a/source/Private/Initialize-GraphModuleLifecycle.ps1 b/source/Private/Initialize-GraphModuleLifecycle.ps1 index c69e466..e14cf72 100644 --- a/source/Private/Initialize-GraphModuleLifecycle.ps1 +++ b/source/Private/Initialize-GraphModuleLifecycle.ps1 @@ -600,6 +600,22 @@ function Stop-GraphModule { } $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 diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index ea742a5..8677423 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -953,6 +953,10 @@ function New-GraphTokenSource { $authMethod = [string]$Profile.AuthMethod $audience = [string]$Cloud.Resource $clientId = $Profile.ClientId + if ($null -eq $MsalFactory) { + return New-GraphAuthTokenSource -Profile $Profile -Cloud $Cloud + } + $factoryProfile = $Profile if ($authMethod -eq 'Certificate' -and -not [string]::IsNullOrEmpty([string] $Profile.Credential.PfxPath)) { @@ -1008,11 +1012,11 @@ function New-GraphTokenSource { return [ConfidentialClientTokenSource]::new($factory, '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..aeeea55 --- /dev/null +++ b/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 @@ -0,0 +1,266 @@ +<# + Private: validate the successor profile identity discriminator shared by + registration, metadata validation, and context construction. +#> +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 "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 "AuthMethod '$authMethod' requires a non-empty, non-zero top-level ClientId. Re-register the profile with -ClientId." + } + if ($hasNestedClientId) { + throw "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 "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 "AuthMethod '$authMethod' requires a non-zero ClientId. Re-register the profile with the application ClientId." + } + $applicationClientId = $parsed.ToString('D') + break + } + 'ManagedIdentity' { + if ($hasNonNullTopLevelClientId) { + throw "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 '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 "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 '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 "AuthMethod 'BearerToken' must not declare ClientId, ManagedIdentityClientId, or Credential.ClientId. Re-register the profile without a client identity selector." + } + break + } + default { + throw "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/Transport/Send-GraphHttpRequest.ps1 b/source/Private/Transport/Send-GraphHttpRequest.ps1 index 10e8cd2..a21a65a 100644 --- a/source/Private/Transport/Send-GraphHttpRequest.ps1 +++ b/source/Private/Transport/Send-GraphHttpRequest.ps1 @@ -338,6 +338,16 @@ function Send-GraphHttpRequest { $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) { # Mutating sends require tenant proof BEFORE the request is issued. # A result that carries no VerifiedTenantId, or whose binding is not diff --git a/source/Public/Get-GraphContext.ps1 b/source/Public/Get-GraphContext.ps1 index 45a3e7f..74afb04 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 = '' @@ -91,35 +117,24 @@ function Get-GraphContext { 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 ` - -ExpectedCredentialGeneration $generation ` - -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 - OwnsMaterial = $false - CredentialGeneration = $generation - } - }.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 { @@ -128,7 +143,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' @@ -142,7 +157,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 ` @@ -150,8 +165,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/Register-GraphTenant.ps1 b/source/Public/Register-GraphTenant.ps1 index f5bae55..a4c3012 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. @@ -107,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 @@ -129,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 @@ -137,7 +140,8 @@ 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()] @@ -230,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) { @@ -296,7 +301,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..362896a 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,12 @@ function Test-GraphTenant { return $false } + try { + $null = Assert-GraphTenantProfileAuthSchema -Profile $TenantProfile + } + catch { + return $false + } + return $true } diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index 5d2ba02..94f0dc3 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; using System.Runtime.Loader; namespace GraphKit.Auth; @@ -17,6 +18,8 @@ public sealed class GraphAuthHost : IDisposable 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 = []; @@ -108,54 +111,109 @@ public GraphAuthHost( public IGraphTokenSource CreateSource(GraphTokenRequest request) { ArgumentNullException.ThrowIfNull(request); - lock (_gate) + + IDisposable? acceptedMaterial = GetOwnedMaterial(request.Credential); + if (acceptedMaterial is not null) { - ThrowIfStopping(); - IGraphTokenSourceFactory factory = _factory ?? - throw new ObjectDisposedException(nameof(GraphAuthHost)); - IGraphTokenSource? source; try { - source = factory.Create(request); + ConsumedOwnedMaterials.Add(acceptedMaterial, ConsumedMaterialMarker); } - catch (Exception exception) + catch (ArgumentException) { - throw ProviderBoundaryFailure.Recreate( - exception, - CancellationToken.None, - "provider_construction_failed", - "Provider"); + throw new GraphAuthException( + "credential_material_consumed", + "CredentialOwnership", + "The owned credential material has already been transferred to an authentication source.", + retryAfter: null, + correlationId: null); } + } - if (source is null) + bool providerFactoryInvoked = false; + try + { + lock (_gate) { - throw new InvalidOperationException( - "The GraphKit.Auth provider factory returned a null token source."); - } + ThrowIfStopping(); + IGraphTokenSourceFactory 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"); + } - try - { - ValidateProviderSource(source); - GraphTokenSourceProxy proxy = new(this, source); - _sources.Add(proxy); - return proxy; + if (source is null) + { + throw new InvalidOperationException( + "The GraphKit.Auth provider factory returned a null token source."); + } + + try + { + ValidateProviderSource(source); + GraphTokenSourceProxy proxy = new(this, source); + _sources.Add(proxy); + return proxy; + } + catch + { + try + { + source.Dispose(); + } + catch + { + throw CreateProviderDisposalFailure(); + } + + throw; + } } - catch + } + catch + { + if (acceptedMaterial is not null && !providerFactoryInvoked) { try { - source.Dispose(); + acceptedMaterial.Dispose(); } catch { - throw CreateProviderDisposalFailure(); + 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; } + + 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(); diff --git a/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 b/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 index 712b69d..da5caa6 100644 --- a/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 +++ b/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 @@ -7,6 +7,90 @@ 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)" + } + $null = [Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($fixtureAssembly) + } + $script:VerifiedTenant = [guid] '00000000-0000-0000-0000-000000000001' $script:openServers = [System.Collections.Generic.List[object]]::new() @@ -123,19 +207,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 +263,64 @@ 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' { + $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' diff --git a/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 b/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 index 818ee28..57f33f0 100644 --- a/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 +++ b/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 @@ -155,8 +155,15 @@ 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' @@ -164,6 +171,8 @@ Describe 'Get-GraphVaultCredential' { $result.Material | Should -Be $script:BearerPlain $result.OwnsMaterial | Should -BeFalse $result.ManagedIdentityClientId | Should -BeNullOrEmpty + $result.CredentialGeneration | Should -Not -BeNullOrEmpty + $result.CredentialGeneration | Should -BeExactly $expectedGeneration } } @@ -390,27 +399,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 index a73a2f0..3e3f0a6 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -221,6 +221,7 @@ $sourceType = $assembly.GetType('GraphKit.Auth.IGraphTokenSource', $true, $false Set-Content -LiteralPath $sourcePath -NoNewline -Encoding utf8NoBOM -Value @' using System; using System.IO; +using System.Security; using System.Threading; using GraphKit.Auth; @@ -242,11 +243,31 @@ public sealed class GraphTokenSourceFactory : IGraphTokenSourceFactory 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"); } @@ -260,10 +281,20 @@ 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; + 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") @@ -337,6 +368,8 @@ internal sealed class FixtureTokenSource : IGraphTokenSource File.AppendAllText(_disposeMarker, "disposed" + Environment.NewLine); } + _ownedMaterial?.Dispose(); + if (string.Equals( Environment.GetEnvironmentVariable("GRAPHKIT_AUTH_TEST_DISPOSE_FAILURE"), "1", @@ -530,6 +563,9 @@ 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; @@ -538,6 +574,480 @@ 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 sanitized = RunSanitizedCleanupFailure(payloadRoot); + var weakKeys = RunWeakKeyProof(payloadRoot); + return JsonSerializer.Serialize(new + { + DistinctHostSecretRace = distinctHost, + SameHostCertificateRace = sameHost, + ReentrantFactory = reentrant, + StoppedHost = stopped, + MissingFactory = missingFactory, + PostProviderFailure = postProvider, + 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 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; @@ -1477,7 +1987,7 @@ foreach ($type in @($assembly.GetExportedTypes() | Sort-Object FullName)) { param( [Parameter(Mandatory)] [string] $ContractsPath, [string] $PayloadRoot, - [Parameter(Mandatory)] [ValidateSet('Validation', 'Lifecycle', 'ProviderFailure', 'FactoryConstructionFailure', 'SourceConstructionFailure', 'VersionMismatch', 'IncompatibleDefault', 'HostLoadFailure', 'ConcurrentDispose', 'BlockedCancellationCallback', 'ImmediateDisposalFailure', 'DeferredDisposalFailure', 'SamePathReplacement')] [string] $Scenario, + [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, @@ -1523,6 +2033,39 @@ function New-ValidRequest { ) } +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 { @@ -1716,6 +2259,9 @@ switch ($Scenario) { ResidentMvid = $residentMvid } | ConvertTo-Json -Compress } + 'OwnershipLedger' { + [GraphKitAuthRuntimeHarness]::OwnershipLedgerProof($PayloadRoot, $DisposeMarker) + } } '@ @@ -2561,4 +3107,83 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $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.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/Profiles/Get-GraphContext.Tests.ps1 b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 index 24715a1..88bbc4f 100644 --- a/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 +++ b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 @@ -7,6 +7,47 @@ 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)); + return X509CertificateLoader.LoadPkcs12( + source.Export(X509ContentType.Pkcs12), + password: null); + } + + 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 +62,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 +101,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 +181,306 @@ 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' + } + 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..8a4a67b 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' }) diff --git a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 index 90cee52..1c20ad1 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 @@ -30,7 +30,7 @@ Describe 'Register-GraphTenant' { [System.IO.File]::WriteAllBytes($pfxPath, [byte[]] @(1, 2, 3)) Register-GraphTenant -ProfileId 'pfx-versioned' -Name 'PFX' -Kind 'lab' ` - -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'Certificate' ` + -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 @@ -46,7 +46,7 @@ Describe 'Register-GraphTenant' { $script:storePath = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) Register-GraphTenant -ProfileId 'vault-cert' -Name 'Vault cert' -Kind 'lab' ` - -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'Certificate' ` + -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 @@ -109,11 +109,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 } @@ -126,10 +126,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..0f609c0 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,142 @@ 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' } + } + ) { + $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 + } + + Test-GraphTenant -TenantProfile $profile | 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 = @{} + $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 + } + + Test-GraphTenant -TenantProfile $profile | 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' + } + + 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..cf12457 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,17 @@ BeforeAll { Describe 'Use-GraphTenant' { + BeforeEach { + Mock Get-GraphVaultCredential -ModuleName GraphKit { + [pscustomobject]@{ + AuthMethod = 'ClientSecret' + Material = ConvertTo-SecureString 'use-graph-tenant-test' -AsPlainText -Force + OwnsMaterial = $true + CredentialGeneration = 'g1|ClientSecret|use-graph-tenant-test' + } + } + } + It 'sets the script-scoped current context and returns it' { $context = Use-GraphTenant -ProfileId 'acme' -StorePath $script:storePath @@ -33,4 +44,3 @@ Describe 'Use-GraphTenant' { { Use-GraphTenant -ProfileId 'missing' -StorePath $script:storePath } | Should -Throw -ExpectedMessage '*No profile with ProfileId*' } } - diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index 686c068..c4c17f6 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -8,6 +8,169 @@ 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)); + return X509CertificateLoader.LoadPkcs12(source.Export(X509ContentType.Pkcs12), null); + } + + public static SecureString CreateSecret() + { + SecureString secret = new(); + foreach (char value in "task6-secret") secret.AppendChar(value); + 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) + { + using X509Certificate2 certificate = X509CertificateLoader.LoadPkcs12( + pfx, + password, + X509KeyStorageFlags.Exportable); + 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; @@ -339,6 +502,27 @@ Describe 'GraphTokenSource' { 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' } @@ -351,23 +535,321 @@ Describe 'GraphTokenSource' { $secret.AuthMode | Should -Be 'ClientSecret' $cert = New-GraphTokenSource -Profile @{ - AuthMethod = 'Certificate'; ClientId = $null + 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 { + ConvertTo-SecureString $passwordText -AsPlainText -Force + } + + $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' { @@ -1063,19 +1545,11 @@ Describe 'GraphTokenSource' { @($script:GenerationSnapshotProbe | Where-Object { $_ -ne 0 }).Count | Should -Be 0 } - It 'pins a relative PFX path to the canonical path passed into its lazy factory' { + 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' $null = New-Item -ItemType Directory -Path $original, $elsewhere -Force [System.IO.File]::WriteAllBytes((Join-Path $original 'credential.pfx'), [byte[]] @(1, 3, 3, 7)) - $script:CapturedPfxFactoryProfile = $null - - Mock New-GraphMsalApplicationFactory -ModuleName GraphKit { - param($Profile, $Cloud, $ExpectedCredentialGeneration) - $script:CapturedPfxFactoryProfile = $Profile - return { throw 'canonical-path capture test must not acquire' } - } - $source = InModuleScope GraphKit -Parameters @{ Origin = $original } { param($Origin) Push-Location $Origin @@ -1091,7 +1565,7 @@ Describe 'GraphTokenSource' { } -Cloud @{ Resource = 'https://graph.microsoft.com' Authority = 'https://login.microsoftonline.com' - } + } -MsalFactory { throw 'canonical-path capture test must not acquire' } } finally { Pop-Location @@ -1100,10 +1574,10 @@ Describe 'GraphTokenSource' { Push-Location $elsewhere try { - $script:CapturedPfxFactoryProfile.Credential.PfxPath | Should -Be ( - [System.IO.Path]::GetFullPath((Join-Path $original 'credential.pfx')) - ) $source.CredentialGeneration | Should -Match '^g1\|Certificate\.PFX\|.+\|71:sha256:[0-9a-f]{64}\|5:vault\|8:password\|2:v1$' + $source.CredentialGeneration | Should -Match ([regex]::Escape( + [System.IO.Path]::GetFullPath((Join-Path $original 'credential.pfx')) + )) } finally { Pop-Location @@ -1229,8 +1703,9 @@ Describe 'GraphTokenSource' { $result = InModuleScope GraphKit { $profile = @{ TenantId = '00000000-0000-0000-0000-000000000001' - ClientId = '00000000-0000-0000-0000-000000000002' + ClientId = $null AuthMethod = 'BearerToken' + Environment = 'Global' Credential = @{ VaultName = 'vault'; SecretName = 'bearer' } } $cloud = @{ diff --git a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 index a329914..d828e22 100644 --- a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +++ b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 @@ -516,7 +516,9 @@ Describe 'GraphKit module lifecycle' { $stateType = $state.PSObject.TypeNames[0] $onRemoveInstalled = $module.OnRemove -is [scriptblock] - $resourceRegistered = $state.OwnedResources.Count -eq 1 + $resourceRegistered = + $state.OwnedResources.Count -ge 1 -and + [object]::ReferenceEquals($state.OwnedResources[$state.OwnedResources.Count - 1], $owned) $null = Remove-Module -ModuleInfo $module -Force -ErrorAction Stop @@ -552,6 +554,66 @@ Describe 'GraphKit module lifecycle' { $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) + $source = 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' + } + [pscustomobject]@{ + BeforeCount = $before.Count + BeforeType = $before[0].GetType().FullName + HostReferenceMatches = [object]::ReferenceEquals($before[0], $script:GraphKitAuthHost) + ResourceTypes = @($script:GraphKitModuleLifecycle.OwnedResources | ForEach-Object { $_.GetType().FullName }) + Source = $source + } + } + $null = Remove-Module -ModuleInfo $module -Force -ErrorAction Stop + $rejected = $false + try { + $null = $result.Source.Acquire($false, [Threading.CancellationToken]::None) + } + catch [ObjectDisposedException] { + $rejected = $true + } + [pscustomobject]@{ + BeforeCount = $result.BeforeCount + BeforeType = $result.BeforeType + HostReferenceMatches = $result.HostReferenceMatches + ResourceTypes = $result.ResourceTypes + SourceRejectedAfterRemoval = $rejected + } + } -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].ResourceTypes) | Should -Be @( + 'GraphKit.Auth.GraphAuthHost', + 'GraphKit.Auth.GraphTokenSourceProxy' + ) + $result[0].SourceRejectedAfterRemoval | Should -BeTrue + } + finally { + $job | Remove-Job -Force -ErrorAction SilentlyContinue + } + } } Describe 'GraphKit HTTP client lifecycle' { From 863155a6dced9f133241ab1587608fb24a059876 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 1 Sep 2026 11:38:20 -0400 Subject: [PATCH 26/79] test: prove GraphKit Auth parity and runspace isolation --- .../plans/2026-08-30-r8-graphkit-auth.md | 386 +++- .../Private/TokenSources/GraphTokenSource.ps1 | 109 +- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 10 +- .../GraphKit.Auth.Tests.csproj | 6 + .../GraphTokenSourceParityTests.cs | 1039 +++++++++++ .../GraphKit.Auth.Tests/OwnershipTests.cs | 134 +- .../GraphModuleLifecycleSender.Tests.ps1 | 97 +- .../GraphKitAuthRunspace.Tests.ps1 | 1567 +++++++++++++++++ tests/Concurrency/TokenIsolation.Tests.ps1 | 23 +- tests/Fixtures/GraphKitAuthParityCases.json | 198 +++ tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 23 + tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 | 1346 ++++++++++++++ .../TokenSources/GraphTokenSource.Tests.ps1 | 372 +++- .../Transport/GraphModuleLifecycle.Tests.ps1 | 125 +- 14 files changed, 5275 insertions(+), 160 deletions(-) create mode 100644 src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs create mode 100644 tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 create mode 100644 tests/Fixtures/GraphKitAuthParityCases.json create mode 100644 tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md index fb5a2b1..68049b6 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -581,46 +581,398 @@ git add source/Private/TokenSources/New-GraphAuthTokenSource.ps1 source/Private/ 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. + +- [ ] **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 +``` -- [ ] **Step 1: Add shared legacy/compiled contract cases** +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. + +- [ ] **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: -Run the same case table against both implementations: ordinary cache hit, expiry refresh, forced -refresh, acquisition failure, cancellation, fixed-bearer force refusal, fingerprint equality, -generation mismatch, adoption, and disposal. Compare behavior and public result properties, not -concrete implementation type. +```text +task7-fingerprint-certificate +245d574cc6b41262c018a65535f9937601045f29abff3df9d112e491048948b6 +task7-fingerprint-client-secret +b4ec6c20d74417b5be0b54ce83bc39a9f0b2e990ec173e6ee9373491a65de55e +task7-fingerprint-managed-identity +6973fa751d466a0429077804c84708dc98188047d415ca992a583a6bf7744866 +task7-fingerprint-bearer-token +04fa2face75f1b6e4df7e9270266abec23741a9e91c7fad9b12b1c8585c30bca +``` -- [ ] **Step 2: Add real runspace acceptance** +Both loaders independently reject these nine permanent malformed cases: -Create one compiled source/context in the parent. Pass that exact object reference to two thread -runspaces, release them with event gates, and require bounded completion. Cover distinct tenants, -same-key single-flight, force-refresh isolation, and fixed bearer. No child may recreate a context. +```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 +``` -- [ ] **Step 3: Add unload/lifecycle acceptance** +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. -Dispose sources, remove the module, clear strong references, perform bounded GC/finalizer cycles, -and assert the host's ALC weak reference is dead. A deliberately active acquisition must cancel and -drain before owned certificate/secret disposal. +Link the exact repository fixture into the .NET test output: -- [ ] **Step 4: Run focused concurrency files serially** +```xml + +``` -Expected: all pass without Pester parallelism, sleeps, or unbounded waits. +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. -- [ ] **Step 5: Commit** +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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **Step 8: Commit, repeat on exact clean SHA, and report** + +Commit only the reviewed file set: ```bash -git add tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 tests/Concurrency/TokenIsolation.Tests.ps1 tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +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:** diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index 8677423..0bda03d 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -455,8 +455,12 @@ class FixedBearerTokenSource : GraphTokenSourceBase { class GraphTokenFlight { [System.Threading.Tasks.TaskCompletionSource[object]] $Completion [bool] $LeaderCancellationRequested + hidden [int] $WaiterCount + hidden [object] $WaiterCountLock GraphTokenFlight() { + $this.WaiterCountLock = [object]::new() + $this.WaiterCount = 0 $this.Completion = [System.Threading.Tasks.TaskCompletionSource[object]]::new( [System.Threading.Tasks.TaskCreationOptions]::RunContinuationsAsynchronously ) @@ -464,6 +468,61 @@ class GraphTokenFlight { } } +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() @@ -885,33 +944,39 @@ function Invoke-GraphTokenSingleFlight { continue } + Add-GraphTokenFlightWaiter -Flight $existing 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 + 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 + $leaderCallerWasCancelled = + $sharedAcquisitionWasCancelled -and $existing.LeaderCancellationRequested - if (-not $leaderCallerWasCancelled -or $CancellationToken.IsCancellationRequested) { - throw - } + 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 + # 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 + } + } + finally { + Remove-GraphTokenFlightWaiter -Flight $existing } } } diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index 94f0dc3..9415850 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -25,8 +25,6 @@ public sealed class GraphAuthHost : IDisposable private readonly HashSet _sources = []; private readonly List _sourceDisposalFailures = []; private readonly CancellationTokenSource _shutdown = new(); - private readonly ManualResetEventSlim _drained = new(initialState: true); - private readonly ManualResetEventSlim _shutdownCompleted = new(initialState: false); private readonly TaskCompletionSource _finalizationCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TimeSpan _shutdownTimeout; @@ -331,11 +329,7 @@ private async Task RunShutdownAsync(TaskCompletionSource shutdownComple internal GraphAuthOperationLease EnterOperation(CancellationToken callerCancellation) { ThrowIfStopping(); - int active = Interlocked.Increment(ref _activeOperations); - if (active == 1) - { - _drained.Reset(); - } + Interlocked.Increment(ref _activeOperations); if (Volatile.Read(ref _state) != Running) { @@ -640,7 +634,6 @@ private void ExitOperation() { if (Interlocked.Decrement(ref _activeOperations) == 0) { - _drained.Set(); if (Volatile.Read(ref _state) == SourcesDisposedAwaitingDrain) { TryFinalizeUnload(); @@ -690,7 +683,6 @@ private void TryFinalizeUnload() } finally { - _shutdownCompleted.Set(); _finalizationCompletion.TrySetResult(failure); } } diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj index 88c1e94..5489348 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj @@ -18,4 +18,10 @@ + + + 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..d0ed375 --- /dev/null +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs @@ -0,0 +1,1039 @@ +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", null); + + 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/OwnershipTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs index 5d03eea..592d076 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs @@ -318,13 +318,27 @@ public async Task ConcurrentOwnedCredentialReuseHasOneWinnerAndOneDisposal(Graph var secondFactory = CreateFactory(); Task first = Task.Run(() => CaptureCreate(firstFactory, firstRequest)); - Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); - Task second = Task.Run(() => CaptureCreate(secondFactory, duplicateRequest)); - bool duplicateRejectedBeforeWinnerCompleted = ReferenceEquals( - await Task.WhenAny(second, Task.Delay(TimeSpan.FromMilliseconds(500))), - second); - release.Set(); - CreateOutcome[] outcomes = await Task.WhenAll(first, second); + 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 = second.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!)) @@ -332,6 +346,13 @@ await Task.WhenAny(second, Task.Delay(TimeSpan.FromMilliseconds(500))), 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 @@ -405,27 +426,52 @@ public void FactoryFailureDisposesOnlyTransferredMaterial() Assert.True(callerOwned.HasPrivateKey); } - [Fact] - public async Task DisposalWaitsForActiveAcquisitionBeforeDisposingOwnedMaterial() + [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 release = new ManualResetEventSlim(false); + using var emergencyRelease = new ManualResetEventSlim(false); var order = new List(); - var client = new GraphTokenSourceTests.FakeTokenClient((_, _) => + var client = new GraphTokenSourceTests.FakeTokenClient((_, cancellation) => { - entered.Set(); - release.Wait(); lock (order) { - order.Add("acquire-complete"); + order.Add("acquire-entered"); } + entered.Set(); - return GraphTokenSourceTests.Result("token", InitialNow, InitialNow.AddHours(1)); + 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( - CertificateRequest(certificate, ownsMaterial: true), + OwnedRequest(mode, certificate, secret), client, clock.GetUtcNow, material => @@ -437,16 +483,56 @@ public async Task DisposalWaitsForActiveAcquisitionBeforeDisposingOwnedMaterial( material.Dispose(); }); - Task acquire = Task.Run(() => - source.Acquire(false, CancellationToken.None)); - Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + Task? acquire = null; + Task? dispose = null; + try + { + acquire = Task.Run(() => + source.Acquire(false, CancellationToken.None)); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); - Task dispose = Task.Run(source.Dispose); - Assert.NotSame(dispose, await Task.WhenAny(dispose, Task.Delay(100))); - release.Set(); - await Task.WhenAll(acquire, dispose); + 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-complete", "material-disposed" }, order); + 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] diff --git a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 index d70ce5e..a3553e7 100644 --- a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 +++ b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 @@ -11,6 +11,7 @@ BeforeAll { if ($null -eq ('GraphKit.Tests.LifecycleBlockingHandler' -as [type])) { Add-Type -TypeDefinition @' using System; +using System.Collections.Concurrent; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -19,6 +20,7 @@ namespace GraphKit.Tests { public sealed class LifecycleBlockingHandler : HttpMessageHandler { + public const string ContractMarker = "GraphKit.Task7.LifecycleSenderFixture/1"; private int _disposeCount; private int _sendCount; @@ -27,6 +29,8 @@ namespace GraphKit.Tests 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, @@ -35,8 +39,15 @@ namespace GraphKit.Tests Interlocked.Increment(ref _sendCount); SeenToken = cancellationToken; Started.TrySetResult(true); - await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); - throw new InvalidOperationException("The blocking test handler resumed without cancellation."); + 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) @@ -48,9 +59,54 @@ namespace GraphKit.Tests 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.Task7.LifecycleSenderFixture/1') { + throw ( + 'The process-global lifecycle sender fixture is stale. ' + + 'Run this file in a fresh PowerShell process.' + ) + } } Describe 'Send-GraphHttpRequest module lifecycle adapter' { @@ -104,6 +160,26 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { } $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 $stateKey = 'GraphKitTest.SenderState.' + [guid]::NewGuid().ToString('N') $clientKey = 'GraphKitTest.SenderClient.' + [guid]::NewGuid().ToString('N') [System.AppDomain]::CurrentDomain.SetData($stateKey, $state) @@ -151,23 +227,36 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { throw 'Stop-GraphModule did not cancel and drain the in-flight sender within five seconds.' } - $null = $stopJob | Receive-Job -Wait -ErrorAction Stop - $result = $sendJob | Receive-Job -Wait -ErrorAction Stop + $sendCompleted = @($sendJob | Wait-Job -Timeout 10) + $sendCompleted.Count | Should -Be 1 + $null = $stopJob | Receive-Job -ErrorAction Stop + $result = $sendJob | Receive-Job -ErrorAction Stop $handler.SendCount | Should -Be 1 $handler.SeenToken.IsCancellationRequested | Should -BeTrue + $handler.Exited.Task.IsCompleted | Should -BeTrue $result.TransportException | Should -Not -BeNullOrEmpty + $state.CleanupDone.Wait(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 $sendJob) { + $null = @($sendJob | Wait-Job -Timeout 10) $sendJob | Remove-Job -Force -ErrorAction SilentlyContinue } if ($null -ne $stopJob) { + $null = @($stopJob | Wait-Job -Timeout 10) $stopJob | Remove-Job -Force -ErrorAction SilentlyContinue } [System.AppDomain]::CurrentDomain.SetData($stateKey, $null) diff --git a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 new file mode 100644 index 0000000..9cae885 --- /dev/null +++ b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 @@ -0,0 +1,1567 @@ +BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $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)" + } + $null = [Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($fixtureAssembly) + } + $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() + if (-not $holder.Go.Wait(5000)) { + throw 'Task 7 fixed-bearer child did not receive the parent release gate.' + } + + $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.CleanupDone.Wait(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() + if (-not $holder.Go.Wait(5000)) { + throw 'Task 7 controlled child did not receive the parent release gate.' + } + $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.CleanupDone.Wait(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() + if (-not $holder.Go.Wait(5000)) { + throw 'Task 7 legacy child did not receive the parent release gate.' + } + $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.CleanupDone.Wait(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' { + $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(5000) | 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(5000) | 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(5000) | 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(5000) | 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(5000) | 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.CleanupDone.Wait(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 6e800d8..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,7 +34,7 @@ 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, $ForceRefreshFlags) + 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 @@ -43,7 +43,6 @@ BeforeAll { $app = [pscustomobject] @{ Tenant = $Tenant Counter = $Counter - DelayMs = $DelayMs ForceRefreshFlags = $ForceRefreshFlags } $app | Add-Member -MemberType ScriptMethod -Name AcquireTokenForClient -Value { @@ -51,7 +50,6 @@ BeforeAll { $builder = [pscustomobject] @{ Tenant = $this.Tenant Counter = $this.Counter - DelayMs = $this.DelayMs ForceRefreshFlags = $this.ForceRefreshFlags } $builder | Add-Member -MemberType ScriptMethod -Name WithForceRefresh -Value { @@ -62,8 +60,6 @@ BeforeAll { $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) @@ -89,12 +85,11 @@ BeforeAll { param( [string] $Tenant, $Counter, - [int] $DelayMs = 0, $ForceRefreshFlags = ([System.Collections.Concurrent.ConcurrentQueue[bool]]::new()) ) - InModuleScope GraphKit -Parameters @{ T = $Tenant; C = $Counter; D = $DelayMs; Q = $ForceRefreshFlags; F = $script:SourceFactoryScript } { - param($T, $C, $D, $Q, $F) - & $F $T $C $D $Q + InModuleScope GraphKit -Parameters @{ T = $Tenant; C = $Counter; Q = $ForceRefreshFlags; F = $script:SourceFactoryScript } { + param($T, $C, $Q, $F) + & $F $T $C $Q } } 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/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index 3e3f0a6..9b5b008 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -2751,6 +2751,29 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { 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 + } } } diff --git a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 new file mode 100644 index 0000000..21b3398 --- /dev/null +++ b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 @@ -0,0 +1,1346 @@ +function global:Get-Task7JsonProperty { + param( + [Parameter(Mandatory)] [System.Text.Json.JsonElement] $Element, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Location + ) + + $matches = @($Element.EnumerateObject() | Where-Object Name -CEQ $Name) + if ($matches.Count -ne 1) { + throw [System.IO.InvalidDataException]::new("$Location must contain exactly one '$Name' property.") + } + return $matches[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." + ) + } + + $input = Get-Task7JsonProperty -Element $row -Name input -Location "row '$id'" + Assert-Task7NoDuplicateJsonProperties -Element $input -Location "row '$id'.input" + Assert-Task7ExactJsonFields -Element $input -Expected $inputFields ` + -Location "row '$id'.input" + foreach ($name in @('tokens', 'expiresOnUtc')) { + $value = Get-Task7JsonProperty -Element $input -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 $input -Name forceFlags ` + -Location "row '$id'.input" + Assert-Task7JsonArrayItems -Element $inputFlags -Allowed @('True', 'False') ` + -Location "row '$id'.input.forceFlags" + $cancel = Get-Task7JsonProperty -Element $input -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 $input -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 $input -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') + $jobs = @() + $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 { + $jobs = @( + 1..4 | ForEach-Object { + Start-ThreadJob -ScriptBlock { + param($Manifest, $Key) + $module = $null + $state = $null + $outcome = $null + try { + $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop + $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.CleanupDone.Wait(5000) + $module = $null + $state = $null + } + $outcome | Add-Member NoteProperty ChildCleanup $cleaned + return $outcome + } -ArgumentList $script:BuiltManifest, $key + } + ) + [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() + $completed = @($jobs | Wait-Job -Timeout 10) + $completed.Count | Should -Be 4 + $outcomes = @($jobs | Receive-Job) + $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 ($job in $jobs) { + Remove-Job $job -Force -ErrorAction SilentlyContinue + } + } + } + + 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/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index c4c17f6..e497904 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -181,19 +181,43 @@ 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); - Thread.Sleep(400); + _entered.Set(); + _release.Wait(); return new ConcurrentConfidentialApplication(); } } @@ -233,6 +257,22 @@ namespace GraphKit.Tests '@ } + $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 @@ -250,6 +290,75 @@ namespace GraphKit.Tests ) } } + + 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' { @@ -359,8 +468,7 @@ Describe 'GraphTokenSource' { $go.Set() $completed = @(Wait-Job -Job $jobs -Timeout 10) $completed.Count | Should -Be 2 -Because 'cross-runspace containment must fail, never hang' - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) - $jobs = $null + $results = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 2 [GraphKit.Tests.ConcurrentApplicationHarness]::FactoryCalls | Should -Be 0 $results.Count | Should -Be 2 @@ -369,9 +477,11 @@ Describe 'GraphTokenSource' { Should -Be 0 } finally { + [GraphKit.Tests.ConcurrentApplicationHarness]::Release() $go.Set() if ($null -ne $jobs) { - $jobs | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + $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) @@ -418,11 +528,14 @@ Describe 'GraphTokenSource' { $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 -Wait + $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) @@ -432,6 +545,7 @@ Describe 'GraphTokenSource' { } } finally { + [GraphKit.Tests.ConcurrentApplicationHarness]::Release() $null = $seed.Flight.Completion.TrySetResult($null) InModuleScope GraphKit -Parameters @{ FlightKey = $seed.Key; Flight = $seed.Flight } { param($FlightKey, $Flight) @@ -443,6 +557,7 @@ Describe 'GraphTokenSource' { } } if ($null -ne $job) { + $null = @($job | Wait-Job -Timeout 10) $job | Remove-Job -Force -ErrorAction SilentlyContinue } } @@ -922,6 +1037,7 @@ Describe 'GraphTokenSource' { $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']) { @@ -1076,11 +1192,16 @@ Describe 'GraphTokenSource' { 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 { @@ -1094,35 +1215,67 @@ Describe 'GraphTokenSource' { $null = $go.Wait() & (Get-Module GraphKit) { Invoke-GraphTokenSingleFlight -Key $key -AcquireScript { - Start-Sleep -Milliseconds 400 + $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() } } } } -ArgumentList $key, $script:BuiltManifest } - $null = $ready.Wait(15000) + $ready.Wait(15000) | Should -BeTrue $go.Set() - - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $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 = 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 { @@ -1130,13 +1283,21 @@ 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' } + Invoke-GraphTokenSingleFlight -Key $key -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' + } } 'ok' } @@ -1146,19 +1307,40 @@ Describe 'GraphTokenSource' { } -ArgumentList $key, $script:BuiltManifest } - $null = $ready.Wait(15000) + $ready.Wait(15000) | Should -BeTrue $go.Set() - - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $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 | 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 | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + $jobs | Remove-Job -Force -ErrorAction SilentlyContinue } + $ready.Dispose() + $go.Dispose() + $entered.Dispose() + $release.Dispose() } } @@ -1167,9 +1349,13 @@ Describe 'GraphTokenSource' { $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 { @@ -1189,8 +1375,11 @@ Describe 'GraphTokenSource' { -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') - Start-Sleep -Milliseconds 800 + $entered.Set() + $null = $release.Wait() throw [System.OperationCanceledException]::new('provider timed out internally') } } $Key @@ -1204,22 +1393,38 @@ Describe 'GraphTokenSource' { $ready.Wait(15000) | Should -BeTrue $go.Set() - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) - $jobs = $null + $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 | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + $jobs | Remove-Job -Force -ErrorAction SilentlyContinue } $ready.Dispose() $go.Dispose() + $entered.Dispose() + $release.Dispose() } } @@ -1302,13 +1507,15 @@ Describe 'GraphTokenSource' { $waitersReady.Wait(15000) | Should -BeTrue $waitersGo.Set() - Start-Sleep -Milliseconds 200 + $oldFollowersObserved = Wait-Task7OuterFollowerCount -Key $key -ExpectedCount 7 + $oldStateBeforeCancel = Get-Task7OuterFlightState -Key $key $leaderCts.Cancel() $replacementStarted.Wait(15000) | Should -BeTrue - $leaderResult = @($leaderJob | Receive-Job -Wait) - Remove-Job -Job $leaderJob -Force -ErrorAction SilentlyContinue - $leaderJob = $null + $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 @@ -1322,13 +1529,18 @@ Describe 'GraphTokenSource' { } $releaseReplacement.Set() - $waiterResults = @($waiterJobs | Receive-Job -Wait) - Remove-Job -Job $waiterJobs -Force -ErrorAction SilentlyContinue - $waiterJobs = $null + $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 @@ -1346,11 +1558,13 @@ Describe 'GraphTokenSource' { [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 -and $leaderJob.State -ne 'Completed') { + if ($null -ne $leaderJob) { + $null = @($leaderJob | Wait-Job -Timeout 10) $leaderJob | Remove-Job -Force -ErrorAction SilentlyContinue } if ($null -ne $waiterJobs) { - $waiterJobs | Where-Object { $_.State -ne 'Completed' } | Remove-Job -Force -ErrorAction SilentlyContinue + $null = @($waiterJobs | Wait-Job -Timeout 10) + $waiterJobs | Remove-Job -Force -ErrorAction SilentlyContinue } $leaderCts.Dispose() $leaderStarted.Dispose() @@ -1366,9 +1580,13 @@ Describe 'GraphTokenSource' { $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 { @@ -1385,8 +1603,11 @@ Describe 'GraphTokenSource' { 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') - Start-Sleep -Milliseconds 400 + $entered.Set() + $null = $release.Wait() return @{ Token = 'runtime-single-flight-token' ExpiresOnUtc = [System.DateTimeOffset]::UtcNow.AddHours(1) @@ -1417,14 +1638,26 @@ Describe 'GraphTokenSource' { } -ArgumentList $key, $script:BuiltManifest } - $null = $ready.Wait(15000) + $ready.Wait(15000) | Should -BeTrue $go.Set() - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) + $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 @@ -1432,14 +1665,23 @@ Describe 'GraphTokenSource' { } } 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() } } } @@ -1823,9 +2065,13 @@ Describe 'GraphTokenSource' { $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() $ready = [System.Threading.CountdownEvent]::new(6) $go = [System.Threading.ManualResetEventSlim]::new($false) + $entered = [System.Threading.CountdownEvent]::new(2) + $release = [System.Threading.ManualResetEventSlim]::new($false) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeCalls', $calls) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeReady', $ready) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeGo', $go) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeEntered', $entered) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeRelease', $release) $jobs = $null try { @@ -1846,8 +2092,11 @@ Describe 'GraphTokenSource' { -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) - Start-Sleep -Milliseconds 500 + $null = $entered.Signal() + $null = $release.Wait() $mode }.GetNewClosure() } $Key $Force @@ -1856,23 +2105,58 @@ Describe 'GraphTokenSource' { $ready.Wait(15000) | Should -BeTrue $go.Set() - $results = @($jobs | Receive-Job -Wait -AutoRemoveJob) - $jobs = $null + $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 = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 6 @($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() + $go.Set() + if ($null -ne $jobs) { + $null = @($jobs | Wait-Job -Timeout 10) + } [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeCalls', $null) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeReady', $null) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeGo', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeEntered', $null) + [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeRelease', $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() } } } diff --git a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 index d828e22..7a79400 100644 --- a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +++ b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 @@ -19,6 +19,7 @@ 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); @@ -115,6 +116,7 @@ namespace GraphKit.Tests private readonly string _name; private readonly ConcurrentQueue _order; private readonly bool _throws; + private int _disposeCount; public OrderedDisposable(string name, ConcurrentQueue order, bool throws) { @@ -123,8 +125,11 @@ namespace GraphKit.Tests _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); } @@ -132,6 +137,21 @@ namespace GraphKit.Tests } '@ } + $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' { @@ -196,7 +216,9 @@ Describe 'GraphKit module lifecycle' { Exit-GraphModuleOperation -State $State } - $null = $stopJob | Receive-Job -Wait -ErrorAction Stop + $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' } @@ -413,7 +435,9 @@ Describe 'GraphKit module lifecycle' { $completedBeforeRelease = $null -ne ($stopJob | Wait-Job -Timeout 1) $owned.Release.Set() - $null = $stopJob | Receive-Job -Wait -ErrorAction Stop + $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.CleanupDone.Wait(5000) | Should -BeTrue @@ -433,40 +457,52 @@ Describe 'GraphKit module lifecycle' { } } - It 'disposes owned resources in LIFO order and reports an observed disposal failure' { + 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() - $first = [GraphKit.Tests.OrderedDisposable]::new('first', $order, $false) - $second = [GraphKit.Tests.OrderedDisposable]::new('second', $order, $true) - $third = [GraphKit.Tests.OrderedDisposable]::new('third', $order, $false) + $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 - First = $first - Second = $second - Third = $third + HostProbe = $hostProbe + Source1Probe = $source1Probe + Source2Probe = $source2Probe Injected = $injected } { - param($State, $First, $Second, $Third, $Injected) - $null = Register-GraphModuleOwnedResource -State $State -Resource $First -OwnedByGraphKit:$true - $null = Register-GraphModuleOwnedResource -State $State -Resource $Second -OwnedByGraphKit:$true - $null = Register-GraphModuleOwnedResource -State $State -Resource $Third -OwnedByGraphKit:$true + 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-second*' + } | Should -Throw -ExceptionType ([System.AggregateException]) -ExpectedMessage '*dispose-failed-source1*' $state.CleanupDone.IsSet | Should -BeTrue $state.CleanupComplete | Should -BeTrue - @($order.ToArray()) | Should -Be @('third', 'second', 'first') + $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 } @@ -561,39 +597,69 @@ Describe 'GraphKit module lifecycle' { $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop $result = & $module { $before = @($script:GraphKitModuleLifecycle.OwnedResources) - $source = New-GraphAuthTokenSource -Profile @{ + $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) - ResourceTypes = @($script:GraphKitModuleLifecycle.OwnedResources | ForEach-Object { $_.GetType().FullName }) - Source = $source + 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 - $rejected = $false - try { - $null = $result.Source.Acquire($false, [Threading.CancellationToken]::None) - } - catch [ObjectDisposedException] { - $rejected = $true + $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 - SourceRejectedAfterRemoval = $rejected + SourceRejectedCount = $rejectedCount + CleanupObserved = $result.State.CleanupDone.Wait(5000) + CleanupComplete = $result.State.CleanupComplete + ActiveOperations = $result.State.ActiveOperations + OwnedResourceCount = $result.State.OwnedResources.Count + FailureCount = @($result.State.GetFailures()).Count } } -ArgumentList $script:BuiltManifest @@ -604,11 +670,18 @@ Describe 'GraphKit module lifecycle' { $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].SourceRejectedAfterRemoval | Should -BeTrue + $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 From 0fcc70721807c45c712078c74d0a2c130071ec2e Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 1 Sep 2026 15:48:10 -0400 Subject: [PATCH 27/79] feat: enforce Task 8 auth and proof boundaries --- .../plans/2026-08-30-r8-graphkit-auth.md | 194 +++++- .../AndroidEnrollmentProfile.List.psd1 | 2 +- ...gedStoreAccountEnterpriseSettings.Get.psd1 | 2 +- .../AppConfigurationPolicy.List.psd1 | 2 +- .../AppInstallSummaryReport.Get.psd1 | 2 +- .../Operations/AppProtectionPolicy.List.psd1 | 2 +- .../AppleEnrollmentProgramToken.List.psd1 | 2 +- .../ApplePushNotificationCertificate.Get.psd1 | 2 +- .../Data/Operations/AppleVppToken.List.psd1 | 2 +- .../AuthenticationMethodsPolicy.Get.psd1 | 2 +- .../Operations/AuthorizationPolicy.Get.psd1 | 2 +- .../Data/Operations/AutopilotDevice.List.psd1 | 2 +- .../Operations/CertificateConnector.List.psd1 | 2 +- .../ConditionalAccessPolicy.List.psd1 | 2 +- .../ConfigurationConflict.List.psd1 | 2 +- .../ConfigurationPolicy.AssignBeta.psd1 | 2 +- .../ConfigurationPolicy.ListBeta.psd1 | 2 +- ...onfigurationPolicyAssignment.ListBeta.psd1 | 2 +- .../ConfigurationPolicySetting.ListBeta.psd1 | 2 +- ...nfigurationSettingDefinition.ListBeta.psd1 | 2 +- .../CrossTenantAccessPolicy.GetDefault.psd1 | 2 +- .../Data/Operations/DeviceCategory.List.psd1 | 2 +- .../Operations/DeviceCategory.ListBeta.psd1 | 2 +- .../DeviceCompliancePolicy.Assign.psd1 | 2 +- .../DeviceCompliancePolicy.List.psd1 | 2 +- .../DeviceCompliancePolicy.ListBeta.psd1 | 2 +- ...DeviceCompliancePolicyAssignment.List.psd1 | 2 +- .../DeviceConfiguration.Assign.psd1 | 2 +- .../Operations/DeviceConfiguration.List.psd1 | 2 +- .../DeviceConfiguration.ListBeta.psd1 | 2 +- .../DeviceConfigurationAssignment.List.psd1 | 2 +- .../DeviceEnrollmentConfiguration.List.psd1 | 2 +- ...eviceEnrollmentConfiguration.ListBeta.psd1 | 2 +- ...tConfigurationPolicyTemplate.ListBeta.psd1 | 2 +- .../DeviceManagementIntent.ListBeta.psd1 | 2 +- .../DeviceManagementRoleAssignment.List.psd1 | 2 +- .../DeviceManagementRoleDefinition.List.psd1 | 2 +- .../DeviceManagementScript.List.psd1 | 2 +- .../DeviceManagementTemplate.ListBeta.psd1 | 2 +- ...agementUnifiedRoleAssignment.ListBeta.psd1 | 2 +- .../Data/Operations/DeviceReport.Export.psd1 | 2 +- .../DirectoryRoleAssignment.List.psd1 | 2 +- .../DirectoryRoleDefinition.List.psd1 | 2 +- .../DirectoryRoleDefinition.ListBeta.psd1 | 2 +- .../Operations/DirectorySetting.List.psd1 | 2 +- .../DirectorySettingTemplate.List.psd1 | 2 +- source/Data/Operations/Domain.List.psd1 | 2 +- .../Data/Operations/DomainConnector.List.psd1 | 2 +- source/Data/Operations/EntraDevice.List.psd1 | 2 +- .../Data/Operations/EntraDevice.ListBeta.psd1 | 2 +- source/Data/Operations/Group.Get.psd1 | 2 +- source/Data/Operations/Group.List.psd1 | 2 +- source/Data/Operations/Group.ListBeta.psd1 | 2 +- source/Data/Operations/GroupMember.List.psd1 | 2 +- .../GroupPolicyConfiguration.ListBeta.psd1 | 2 +- .../GroupPolicyDefinitionValue.ListBeta.psd1 | 2 +- ...GroupPolicyPresentationValue.ListBeta.psd1 | 2 +- .../IntuneBrandingProfile.List.psd1 | 2 +- .../Data/Operations/ManagedDevice.Delete.psd1 | 2 +- source/Data/Operations/ManagedDevice.Get.psd1 | 2 +- .../Data/Operations/ManagedDevice.List.psd1 | 2 +- .../Operations/ManagedDevice.ListBeta.psd1 | 2 +- .../Data/Operations/ManagedDevice.Retire.psd1 | 2 +- .../Operations/ManagedDevice.SyncDevice.psd1 | 2 +- .../Data/Operations/ManagedDevice.Wipe.psd1 | 2 +- .../ManagedDeviceCleanupRule.ListBeta.psd1 | 2 +- .../Operations/ManagedDeviceSetting.Get.psd1 | 2 +- source/Data/Operations/MobileApp.Assign.psd1 | 2 +- source/Data/Operations/MobileApp.List.psd1 | 2 +- .../Data/Operations/MobileApp.ListBeta.psd1 | 2 +- .../Operations/MobileAppAssignment.List.psd1 | 2 +- .../Operations/MobileAppCategory.List.psd1 | 2 +- .../MobileThreatDefenseConnector.List.psd1 | 2 +- .../Data/Operations/NamedLocation.List.psd1 | 2 +- .../OperationApprovalPolicy.List.psd1 | 2 +- .../Organization.GetMdmAuthority.psd1 | 2 +- source/Data/Operations/Organization.List.psd1 | 2 +- .../Operations/Organization.ListBeta.psd1 | 2 +- .../RoleAssignmentScheduleInstance.List.psd1 | 2 +- .../RoleEligibilityScheduleInstance.List.psd1 | 2 +- .../SecurityDefaultsPolicy.Get.psd1 | 2 +- .../Operations/ServicePrincipal.List.psd1 | 2 +- .../Data/Operations/SubscribedSku.List.psd1 | 2 +- source/Data/Operations/User.List.psd1 | 2 +- ...indowsAutopilotDeploymentProfile.List.psd1 | 2 +- .../WindowsFeatureUpdateProfile.List.psd1 | 2 +- .../WindowsUpdateCatalogItem.List.psd1 | 2 +- source/Private/Confirm-GraphTenantBinding.ps1 | 112 +++- source/Private/Invoke-GraphPaging.ps1 | 172 +++++- source/Private/Invoke-GraphRetry.ps1 | 187 +++++- .../Assert-GraphOperationAuthMode.ps1 | 41 ++ .../Import-GraphOperationDescriptor.ps1 | 31 + .../Transport/Send-GraphHttpRequest.ps1 | 268 ++++++++- source/Private/Wait-GraphThrottleGate.ps1 | 151 ++++- source/Public/Get-GraphObject.ps1 | 27 +- source/Public/Invoke-GraphBatch.ps1 | 20 +- source/Public/Invoke-GraphOperation.ps1 | 26 +- tests/Adapter/TokenIdentityPipeline.Tests.ps1 | 228 ++++++- .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 561 +++++++++++++++++- .../Operations/DescriptorInvariants.Tests.ps1 | 11 +- .../Unit/Operations/Get-GraphObject.Tests.ps1 | 130 +++- .../Import-GraphOperationDescriptor.Tests.ps1 | 68 +++ .../Unit/Pipeline/Invoke-GraphBatch.Tests.ps1 | 104 +++- .../Pipeline/Invoke-GraphOperation.Tests.ps1 | 103 ++++ .../Pipeline/Invoke-GraphPaging.Tests.ps1 | 366 +++++++++++- tests/Unit/Throttle/ThrottleGate.Tests.ps1 | 373 ++++++++++++ .../Transport/Invoke-GraphRetry.Tests.ps1 | 223 ++++++- 107 files changed, 3338 insertions(+), 230 deletions(-) create mode 100644 source/Private/Operations/Assert-GraphOperationAuthMode.ps1 diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md index 68049b6..230244f 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -979,7 +979,173 @@ service-behavior claim. - Create: `scripts/Invoke-GraphKitAuthParity.ps1` - Create: `tests/QA/GraphKitAuthLiveParity.tests.ps1` -- Modify only the R8 evidence ledger/spec after observed results. +- 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. + +- [ ] **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 +``` - [ ] **Step 1: Write and test a digest-bound protected runner** @@ -989,27 +1155,39 @@ managed-identity, and fixed-bearer routing without reading a credential, calling permission, or creating Azure resources. Real mode emits only redacted counts, auth mode, adapter diagnostics, package digest, and success/failure state. -- [ ] **Step 2: Pack/test and freeze the pre-cutover artifact** +- [ ] **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 `scripts/Invoke-GraphKitAuthParity.ps1` and +`tests/QA/GraphKitAuthLiveParity.tests.ps1`. No observed-evidence file belongs in either commit. + +- [ ] **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. Record the exact prerelease and digest; do not rebuild between -live modes. +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 3: Run approved Ivy24 parity** +- [ ] **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 4: Provision a fresh managed-identity host only with explicit authority** +- [ ] **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 5: Commit only the tested runner and redacted observed evidence** +- [ ] **Step 6: Commit only redacted observed evidence without rebuilding** -Do not proceed to dependency removal until all four applicable protected-live parity modes pass. +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 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..8bd3b54 100644 --- a/source/Data/Operations/Group.Get.psd1 +++ b/source/Data/Operations/Group.Get.psd1 @@ -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/Private/Confirm-GraphTenantBinding.ps1 b/source/Private/Confirm-GraphTenantBinding.ps1 index 17c349d..eb7d666 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,30 @@ 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 @@ -108,12 +140,32 @@ function Confirm-GraphTenantBinding { [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 @@ -124,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) { @@ -134,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('/')) @@ -142,7 +202,7 @@ function Confirm-GraphTenantBinding { ReplayPolicy = 'Safe' ThrottleClass = 'Read' ResourceFamily = 'Graph.Directory' - IdentityRequirement = 'Verified' + IdentityRequirement = 'AllowUnverifiedRead' ApiVersion = 'v1.0' Condition = $null Reconciliation = $null @@ -151,31 +211,61 @@ function Confirm-GraphTenantBinding { $transport = $ProofTransport if ($null -eq $transport) { $transport = { - param($Context, $Descriptor, $Uri, $CancellationToken) + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) + $deadlineSeconds = [int] [Math]::Ceiling(([TimeSpan] $RemainingDeadline).TotalSeconds) + if ($deadlineSeconds -lt 1) { + throw (New-GraphTenantBindingDeadlineException) + } + $deadlineSeconds = [Math]::Min(86400, $deadlineSeconds) Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri $Uri -Method GET ` - -Headers @{} -Body $null -CancellationToken $CancellationToken + -Headers @{} -Body $null -CancellationToken $CancellationToken ` + -DeadlineSeconds $deadlineSeconds } } # 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. + $proofCloud = if ($null -ne $Context.PSObject.Properties['Cloud']) { + [string] $Context.Cloud + } + else { + 'TenantProof' + } + $proofClientId = if ($null -ne $Context.PSObject.Properties['ClientId']) { + $Context.ClientId + } + else { + $null + } $proofContext = [pscustomobject] @{ ProfileId = 'tenant-proof' TenantId = $targetTenant - Cloud = 'TenantProof' + Cloud = $proofCloud GraphBaseUri = $Context.GraphBaseUri - ClientId = $null + ClientId = $proofClientId TokenSource = New-GraphPinnedTokenSource -TokenResult $TokenResult - CredentialFingerprint = [string] $TokenResult.TokenFingerprint + CredentialFingerprint = $fingerprint AcquisitionCacheKey = "tenant-proof|$cacheKey" IdentityState = 'NotAcquired' } $envelope = & $transport -Context $proofContext -Descriptor $proofDescriptor -Uri $proofUri ` - -CancellationToken $CancellationToken + -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/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 e82f374..843e03c 100644 --- a/source/Private/Invoke-GraphRetry.ps1 +++ b/source/Private/Invoke-GraphRetry.ps1 @@ -45,7 +45,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 +137,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,7 +164,24 @@ 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 } } # ---- Deadline: monotonic Stopwatch plus the injected (virtual) clock ---- @@ -175,6 +192,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 +212,8 @@ function Invoke-GraphRetry { $certaintyFinal = 'Known' $data = @() $verifiedTenantId = $null + $verifiedTokenFingerprint = $null + $verifiedCredentialGeneration = $null $lastAttemptCertainty = $null for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { @@ -204,7 +230,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 @@ -291,8 +359,25 @@ function Invoke-GraphRetry { $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 + } } } @@ -305,9 +390,19 @@ function Invoke-GraphRetry { } $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 ---- @@ -333,19 +428,32 @@ function Invoke-GraphRetry { # because the caller token happened to be signalled at the same time. $candidate = $sendFailure $isCancellationFailure = $false + $isTenantBindingDeadline = $false while ($null -ne $candidate) { if ($candidate -is [System.OperationCanceledException]) { $isCancellationFailure = $true - break + } + if ($candidate -is [System.TimeoutException] -and + $candidate.Data['GraphKit.TenantBindingDeadlineExpired'] -eq $true) { + $isTenantBindingDeadline = $true } $candidate = $candidate.InnerException } - if ($CancellationToken.IsCancellationRequested -and $isCancellationFailure) { + # 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 ($CancellationToken.IsCancellationRequested -and + ($isCancellationFailure -or $isTenantBindingDeadline)) { $outcome = 'Cancelled' $certaintyFinal = 'Indeterminate' break } + if ($isTenantBindingDeadline) { + $outcome = 'DeadlineExpired' + $certaintyFinal = 'Indeterminate' + break + } throw } @@ -433,7 +541,63 @@ 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 { + & $delay $boundedDelaySeconds -CancellationToken $CancellationToken + } + 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 } @@ -442,6 +606,8 @@ function Invoke-GraphRetry { # 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') { @@ -464,6 +630,9 @@ function Invoke-GraphRetry { RetrievedUtc = (& $utcNow) 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 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/Transport/Send-GraphHttpRequest.ps1 b/source/Private/Transport/Send-GraphHttpRequest.ps1 index a21a65a..fd378d0 100644 --- a/source/Private/Transport/Send-GraphHttpRequest.ps1 +++ b/source/Private/Transport/Send-GraphHttpRequest.ps1 @@ -169,6 +169,11 @@ function Send-GraphHttpRequest { [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, @@ -179,8 +184,19 @@ function Send-GraphHttpRequest { $leaseAcquired = $false $lifetimeCts = $null $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 @@ -191,6 +207,66 @@ function Send-GraphHttpRequest { ) $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) @@ -304,24 +380,35 @@ function Send-GraphHttpRequest { # 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. - 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, $effectiveCancellationToken) + 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() + } } - else { - $sourceForAcquire = $TokenSource - $forceForAcquire = $ForceRefresh - $cancellationForAcquire = $effectiveCancellationToken - $flightKey = Get-GraphTokenFlightKey ` - -AcquisitionKey $TokenAcquisitionKey ` - -ForceRefresh:$ForceRefresh - $tokenResult = Invoke-GraphTokenSingleFlight ` - -Key $flightKey ` - -CancellationToken $effectiveCancellationToken ` - -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.' @@ -349,6 +436,18 @@ function Send-GraphHttpRequest { ) } 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 @@ -367,26 +466,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, $CancellationToken) + param($Context, $TokenResult, $CancellationToken, $RemainingDeadline) Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` - -CancellationToken $CancellationToken + -CancellationToken $CancellationToken -RemainingDeadline $RemainingDeadline } } - & $prover -Context $proofContext -TokenResult $tokenResult -CancellationToken $effectiveCancellationToken + 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 + } + } + + # 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 @@ -417,6 +550,18 @@ function Send-GraphHttpRequest { $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 -State $LifecycleState ` -ConnectTimeoutSeconds $TimeoutConnectionSeconds ` @@ -424,11 +569,20 @@ function Send-GraphHttpRequest { # 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($effectiveCancellationToken) + $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, @@ -462,8 +616,57 @@ 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 cancellation, and completion can race + # the deadline signal. Success is authoritative only while the inherited + # operation budget still remains after the entire response body is read. + if ($VerifyTenantBinding) { + $effectiveCancellationToken.ThrowIfCancellationRequested() + $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 + } + + # Cancellation observed at either final boundary is operation control, not + # a transport result. Let Invoke-GraphRetry preserve its Cancelled envelope + # and release admission; normalizing this OCE could turn a completed 2xx + # response into a false success. + if ($isCancellationFailure -and $effectiveCancellationToken.IsCancellationRequested) { + throw + } + if ($isTenantBindingDeadline) { + 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 @@ -503,13 +706,20 @@ function Send-GraphHttpRequest { } finally { try { - if ($null -ne $lifetimeCts) { - $lifetimeCts.Dispose() + if ($null -ne $tenantBindingDeadlineCts) { + $tenantBindingDeadlineCts.Dispose() } } finally { - if ($leaseAcquired) { - Exit-GraphModuleOperation -State $LifecycleState + 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..5e7c4dd 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,80 @@ 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. Injected delays retain the + # virtual-time seam and receive the same token; cancellation is checked again + # immediately after every injected step. + $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 { + & $Delay -Milliseconds $Milliseconds -CancellationToken $CancellationToken + $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 +131,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-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/tests/Adapter/TokenIdentityPipeline.Tests.ps1 b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 index dc4b6a9..02eecce 100644 --- a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 +++ b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 @@ -129,7 +129,8 @@ BeforeAll { function New-TokenPipelineContext { param( [uri] $Authority, - [object] $TokenSource + [object] $TokenSource, + [guid] $ClientId = [guid] '00000000-0000-0000-0000-000000000010' ) return [pscustomobject] @{ @@ -137,7 +138,7 @@ BeforeAll { TenantId = $script:TenantId Cloud = 'Global' GraphBaseUri = $Authority - ClientId = 'client-id' + ClientId = $ClientId TokenSource = $TokenSource CredentialFingerprint = 'credential-fingerprint' AcquisitionCacheKey = 'token-identity-acquisition-key' @@ -148,10 +149,11 @@ BeforeAll { function New-TokenPipelineDescriptor { param( [string] $ReplayPolicy = 'Safe', - [string] $ThrottleClass = 'Read' + [string] $ThrottleClass = 'Read', + [string] $IdentityRequirement ) - return @{ + $descriptor = @{ CredentialPolicy = 'GraphBearer' ReplayPolicy = $ReplayPolicy ThrottleClass = $ThrottleClass @@ -160,6 +162,12 @@ BeforeAll { Condition = $null Reconciliation = $null } + + if ($PSBoundParameters.ContainsKey('IdentityRequirement')) { + $descriptor.IdentityRequirement = $IdentityRequirement + } + + return $descriptor } } @@ -206,6 +214,198 @@ Describe 'Composed retry and sender token identity' { $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' { + $port = Get-TokenPipelineFreePort + $server = Start-TokenPipelineServer -Port $port -Responses @( + @{ StatusCode = 200; Body = '{"value":[]}' } + ) + $authority = [uri] "http://127.0.0.1:$port" + $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' { + $port = Get-TokenPipelineFreePort + $authority = [uri] "http://127.0.0.1:$port" + $tokenSource = New-RotatingTokenSource + $script:deadlineProofEntered = 0 + $script:deadlineProofSawCancellation = $false + $script:deadlineOuterBudget = [TimeSpan]::Zero + + 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' + $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) + } + } + + $capture.Result.Outcome | Should -BeExactly 'DeadlineExpired' + $capture.Result.Certainty | Should -BeExactly 'Indeterminate' + $capture.InFlight | Should -Be 0 + $script:deadlineOuterBudget | Should -BeGreaterThan ([TimeSpan]::Zero) + $script:deadlineOuterBudget | 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' { + $port = Get-TokenPipelineFreePort + $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 + $script:cancelledVerifiedGetClock = [datetime] '2026-09-01T12:00:00Z' + $tokenSource | Add-Member -MemberType ScriptMethod -Name Acquire -Force -Value { + param([bool] $forceRefresh, $cancellationToken) + + $this.AcquireFlags.Add($forceRefresh) + $this.CancellationSource.Cancel() + $script:cancelledVerifiedGetClock = $script:cancelledVerifiedGetClock.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 { + param($Context, $Descriptor, $Authority, $CancellationToken) + + $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 = { $script:cancelledVerifiedGetClock } + 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' { $port = Get-TokenPipelineFreePort $server = Start-TokenPipelineServer -Port $port -Responses @( @@ -313,23 +513,18 @@ Describe 'Composed retry and sender token identity' { } try { - $message = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor -ReplayPolicy NeverReplay -ThrottleClass Write), $authority, $cts.Token { + $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor -ReplayPolicy NeverReplay -ThrottleClass Write), $authority, $cts.Token { param($Context, $Descriptor, $Authority, $CancellationToken) - try { - Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri ([uri]::new($Authority, 'mutation')) ` - -Method POST -Headers @{} -Body @{ value = 'x' } -CancellationToken $CancellationToken - return '' - } - catch { - return $_.Exception.Message - } + 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 - $message | Should -BeLike '*Tenant proof failed*' + $result.Outcome | Should -BeExactly 'Cancelled' + $result.Certainty | Should -BeExactly 'Indeterminate' } finally { $listener.Stop() @@ -364,5 +559,10 @@ Describe 'Composed retry and sender token identity' { $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/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index 321d295..8c88e7c 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -7,6 +7,78 @@ 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 + { + 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; + } + } + } +} +'@ + } + $script:TenantId = [guid] '00000000-0000-0000-0000-000000000001' $script:OtherTenantId = [guid] '00000000-0000-0000-0000-000000000002' @@ -18,7 +90,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 +127,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 +140,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) @@ -155,6 +234,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) + $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) + $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' { @@ -206,11 +352,14 @@ 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 = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor } return [pscustomobject] @{ Outcome = 'Succeeded' @@ -220,7 +369,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 +380,13 @@ 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 } It 'forwards the caller cancellation token into the proof retry pipeline' { @@ -249,20 +404,70 @@ Describe 'Confirm-GraphTenantBinding' { } } - $message = InModuleScope GraphKit -ArgumentList $cache, (New-TestContext), (New-TestTokenResult), $cts.Token { + $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 '' + return $null } catch { - return $_.Exception.Message + return $_.Exception } } $script:proofCancellationToken.IsCancellationRequested | Should -BeTrue - $message | Should -BeLike '*Tenant proof failed*' + $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 { 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() + } } } } @@ -401,5 +606,337 @@ 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 + $key = Get-GraphTenantBindingKey -Fingerprint $TokenResult.TokenFingerprint ` + -Generation $TokenResult.CredentialGeneration -TenantId $Context.TenantId + $script:GraphTenantBindingCache[$key] = $true + $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 '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 { + $failure = 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 { + 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 + return $null + } + catch { + return $_.Exception + } + } + finally { + $null = $script:GraphTenantBindingCache.Remove($key) + Stop-GraphModule -State $state + } + } + + $isCancellation = $false + $isDeadline = $false + $candidate = $failure + 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 + $isCancellation | Should -BeTrue + $isDeadline | Should -BeFalse + } + finally { + $client.Dispose() + $handler.Dispose() + $cts.Dispose() + } + } } } diff --git a/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 b/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 index cffca47..0686e8c 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 @@ -354,4 +364,3 @@ Describe 'The TenantPulse-unblocking reads keep their official paths' { } } - 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..6d373a2 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,105 @@ 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' { + Mock Invoke-GraphRetry -ModuleName GraphKit { return (New-FakeEnvelope) } + + $result = Invoke-GraphOperation -Context $script:Context -Uri 'https://graph.microsoft.com/v1.0/me' -Method GET + + $result.Outcome | Should -Be 'Succeeded' + 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 +202,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..44422b8 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,7 +76,30 @@ 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()] [string] $TokenFingerprint = 'paging-token-fingerprint', + [AllowNull()] [string] $CredentialGeneration = 'paging-credential-generation', + [string] $Cloud = 'Global' + ) + + return @{ + ProfileId = 'paging-verified' + TenantId = $TenantId + ActualTenantId = $ActualTenantId + IdentityState = $IdentityState + TokenFingerprint = $TokenFingerprint + CredentialGeneration = $CredentialGeneration + Cloud = $Cloud + ApiVersion = 'v1.0' + ResourceFamily = 'Intune.ManagedDevices' } } @@ -71,6 +107,7 @@ BeforeAll { $script:PageQueue.Clear() $script:RecordedHeaders.Clear() $script:RecordedUris.Clear() + $script:RecordedDeadlineSeconds.Clear() } } @@ -95,6 +132,326 @@ 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 + $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 + $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 +544,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/Throttle/ThrottleGate.Tests.ps1 b/tests/Unit/Throttle/ThrottleGate.Tests.ps1 index a7898c7..55239d2 100644 --- a/tests/Unit/Throttle/ThrottleGate.Tests.ps1 +++ b/tests/Unit/Throttle/ThrottleGate.Tests.ps1 @@ -155,6 +155,379 @@ 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 = 0 + + try { + $failure = $null + try { + $null = Wait-GraphThrottleGate -Scope $scope -Coordinator $coordinator ` + -UtcNow $UtcNow -CancellationToken $cts.Token ` + -Delay { param($Milliseconds) $delayCalls++ } + } + 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 + 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/Transport/Invoke-GraphRetry.Tests.ps1 b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 index 8506996..86f1f25 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,6 +88,7 @@ BeforeAll { ResourceFamily = $ResourceFamily Condition = $Condition Reconciliation = $null + IdentityRequirement = $IdentityRequirement } } @@ -119,12 +124,18 @@ BeforeAll { } 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 @@ -136,7 +147,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 @@ -158,6 +169,8 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $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' @@ -274,6 +287,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() @@ -426,6 +595,52 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $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' + } } } } From 1937cbd721be9bcfc717c5b200d185540d5aae8e Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 1 Sep 2026 16:00:02 -0400 Subject: [PATCH 28/79] test: isolate packaged proof harness scopes --- tests/Adapter/TokenIdentityPipeline.Tests.ps1 | 23 +++++++++++-------- .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 21 +++++++++++++---- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 index 02eecce..fbb7703 100644 --- a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 +++ b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 @@ -284,7 +284,6 @@ Describe 'Composed retry and sender token identity' { $tokenSource = New-RotatingTokenSource $script:deadlineProofEntered = 0 $script:deadlineProofSawCancellation = $false - $script:deadlineOuterBudget = [TimeSpan]::Zero Mock Confirm-GraphTenantBinding -ModuleName GraphKit { param($Context, $TokenResult, [System.Threading.CancellationToken] $CancellationToken, $RemainingDeadline) @@ -299,6 +298,7 @@ Describe 'Composed retry and sender token identity' { 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, @@ -328,16 +328,17 @@ Describe 'Composed retry and sender token identity' { Jitter = { 0.0 } } [pscustomobject] @{ - Result = $result - InFlight = (Get-GraphThrottleCoordinator).GetInFlight([string] $scope.LeafKey) + 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 - $script:deadlineOuterBudget | Should -BeGreaterThan ([TimeSpan]::Zero) - $script:deadlineOuterBudget | Should -BeLessOrEqual ([TimeSpan]::FromSeconds(5)) + $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 @@ -349,13 +350,14 @@ Describe 'Composed retry and sender token identity' { $cts = [System.Threading.CancellationTokenSource]::new() $tokenSource = New-RotatingTokenSource $tokenSource | Add-Member -MemberType NoteProperty -Name CancellationSource -Value $cts - $script:cancelledVerifiedGetClock = [datetime] '2026-09-01T12:00:00Z' + $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() - $script:cancelledVerifiedGetClock = $script:cancelledVerifiedGetClock.AddSeconds(5) + $this.ClockCapture.UtcNow = $this.ClockCapture.UtcNow.AddSeconds(5) return [pscustomobject] @{ AccessToken = 'cancelled-verified-get-token' ExpiresOnUtc = [System.DateTimeOffset]::UtcNow.AddHours(1) @@ -378,15 +380,16 @@ Describe 'Composed retry and sender token identity' { try { $capture = InModuleScope GraphKit -ArgumentList ` (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), ` - (New-TokenPipelineDescriptor -IdentityRequirement Verified), $authority, $cts.Token { - param($Context, $Descriptor, $Authority, $CancellationToken) + (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 = { $script:cancelledVerifiedGetClock } + UtcNow = $utcNow Delay = { param($Seconds) } Jitter = { 0.0 } } diff --git a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index 8c88e7c..e577c2d 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -359,7 +359,10 @@ Describe 'Confirm-GraphTenantBinding' { Descriptor = $Descriptor Context = $Context DeadlineSeconds = $DeadlineSeconds - Scope = New-GraphThrottleScope -Context $Context -Descriptor $Descriptor + Scope = & (Get-Module GraphKit) { + param($ProofContext, $ProofDescriptor) + New-GraphThrottleScope -Context $ProofContext -Descriptor $ProofDescriptor + } $Context $Descriptor } return [pscustomobject] @{ Outcome = 'Succeeded' @@ -392,12 +395,15 @@ Describe 'Confirm-GraphTenantBinding' { 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() - $cts.Cancel() + $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 @@ -416,6 +422,8 @@ Describe 'Confirm-GraphTenantBinding' { } } + $script:proofCancellationWasRequestedAtEntry | Should -BeFalse + $script:proofCancellationToken.Equals($cts.Token) | Should -BeTrue $script:proofCancellationToken.IsCancellationRequested | Should -BeTrue $failure | Should -Not -BeNullOrEmpty $isCancellation = $false @@ -733,9 +741,12 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { param($Context, $TokenResult) $capture.ProverCalls++ $TokenResult.VerifiedTenantId = [string] $Context.TenantId - $key = Get-GraphTenantBindingKey -Fingerprint $TokenResult.TokenFingerprint ` - -Generation $TokenResult.CredentialGeneration -TenantId $Context.TenantId - $script:GraphTenantBindingCache[$key] = $true + & (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 = { From c1dbec661a88d41800f492997af4da140f63e998 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 1 Sep 2026 16:31:28 -0400 Subject: [PATCH 29/79] fix: preserve cancellation and incomplete response semantics --- source/Private/Get-GraphRetryDecision.ps1 | 9 +- source/Private/Invoke-GraphRetry.ps1 | 57 +++++- .../Transport/GraphTransportResult.ps1 | 2 + .../Transport/Send-GraphHttpRequest.ps1 | 60 ++++-- .../GraphModuleLifecycleSender.Tests.ps1 | 178 +++++++++++++++++- tests/Adapter/LoopbackSender.Tests.ps1 | 2 + tests/Adapter/Send-GraphHttpRequest.Tests.ps1 | 4 + .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 106 +++++++++-- tests/Unit/Pipeline/WriteGate.Tests.ps1 | 7 +- .../Transport/Invoke-GraphRetry.Tests.ps1 | 158 ++++++++++++++++ 10 files changed, 539 insertions(+), 44 deletions(-) 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/Invoke-GraphRetry.ps1 b/source/Private/Invoke-GraphRetry.ps1 index 843e03c..43731e4 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' } @@ -384,6 +396,28 @@ function Invoke-GraphRetry { # ---- 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 @@ -408,7 +442,8 @@ function Invoke-GraphRetry { # ---- 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') } @@ -428,11 +463,16 @@ function Invoke-GraphRetry { # 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 @@ -443,8 +483,9 @@ function Invoke-GraphRetry { # 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 ($CancellationToken.IsCancellationRequested -and - ($isCancellationFailure -or $isTenantBindingDeadline)) { + if ($isOperationCancellation -or + ($CancellationToken.IsCancellationRequested -and + ($isCancellationFailure -or $isTenantBindingDeadline))) { $outcome = 'Cancelled' $certaintyFinal = 'Indeterminate' break diff --git a/source/Private/Transport/GraphTransportResult.ps1 b/source/Private/Transport/GraphTransportResult.ps1 index 4e439e3..09701fd 100644 --- a/source/Private/Transport/GraphTransportResult.ps1 +++ b/source/Private/Transport/GraphTransportResult.ps1 @@ -15,6 +15,8 @@ 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. diff --git a/source/Private/Transport/Send-GraphHttpRequest.ps1 b/source/Private/Transport/Send-GraphHttpRequest.ps1 index fd378d0..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 @@ -183,6 +185,7 @@ function Send-GraphHttpRequest { $leaseAcquired = $false $lifetimeCts = $null + $effectiveCancellationToken = [System.Threading.CancellationToken]::None $phaseCts = $null $tenantBindingDeadlineCts = $null $request = $null @@ -617,11 +620,14 @@ function Send-GraphHttpRequest { $result.Body = ConvertFrom-GraphResponseBody -Bytes $bodyBytes -Headers $result.Headers - # A handler is not trusted to honour cancellation, and completion can race - # the deadline signal. Success is authoritative only while the inherited - # operation budget still remains after the entire response body is read. + # 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) { - $effectiveCancellationToken.ThrowIfCancellationRequested() $remainingAfterBody = [TimeSpan] (& $getTenantBindingRemaining) if (($null -ne $tenantBindingDeadlineCts -and $tenantBindingDeadlineCts.IsCancellationRequested) -or $remainingAfterBody -le [TimeSpan]::Zero) { @@ -645,14 +651,12 @@ function Send-GraphHttpRequest { $candidate = $candidate.InnerException } - # Cancellation observed at either final boundary is operation control, not - # a transport result. Let Invoke-GraphRetry preserve its Cancelled envelope - # and release admission; normalizing this OCE could turn a completed 2xx - # response into a false success. - if ($isCancellationFailure -and $effectiveCancellationToken.IsCancellationRequested) { - throw - } - if ($isTenantBindingDeadline) { + # 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 } @@ -673,6 +677,9 @@ function Send-GraphHttpRequest { 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 @@ -683,6 +690,33 @@ function Send-GraphHttpRequest { } 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 + } + + 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 diff --git a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 index a3553e7..e8a79e3 100644 --- a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 +++ b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 @@ -12,7 +12,10 @@ BeforeAll { 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; @@ -20,7 +23,7 @@ namespace GraphKit.Tests { public sealed class LifecycleBlockingHandler : HttpMessageHandler { - public const string ContractMarker = "GraphKit.Task7.LifecycleSenderFixture/1"; + public const string ContractMarker = "GraphKit.Task8.LifecycleSenderFixture/2"; private int _disposeCount; private int _sendCount; @@ -60,6 +63,75 @@ namespace GraphKit.Tests } } + 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; @@ -101,7 +173,7 @@ namespace GraphKit.Tests } if ($null -eq $handlerMarker -or [string] $handlerMarker.GetRawConstantValue() -cne - 'GraphKit.Task7.LifecycleSenderFixture/1') { + 'GraphKit.Task8.LifecycleSenderFixture/2') { throw ( 'The process-global lifecycle sender fixture is stale. ' + 'Run this file in a fresh PowerShell process.' @@ -154,6 +226,107 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { } } + 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 @@ -236,6 +409,7 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { $handler.SeenToken.IsCancellationRequested | Should -BeTrue $handler.Exited.Task.IsCompleted | Should -BeTrue $result.TransportException | Should -Not -BeNullOrEmpty + $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue $state.CleanupDone.Wait(5000) | Should -BeTrue $state.ActiveOperations | Should -Be 0 $state.CleanupComplete | Should -BeTrue 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 da5caa6..ef0fd8b 100644 --- a/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 +++ b/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 @@ -402,6 +402,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' { @@ -422,6 +423,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' { @@ -448,6 +450,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' { @@ -473,6 +476,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/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index e577c2d..ea56211 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -873,6 +873,88 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { } } + 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() @@ -898,23 +980,17 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { }.GetNewClosure() try { - $failure = InModuleScope GraphKit -ArgumentList $authority, $tokenSource, $factory, $bindingContext, $script:TenantId, $cts.Token { + $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 { - 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 - return $null - } - catch { - return $_.Exception - } + 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) @@ -924,7 +1000,7 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { $isCancellation = $false $isDeadline = $false - $candidate = $failure + $candidate = $result.TransportException while ($null -ne $candidate) { if ($candidate -is [System.OperationCanceledException]) { $isCancellation = $true @@ -940,6 +1016,10 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { $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 } 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/Transport/Invoke-GraphRetry.Tests.ps1 b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 index 86f1f25..3cc2beb 100644 --- a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 +++ b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 @@ -220,6 +220,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)) @@ -269,6 +291,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' { @@ -485,6 +574,75 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $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' { From beceb2243dfab2ec95f34faab69f0b32419ed210 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 1 Sep 2026 18:05:30 -0400 Subject: [PATCH 30/79] test: add protected GraphKit Auth parity runner --- scripts/Invoke-GraphKitAuthParity.ps1 | 2152 +++++++++++++++ tests/QA/GraphKitAuthLiveParity.tests.ps1 | 2999 +++++++++++++++++++++ 2 files changed, 5151 insertions(+) create mode 100644 scripts/Invoke-GraphKitAuthParity.ps1 create mode 100644 tests/QA/GraphKitAuthLiveParity.tests.ps1 diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 new file mode 100644 index 0000000..3063620 --- /dev/null +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -0,0 +1,2152 @@ +<# + 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:GraphKitAuthParityNativeType = $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' + +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 ($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-GraphKitAuthParityAbiTypeDisplayName -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-GraphKitAuthParityAbiNullabilityDisplay -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-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 = @' +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 +'@ + $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() + $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 [string]$Evidence.OwnerSid -ceq $currentSid -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 }) +} + +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 + } +} + +$task8Hooks = if ($MyInvocation.InvocationName -ceq '.') { + Get-GraphKitAuthParityTestHooks +} +else { $null } +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 +$task8ImportedModule = $null +$task8Imported = $null +$task8Context = $null +$task8ContextCommand = $null +$task8ContextResult = $null +$task8ContextParameters = $null +$task8ReadResult = $null +$task8ReadCommand = $null +$task8ReadResultRecords = $null +$task8GetContextAction = $null +$task8ReadAction = $null +$task8LiveCoreResult = $null +$task8StorePathBound = $false +$task8Diagnostics = $null +$task8ProviderWeakReference = $null +$task8PrimaryFailed = $false +$task8FailureStage = 'Artifact' +$task8FailureCode = 'ArtifactRejected' +$task8HadModulePath = Test-Path -LiteralPath Env:PSModulePath +$task8SavedModulePath = if ($task8HadModulePath) { [string]$env:PSModulePath } else { $null } +$task8ModulePathChanged = $false + +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' + $env:PSModulePath = if ($task8HadModulePath -and + -not [string]::IsNullOrEmpty($task8SavedModulePath)) { + $task8State.ModuleRoot + [IO.Path]::PathSeparator + $task8SavedModulePath + } + else { $task8State.ModuleRoot } + $task8ModulePathChanged = $true + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name BeforeFinalImportRecheck ` + -Arguments @($task8State) + Assert-GraphKitAuthParityState -State $task8State -Purpose Import + $task8Imported = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { + Import-Module -Name $task8State.ExtractedManifestPath -PassThru -Force -ErrorAction Stop + } + $task8ImportedModule = $task8Imported[0] + $task8LocationComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { [StringComparison]::Ordinal } + if ($task8ImportedModule.Name -cne 'GraphKit' -or + -not [string]::Equals( + [IO.Path]::GetFullPath($task8ImportedModule.ModuleBase), + [IO.Path]::GetFullPath($task8State.ModuleRoot), + $task8LocationComparison) -or + -not [string]::Equals( + [IO.Path]::GetFullPath($task8ImportedModule.Path), + [IO.Path]::GetFullPath($task8State.ExtractedModulePath), + $task8LocationComparison) -or + "$($task8ImportedModule.Version)-$($task8ImportedModule.PrivateData.PSData.Prerelease)" -cne + $task8Record.moduleVersion) { + throw [InvalidOperationException]::new('The exact extracted GraphKit module was not imported.') + } + $task8State.ImportedManifestPath = $task8State.ExtractedManifestPath + $task8State.ImportedModulePath = $task8ImportedModule.Path + $task8Record.checks.exactImport = $true + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name AfterImport ` + -Arguments @($task8State) + + $task8FailureStage = 'Diagnostics' + $task8FailureCode = 'DiagnosticsRejected' + $task8Diagnostics = Get-GraphKitAuthParityDiagnostics ` + -Module $task8ImportedModule -State $task8State + $task8ProviderWeakReference = $task8Diagnostics.ProviderWeakReference + foreach ($property in $task8Diagnostics.Checks.PSObject.Properties) { + $task8Record.adapter.$($property.Name) = [bool]$property.Value + } + if (@($task8Record.adapter.PSObject.Properties.Value | Where-Object { + -not [bool]$_ + }).Count -ne 0) { + throw [InvalidOperationException]::new('The GraphKit.Auth adapter diagnostics were rejected.') + } + + if ($task8Execution -ceq 'Live') { + $task8StorePathBound = $PSBoundParameters.ContainsKey('StorePath') + Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name PrepareLiveModule ` + -Arguments @( + $task8ImportedModule, $task8State, $task8Route, $ProfileId, + $(if ($task8StorePathBound) { $StorePath } else { $null }), + $task8StorePathBound) + Assert-GraphKitAuthParityState -State $task8State -Purpose Import + $task8ContextCommand = @(Get-Command -Name Get-GraphContext -Module GraphKit ` + -CommandType Function -ErrorAction Stop) + $task8ReadCommand = @(Get-Command -Name Get-GraphObject -Module GraphKit ` + -CommandType Function -ErrorAction Stop) + if ($task8ContextCommand.Count -ne 1 -or + -not [object]::ReferenceEquals($task8ContextCommand[0].Module, $task8ImportedModule) -or + $task8ReadCommand.Count -ne 1 -or + -not [object]::ReferenceEquals($task8ReadCommand[0].Module, $task8ImportedModule)) { + throw [InvalidOperationException]::new('The exact public live commands were not found.') + } + $task8GetContextAction = { + param($requestedProfileId, $requestedStorePath, $route) + $parameters = @{ ProfileId = $requestedProfileId; ErrorAction = 'Stop' } + if ($task8StorePathBound) { $parameters.StorePath = $requestedStorePath } + $records = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { + & $task8ContextCommand[0] @parameters + } + return $records[0] + }.GetNewClosure() + $task8ReadAction = { + param($context, $type, $operation, $passThruResult) + $records = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { + & $task8ReadCommand[0] -Context $context -Type $type ` + -Operation $operation -PassThruResult:$passThruResult -ErrorAction Stop + } + return $records[0] + }.GetNewClosure() + $task8LiveCoreResult = Invoke-GraphKitAuthParityLiveCore -Route $task8Route ` + -Diagnostics $task8Diagnostics -ProfileId $ProfileId -StorePath $StorePath ` + -StorePathBound:$task8StorePathBound -GetContextAction $task8GetContextAction ` + -ReadAction $task8ReadAction + $task8Record.checks.contextMatched = [bool]$task8LiveCoreResult.contextMatched + $task8Record.checks.sourceMatched = [bool]$task8LiveCoreResult.sourceMatched + $task8Record.checks.tenantProofVerified = [bool]$task8LiveCoreResult.tenantProofVerified + $task8Record.read.attempted = [bool]$task8LiveCoreResult.readAttempted + $task8Record.read.succeeded = [bool]$task8LiveCoreResult.readSucceeded + $task8Record.read.rowCount = [long]$task8LiveCoreResult.rowCount + if ($task8LiveCoreResult.state -cne 'Passed') { + $task8FailureStage = [string]$task8LiveCoreResult.failureStage + $task8FailureCode = [string]$task8LiveCoreResult.failureCode + throw [InvalidOperationException]::new('The protected parity live core was rejected.') + } + } +} +catch { + $task8PrimaryFailed = $true + Set-GraphKitAuthParityFailure -Record $task8Record ` + -Stage $task8FailureStage -Code $task8FailureCode +} +finally { + $task8LiveCoreResult = $null + $task8GetContextAction = $null + $task8ReadAction = $null + $task8ReadResult = $null + $task8ReadResultRecords = $null + $task8ReadCommand = $null + $task8Context = $null + $task8ContextResult = $null + $task8ContextCommand = $null + $task8ContextParameters = $null + $task8Diagnostics = $null + $task8Imported = $null + $task8CleanupFailed = $false + if ($null -ne $task8ImportedModule) { + try { + $null = Invoke-GraphKitAuthParityCaptured -ExpectedCount 0 -Action { + Remove-Module -ModuleInfo $task8ImportedModule -Force -ErrorAction Stop + } + } + catch { $task8CleanupFailed = $true } + $task8ImportedModule = $null + } + if ($null -ne $task8ProviderWeakReference) { + for ($task8GcAttempt = 0; + $task8GcAttempt -lt 30 -and $task8ProviderWeakReference.IsAlive; + $task8GcAttempt++) { + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + [GC]::Collect() + } + if ($task8ProviderWeakReference.IsAlive) { $task8CleanupFailed = $true } + $task8ProviderWeakReference = $null + } + if ($task8ModulePathChanged) { + if ($task8HadModulePath) { $env:PSModulePath = $task8SavedModulePath } + else { Remove-Item -LiteralPath Env:PSModulePath -ErrorAction SilentlyContinue } + $task8ModulePathChanged = $false + } + if ($null -ne $task8State) { + 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/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 new file mode 100644 index 0000000..15b2fd5 --- /dev/null +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -0,0 +1,2999 @@ +$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 = '7' } +) + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $script:runnerPath = Join-Path $script:repoRoot 'scripts/Invoke-GraphKitAuthParity.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 + ) + + $nonce = [guid]::NewGuid().ToString('N') + $wrapperPath = Join-Path $TestDrive "task8-wrapper-$nonce.ps1" + $tracePath = Join-Path $TestDrive "task8-trace-$nonce.jsonl" + [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)][string] $TracePath +) +$ErrorActionPreference = 'Stop' +$UseDryRun = $UseDryRunText -ceq 'true' +$UseOrdinaryExecution = $OrdinaryExecutionText -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 Set-Task8FixtureOwnerWritable { + param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][bool] $Directory) + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = Get-Acl -LiteralPath $Path + $acl.SetOwner($identity) + $acl.SetAccessRuleProtection($true, $false) + $inheritance = if ($Directory) { + [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' + } + else { [Security.AccessControl.InheritanceFlags]::None } + $acl.SetAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $Path -AclObject $acl + if (-not $Directory) { (Get-Item -LiteralPath $Path).IsReadOnly = $false } + } + 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.Reflection; +using System.Threading; + +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-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 @{ + manifestPath = [string] $state.ImportedManifestPath + modulePath = [string] $state.ImportedModulePath + moduleVersion = [string] $state.ModuleVersion + } +}.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() + } + 'OutsideSentinel' { + $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() + } + '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')) + $null = New-Item -ItemType HardLink -Path $outside -Target $path -ErrorAction Stop + 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')) + $null = New-Item -ItemType HardLink -Path $outside -Target $path -ErrorAction Stop + 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 '7') { + $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 ($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) + } + $contracts = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -ceq 'GraphKit.Auth.Contracts' + }) + if ($contracts.Count -ne 1) { + throw 'The exact package did not leave 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) + if ($UseOrdinaryExecution) { & $RunnerPath @parameters } + else { . $RunnerPath @parameters } + } +} +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 + } + $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(), + '-TracePath',$tracePath + )) { + $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() + $outputLines = @($stdout -split "`r?`n" | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) + $parsed = $null + $jsonCount = 0 + if ($outputLines.Count -eq 1) { + try { + $parsed = ConvertFrom-Task8JsonText -Json $outputLines[0] + $jsonCount = 1 + } + catch { $parsed = $null } + } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout + StdErr = $stderr + Output = $stdout + $stderr + Data = $parsed + 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 Remove-Task8ResidualFixturePath { + param([AllowNull()][string] $Path) + if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) { return } + $full = [IO.Path]::GetFullPath($Path) + $temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) + if (-not $full.StartsWith($temp, [StringComparison]::Ordinal) -or + [IO.Path]::GetFileName($full) -notmatch '^graphkit-task8-') { + throw 'Task 8 fixture cleanup refused a non-literal residual path.' + } + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User + $paths = @( + Get-ChildItem -LiteralPath $full -Recurse -Force -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Length } -Descending + ) + @(Get-Item -LiteralPath $full -Force) + foreach ($item in $paths) { + if (-not $item.PSIsContainer) { $item.IsReadOnly = $false } + $acl = Get-Acl -LiteralPath $item.FullName + $acl.SetAccessRuleProtection($true, $false) + $acl.SetAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $item.FullName -AclObject $acl + } + } + else { + & chmod -R u+rwX $full + if ($LASTEXITCODE -ne 0) { throw 'Task 8 fixture cleanup could not restore owner access.' } + } + Remove-Item -LiteralPath $full -Recurse -Force -ErrorAction Stop + } + + 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.' + } + + $sender = Get-Task8ParsedFunction -Path ( + Join-Path $SourceRoot 'Private/Transport/Send-GraphHttpRequest.ps1') ` + -Name Send-GraphHttpRequest + $proofCalls = @($sender.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -ceq 'Confirm-GraphTenantBinding' + }, $true)) + $physicalSends = @($sender.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 + } + + 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 + + @($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) + $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' { + if (-not (Test-Path -LiteralPath $script:runnerPath -PathType Leaf)) { + throw 'Task 8 runner AST is not implemented.' + } + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $script:runnerPath, [ref] $tokens, [ref] $errors) + @($errors).Count | Should -Be 0 + $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 = @($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' { + $contractsPath = Join-Path $script:repoRoot ` + 'output/module/GraphKit/0.4.0/Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + $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-Task8ResidualFixturePath -Path $root + Remove-Task8ResidualFixturePath -Path $outside + } + 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-Task8ResidualFixturePath -Path $root + Remove-Task8ResidualFixturePath -Path $outside + } + 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 'restores PSModulePath, removes GraphKit, deletes only its exact root, and preserves a sibling' { + $candidate = Get-Task8PackedCandidate + $result = Invoke-Task8RunnerProcess -PackagePath $candidate.PackagePath ` + -PackageSha256 $candidate.PackageSha256 -AuthMode Certificate -DryRun ` + -HookKind OutsideSentinel + $trace = Get-Task8TraceRecords $result.TracePath + $root = [string] ($trace | Where-Object event -eq 'root-created').data.root + $outside = [string] ($trace | Where-Object event -eq 'outside-created').data.path + try { + $result.Data.state | Should -BeExactly 'Passed' + (Test-Path -LiteralPath $root) | Should -BeFalse + (Test-Path -LiteralPath $outside -PathType Leaf) | Should -BeTrue + $finished = $trace | Where-Object event -eq 'wrapper-finished' + $finished.data.modulePathRestored | Should -BeTrue + $finished.data.graphKitLoaded | Should -BeFalse + } + finally { + if (Test-Path -LiteralPath $outside -PathType Leaf) { + Remove-Item -LiteralPath $outside -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 + } +} + +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' + $result.Data.state | Should -BeExactly 'Passed' + @((Get-Task8TraceRecords $result.TracePath) | + Where-Object event -eq 'stream-sentinel-fired').Count | Should -Be 1 + + $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' + } + + 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 + } +} From 27c1ff12eedb30377c8e6c6bde15d786ff63e6a0 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 1 Sep 2026 22:21:20 -0400 Subject: [PATCH 31/79] docs: close no-adopter R9 gates --- AGENTS.md | 2 +- docs/cutover/2026-08-15-phase5-cutover.md | 12 +++-- ...hkit-tenantpulse-product-program-design.md | 51 ++++++++++++------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c62c7ba..e395115 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Run the suite through `./build.ps1 -Tasks test`, never `Invoke-Pester ./tests` d **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 896 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. -**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: 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/specs/2026-08-19-graphkit-tenantpulse-product-program-design.md b/docs/superpowers/specs/2026-08-19-graphkit-tenantpulse-product-program-design.md index d070413..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 @@ -445,15 +456,20 @@ not satisfy this milestone. ### 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 @@ -561,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 From 501cf25addc807d1d3a89e37230b9c3706963aef Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 2 Sep 2026 19:40:21 -0400 Subject: [PATCH 32/79] docs: record R8 deterministic-complete vs approval-gated split --- CHANGELOG.md | 11 +++ .../plans/2026-08-30-r8-graphkit-auth.md | 91 +++++++++++-------- .../2026-08-30-r8-graphkit-auth-design.md | 4 +- 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 706f69d..a191739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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 - The real retry/sender path now acquires exactly one bearer per physical attempt. Tenant proof, diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md index 230244f..4ea1522 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -10,6 +10,17 @@ --- +## 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, frozen at clean commit +`beceb22`). 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: @@ -68,7 +79,7 @@ Existing files to modify: - Test: `tests/QA/PackageIdentity.tests.ps1` - Test: `tests/QA/ReleaseProof.tests.ps1` -- [ ] **Step 1: Write failing successor-version tests** +- [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 @@ -81,7 +92,7 @@ $proof.source.revision | Should -Match '^[0-9a-f]{40}$' $proof.module.version | Should -Be ([string] $metadata.version) ``` -- [ ] **Step 2: Run the focused tests and verify red** +- [x] **Step 2: Run the focused tests and verify red** Run: @@ -92,7 +103,7 @@ Invoke-Pester ./tests/QA/PackageIdentity.tests.ps1,./tests/QA/ReleaseProof.tests Expected: failures naming stable `0.3.0`, missing source revision, and prerelease package discovery. -- [ ] **Step 3: Implement deterministic version generation** +- [x] **Step 3: Implement deterministic version generation** `Get-GraphKitTrainVersion.ps1` returns one string and nothing else: @@ -115,11 +126,11 @@ Set `$env:ModuleVersion` in `build.ps1` before Sampler resolves build metadata. 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. -- [ ] **Step 4: Run identity/proof tests and verify green** +- [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. -- [ ] **Step 5: Commit** +- [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 @@ -134,7 +145,7 @@ git commit -m "build: establish the GraphKit R8 prerelease identity" - Create: `tests/QA/GraphKitAuthPackage.tests.ps1` - Modify: `tests/QA/PackageDependencies.tests.ps1` -- [ ] **Step 1: Write the missing-artifact and ABI tests** +- [x] **Step 1: Write the missing-artifact and ABI tests** The tests require these exact package paths: @@ -157,11 +168,11 @@ Load the contracts assembly and assert: Reflect over every public type/member signature and fail when the declaring assembly or full type name contains `Microsoft.Identity.Client`. -- [ ] **Step 2: Run the two files and verify red** +- [x] **Step 2: Run the two files and verify red** Expected: missing assembly/package path failures only. -- [ ] **Step 3: Commit tests 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 @@ -180,7 +191,7 @@ git commit -m "test: define the GraphKit Auth package boundary" - Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs` - Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs` -- [ ] **Step 1: Pin the SDK and deterministic defaults** +- [x] **Step 1: Pin the SDK and deterministic defaults** `global.json`: @@ -199,7 +210,7 @@ git commit -m "test: define the GraphKit Auth package boundary" `ContinuousIntegrationBuild=true`, `DebugType=None`, and `RestorePackagesWithLockFile=true`. -- [ ] **Step 2: Implement ABI-v1 DTOs and interfaces** +- [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 @@ -221,7 +232,7 @@ public sealed class GraphTokenResult } ``` -- [ ] **Step 3: Implement strict loader and proxy lifetime** +- [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 @@ -233,7 +244,7 @@ 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. -- [ ] **Step 4: Build the contracts project** +- [x] **Step 4: Build the contracts project** Run: @@ -243,7 +254,7 @@ dotnet build src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.c Expected: zero warnings and errors; no `Microsoft.Identity.Client` in `project.assets.json`. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add global.json src/GraphKit.Auth @@ -263,7 +274,7 @@ git commit -m "feat: define the GraphKit Auth ABI" - Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs` - Create: `src/GraphKit.Auth/GraphKit.Auth.sln` -- [ ] **Step 1: Write failing .NET source-contract tests** +- [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, @@ -280,7 +291,7 @@ public void ForcedRefreshReplacesAnOlderCachedResult() } ``` -- [ ] **Step 2: Run .NET tests and verify red** +- [x] **Step 2: Run .NET tests and verify red** Run: @@ -290,7 +301,7 @@ dotnet test src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj -c Expected: missing provider/source types. -- [ ] **Step 3: Implement the minimal complete provider** +- [x] **Step 3: Implement the minimal complete provider** `GraphKit.Auth.csproj` pins: @@ -312,7 +323,7 @@ generation on every result/adoption, and never parses a JWT. Fixed bearer return isolated provider and converted to a GraphKit-owned `GraphAuthException` without preserving an MSAL `InnerException` or `Data` value. -- [ ] **Step 4: Lock restore and run tests green** +- [x] **Step 4: Lock restore and run tests green** Run: @@ -324,7 +335,7 @@ dotnet test src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj -c Expected: all tests pass, zero warnings, committed lock files name exact MSAL 4.82.1. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/GraphKit.Auth @@ -383,7 +394,7 @@ containment, native identity and link count, stable-handle hashing, and platform 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. -- [ ] **Step 1: Record the approved baseline and write genuine failing tests** +- [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 @@ -404,7 +415,7 @@ Extend release-proof mutations for exact duplicates, portable case, NFC, separat 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. -- [ ] **Step 2: Implement one locked build lineage and fresh sealed staging** +- [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 @@ -476,7 +487,7 @@ restores Git environment, and proves fingerprint/status restoration before relea finalization runs with no exclusion active. This is test-fixture projection from the authorized stage, not another build or another package source. -- [ ] **Step 3: Copy only a freshly reverified stage into the built module** +- [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 @@ -498,7 +509,7 @@ Prepare_GraphKitAuth_Clean -> package_graphkit_r8_nupkg ``` -- [ ] **Step 4: Reverify before import and harden canonical proof** +- [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, @@ -511,7 +522,7 @@ PowerShell 7.4 CI is the future .NET 8 runtime/import evidence; local xUnit on t 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. -- [ ] **Step 5: Enforce exact-event-source CI and run all gates** +- [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 @@ -529,7 +540,7 @@ against the already-tested package. Require clean status, `git diff --check`, un 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. -- [ ] **Step 6: Commit and report** +- [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 @@ -554,18 +565,18 @@ push, PR, publication, tenant, vault, token acquisition, Azure, merge, or galler - Test: `tests/Unit/Profiles/Get-GraphContext.Tests.ps1` - Test: `tests/Adapter/Send-GraphHttpRequest.Tests.ps1` -- [ ] **Step 1: Write failing bridge/ownership tests** +- [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. -- [ ] **Step 2: Verify red** +- [x] **Step 2: Verify red** Run the three focused Pester files. Expected: built-in contexts still return PowerShell classes. -- [ ] **Step 3: Implement the bridge** +- [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 @@ -574,7 +585,7 @@ absent. Register the host before any source and register each compiled source as In the sender, adopt shared results for either legacy `GraphTokenSourceBase` or compiled `IGraphTokenSource`. Apply the creation-runspace preflight only to the legacy base class. -- [ ] **Step 4: Run focused tests green and commit** +- [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 @@ -656,7 +667,7 @@ Five count authorities remain separate. Task 7 records and asserts exact post-di accepting at least 48 .NET tests is not Task 7 count authority; durable global ratchet synchronization remains Task 9 scope. -- [ ] **Step 1: Replace the tracked plan section and record exact clean baselines** +- [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. @@ -675,7 +686,7 @@ The focused baseline is the sum of the five existing Task 7 files on clean Task `TokenIsolation` 8, `GraphTokenSource` 48, `GraphModuleLifecycleSender` 2, `GraphModuleLifecycle` 13, and `GraphKitAuth` 24. The two new files contribute zero at base. -- [ ] **Step 2: Add the strict shared 16-row matrix and test-only discovery** +- [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`, @@ -785,7 +796,7 @@ over already runspace-neutral compiled sources, require reversible mutation proo Task 7 waiter/dead-field/lifecycle behavior must fail for the intended reason. Restore every mutation byte-for-byte and repack before proceeding. -- [ ] **Step 3: Make compiled-source drain deterministic for both owned modes** +- [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 @@ -798,7 +809,7 @@ 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. -- [ ] **Step 4: Add exact outer-flight waiter instrumentation** +- [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 @@ -813,7 +824,7 @@ Cover ordinary collapse, provider-failure fanout, leader-cancellation replacemen production-sender collapse, ordinary/forced partitioning, concurrent credential reuse, active-source disposal, and exact empty-registry cleanup. -- [ ] **Step 5: Prove exact parent-source use across thread runspaces** +- [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. @@ -851,7 +862,7 @@ Required cases: 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. -- [ ] **Step 6: Prove lifecycle by composition and collect the packaged ALC** +- [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 @@ -877,7 +888,7 @@ While retaining the exact source, call `Acquire` and require `ObjectDisposedExce 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. -- [ ] **Step 7: Run exact focused and complete gates** +- [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 @@ -936,7 +947,7 @@ Reject the task if scheduler duration is used as ordering evidence, a child reco an automatic child host remains alive, generated output is tracked, public ABI changes, or external access occurs. -- [ ] **Step 8: Commit, repeat on exact clean SHA, and report** +- [x] **Step 8: Commit, repeat on exact clean SHA, and report** Commit only the reviewed file set: @@ -1010,7 +1021,7 @@ resources, use external network access, or push, open/merge a PR, merge, or publ separate explicit authority. The ignored controller record `.superpowers/sdd/2026-08-30-r8-graphkit-auth/progress.md` remains outside every commit. -- [ ] **Step 0: Close deterministic prerequisites exposed by the parity red phase** +- [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 @@ -1147,7 +1158,7 @@ tests/Unit/Throttle/ThrottleGate.Tests.ps1 tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 ``` -- [ ] **Step 1: Write and test a digest-bound protected runner** +- [x] **Step 1: Write and test a digest-bound protected runner** The runner requires the exact package path and SHA-256, installs into an isolated module path, and accepts one auth mode per invocation. Dry-run tests prove certificate, client-secret, @@ -1155,13 +1166,13 @@ managed-identity, and fixed-bearer routing without reading a credential, calling permission, or creating Azure resources. Real mode emits only redacted counts, auth mode, adapter diagnostics, package digest, and success/failure state. -- [ ] **Step 2: Commit deterministic prerequisites and runner in sequence** +- [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 `scripts/Invoke-GraphKitAuthParity.ps1` and `tests/QA/GraphKitAuthLiveParity.tests.ps1`. No observed-evidence file belongs in either commit. -- [ ] **Step 3: Pack/test and freeze the exact clean runner 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 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 index 7876a69..21b2089 100644 --- a/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md +++ b/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md @@ -2,7 +2,7 @@ **Date:** 2026-08-30 -**Status:** Approved by the active end-to-end product-program goal +**Status:** Approved by the active end-to-end product-program goal. Deterministic implementation is complete and green; protected live parity, exact-SHA CI, and publication remain approval-gated. **Scope:** GraphKit R8 only @@ -33,7 +33,7 @@ from the exact source used to build it: 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-diff-hash> +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. From 6aee19bc50d2cdfbdba55d6694465855c5c6fb51 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 14:14:45 -0400 Subject: [PATCH 33/79] fix: restore lazy SecretManagement boundary --- CHANGELOG.md | 4 + source/GraphKit.psd1 | 5 +- tests/QA/BuiltModule.tests.ps1 | 4 +- tests/QA/CleanImport.tests.ps1 | 166 ++++++++++++++++++ tests/QA/ImportOrderMatrix.tests.ps1 | 80 +++++++++ tests/QA/PackageDependencies.tests.ps1 | 17 +- .../Auth/SecretManagementBoundary.Tests.ps1 | 55 +++++- 7 files changed, 313 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a191739..a248b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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. diff --git a/source/GraphKit.psd1 b/source/GraphKit.psd1 index 0a154e8..b6737fb 100644 --- a/source/GraphKit.psd1 +++ b/source/GraphKit.psd1 @@ -54,7 +54,6 @@ PowerShellVersion = '7.4' RequiredModules = @( # MSAL delivery vehicle only - Connect-MgGraph is never called. See the design spec. @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' } - @{ ModuleName = 'Microsoft.PowerShell.SecretManagement'; ModuleVersion = '1.1.2' } ) # Assemblies that must be loaded prior to importing this module @@ -146,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 remains a runtime RequiredModule even though vault-backed - # paths validate vault availability only when they are used. + # 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/tests/QA/BuiltModule.tests.ps1 b/tests/QA/BuiltModule.tests.ps1 index 9a5b344..dab71c4 100644 --- a/tests/QA/BuiltModule.tests.ps1 +++ b/tests/QA/BuiltModule.tests.ps1 @@ -37,11 +37,11 @@ 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 both always-required runtime dependencies' { + 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 -Contain 'Microsoft.PowerShell.SecretManagement' + $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' { 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/ImportOrderMatrix.tests.ps1 b/tests/QA/ImportOrderMatrix.tests.ps1 index 2f8938e..d6d5aff 100644 --- a/tests/QA/ImportOrderMatrix.tests.ps1 +++ b/tests/QA/ImportOrderMatrix.tests.ps1 @@ -146,3 +146,83 @@ 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.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/PackageDependencies.tests.ps1 b/tests/QA/PackageDependencies.tests.ps1 index 68164bd..9dff84e 100644 --- a/tests/QA/PackageDependencies.tests.ps1 +++ b/tests/QA/PackageDependencies.tests.ps1 @@ -7,7 +7,6 @@ BeforeAll { $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' - $script:secretManagementPath = Join-Path $script:repoRoot 'output/RequiredModules/Microsoft.PowerShell.SecretManagement/1.1.2' function Get-PackageDependencies { param([Parameter(Mandatory)] [string] $PackagePath) @@ -37,11 +36,9 @@ BeforeAll { $modulePath = Join-Path $Root 'Modules' $graphKitDestination = Join-Path $modulePath "GraphKit/$script:baseVersion" $graphAuthDestination = Join-Path $modulePath 'Microsoft.Graph.Authentication/2.38.1' - $secretManagementDestination = Join-Path $modulePath 'Microsoft.PowerShell.SecretManagement/1.1.2' - $null = New-Item -ItemType Directory -Path $graphKitDestination, $graphAuthDestination, $secretManagementDestination -Force + $null = New-Item -ItemType Directory -Path $graphKitDestination, $graphAuthDestination -Force [System.IO.Compression.ZipFile]::ExtractToDirectory($script:packagePath, $graphKitDestination) Copy-Item -Path (Join-Path $script:graphAuthPath '*') -Destination $graphAuthDestination -Recurse -Force - Copy-Item -Path (Join-Path $script:secretManagementPath '*') -Destination $secretManagementDestination -Recurse -Force return $modulePath } @@ -95,18 +92,18 @@ Import-Module '$($isolatedManifest.Replace("'", "''"))' -Force -ErrorAction Stop } Describe 'Packed GraphKit dependency contract' -Tag 'QA' { - It 'records Graph Authentication and SecretManagement as exact NuGet dependencies' { + 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 2 + $dependencies.Count | Should -Be 1 $dependencyMap = @{} foreach ($dependency in $dependencies) { $dependencyMap[[string] $dependency.id] = [string] $dependency.version } $dependencyMap['Microsoft.Graph.Authentication'] | Should -Be '2.38.1' - $dependencyMap['Microsoft.PowerShell.SecretManagement'] | Should -Be '1.1.2' + $dependencyMap.ContainsKey('Microsoft.PowerShell.SecretManagement') | Should -BeFalse -Because 'vault support is optional and resolved on first vault-backed use' } - It 'imports the isolated artifact with both required runtime dependencies' { + It 'imports the isolated artifact with Graph Authentication alone' { $modulePath = New-IsolatedGraphKitModulePath -Root (Join-Path $TestDrive 'non-vault') $result = Invoke-IsolatedGraphKitProbe -ModulePath $modulePath @@ -116,8 +113,8 @@ Describe 'Packed GraphKit dependency contract' -Tag 'QA' { $result.Data.OperationName | Should -Be 'ManagedDevice.List' $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 -BeTrue -Because 'SecretManagement is restored as a runtime RequiredModule' - $result.Data.SecretManagementAvailable | Should -BeTrue -Because 'SecretManagement is a required runtime package dependency' + $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/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 + } } From 0e3b1c34e3640bfd2d7d37fc4e0c5b20e263c44f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 15:00:08 -0400 Subject: [PATCH 34/79] test: isolate token partition concurrency harness --- .../TokenSources/GraphTokenSource.Tests.ps1 | 85 +++++++++++++------ 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index e497904..13d8b16 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -2063,28 +2063,41 @@ Describe 'GraphTokenSource' { It 'collapses same-mode callers while ordinary and forced flights remain separate' { $key = 'mode-partition-key' $calls = [System.Collections.Concurrent.ConcurrentQueue[string]]::new() - $ready = [System.Threading.CountdownEvent]::new(6) - $go = [System.Threading.ManualResetEventSlim]::new($false) $entered = [System.Threading.CountdownEvent]::new(2) $release = [System.Threading.ManualResetEventSlim]::new($false) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeCalls', $calls) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeReady', $ready) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeGo', $go) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeEntered', $entered) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeRelease', $release) - $jobs = $null + $workers = [System.Collections.Generic.List[object]]::new() try { - $jobs = 0..5 | ForEach-Object { + # 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 - Start-ThreadJob -ThrottleLimit 6 -ScriptBlock { - param($Key, $Force, $Manifest) - Import-Module $Manifest - $ready = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ModeReady') - $go = [System.AppDomain]::CurrentDomain.GetData('GraphKitTest.ModeGo') - $null = $ready.Signal() - $null = $go.Wait() + $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' } @@ -2100,11 +2113,20 @@ Describe 'GraphTokenSource' { $mode }.GetNewClosure() } $Key $Force - } -ArgumentList $key, $force, $script:BuiltManifest + }).AddArgument($key).AddArgument($force) + + $workers.Add([pscustomobject] @{ + PowerShell = $pipeline + Runspace = $runspace + Async = $null + Received = $false + }) + } + + foreach ($worker in $workers) { + $worker.Async = $worker.PowerShell.BeginInvoke() } - $ready.Wait(15000) | Should -BeTrue - $go.Set() $entered.Wait(5000) | Should -BeTrue $flightKeys = InModuleScope GraphKit -Parameters @{ K = $key } { param($K) @@ -2128,7 +2150,14 @@ Describe 'GraphTokenSource' { $forcedBeforeRelease.WaiterCount | Should -Be 2 $ordinaryBeforeRelease.RegistryCount | Should -Be 2 $forcedBeforeRelease.RegistryCount | Should -Be 2 - $results = Receive-Task7BoundedJobs -Jobs $jobs -ExpectedCount 6 + $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 @@ -2141,20 +2170,22 @@ Describe 'GraphTokenSource' { } finally { $release.Set() - $go.Set() - if ($null -ne $jobs) { - $null = @($jobs | Wait-Job -Timeout 10) + 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.ModeReady', $null) - [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeGo', $null) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeEntered', $null) [System.AppDomain]::CurrentDomain.SetData('GraphKitTest.ModeRelease', $null) - if ($null -ne $jobs) { - $jobs | Remove-Job -Force -ErrorAction SilentlyContinue - } - $ready.Dispose() - $go.Dispose() $entered.Dispose() $release.Dispose() } From bf71102f6ad9f63468bec725ff8d6cc8b3560f31 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 15:26:57 -0400 Subject: [PATCH 35/79] fix: harden release gate integrity --- .build/GraphKitAuth.tasks.ps1 | 29 ++- .github/powershell-release-sha256.json | 27 +++ .../Install-VerifiedPowerShellArchive.ps1 | 42 ++++ .github/workflows/ci.yml | 17 +- AGENTS.md | 2 +- CHANGELOG.md | 4 + scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- tests/QA/Get-GraphKitPesterDiscoveryCount.ps1 | 32 ++++ tests/QA/GraphKitAuthTestResultGate.tests.ps1 | 53 +++++ tests/QA/MinimumTestsRatchetSync.tests.ps1 | 27 ++- tests/QA/PowerShellReleaseArchive.tests.ps1 | 181 ++++++++++++++++++ tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +- 14 files changed, 398 insertions(+), 28 deletions(-) create mode 100644 .github/powershell-release-sha256.json create mode 100644 .github/scripts/Install-VerifiedPowerShellArchive.ps1 create mode 100644 tests/QA/Get-GraphKitPesterDiscoveryCount.ps1 create mode 100644 tests/QA/GraphKitAuthTestResultGate.tests.ps1 create mode 100644 tests/QA/PowerShellReleaseArchive.tests.ps1 diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index 143f797..c6fbd11 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -17,6 +17,24 @@ $script:GraphKitAuthStage = $null $script:GraphKitAuthStageCaptureType = $null $script:GraphKitAuthAbiFixtureState = $null $script:GraphKitAuthAbiGitConfigState = $null +$script:GraphKitAuthExpectedTestCount = 74 + +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 } @@ -1343,16 +1361,7 @@ if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyConti if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth Release tests failed.' } $trxPath = Join-Path $resultRoot 'GraphKit.Auth.trx' [xml]$trx = Get-Content -LiteralPath $trxPath -Raw - $outcomes = @($trx.TestRun.Results.UnitTestResult | ForEach-Object { [string]$_.outcome }) - $counters = $trx.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 -lt 48 -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: total=$($outcomes.Count), passed=$(@($outcomes | Where-Object { $_ -ceq 'Passed' }).Count)." - } + 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.' } 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 dccfcb0..42422af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,8 +47,8 @@ jobs: # 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: | @@ -68,16 +68,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" @@ -114,7 +111,7 @@ 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 896 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1452 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index e395115..e7cfbf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 896 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. +**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 1452 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. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index a248b06..1a5045f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Release gates now require exactly 74 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. - 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, diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index f8d2720..9bf3905 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 896 +$minimumTests = 1452 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 8dc095e..5371a7f 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 896 +$minimumTests = 1452 $allowedSkips = 0 $allowedNotRun = 0 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/GraphKitAuthTestResultGate.tests.ps1 b/tests/QA/GraphKitAuthTestResultGate.tests.ps1 new file mode 100644 index 0000000..80089db --- /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 74 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 74 + + { Assert-GraphKitAuthTestResult -Result $result } | Should -Not -Throw + } + + It 'rejects an all-passing result with only 73 discovered tests' { + $result = New-GraphKitAuthTrxResult -Total 73 + + { Assert-GraphKitAuthTestResult -Result $result } | + Should -Throw '*expected exactly 74*' + } + + It 'rejects an all-passing result with 75 discovered tests' { + $result = New-GraphKitAuthTrxResult -Total 75 + + { Assert-GraphKitAuthTestResult -Result $result } | + Should -Throw '*expected exactly 74*' + } +} diff --git a/tests/QA/MinimumTestsRatchetSync.tests.ps1 b/tests/QA/MinimumTestsRatchetSync.tests.ps1 index 76d6f91..83660b5 100644 --- a/tests/QA/MinimumTestsRatchetSync.tests.ps1 +++ b/tests/QA/MinimumTestsRatchetSync.tests.ps1 @@ -23,22 +23,47 @@ BeforeAll { } Describe 'MinimumTests ratchet synchronization' -Tag 'QA' { - It 'keeps CI, proof production, proof verification, and the canonical 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 $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' 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 ' ')" + } + $discovery = $discoveryOutput[-1] | 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/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 1a0027a..67953e9 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 = 896) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1452) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 1e0d547..ad7796b 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -105,7 +105,7 @@ BeforeAll { [switch] $IncludeGraphKitAuth, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 896 + [int] $Total = 1452 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -313,7 +313,7 @@ $requiredAssembliesLine RequiredModules = @(@{ ModuleName = 'Microsoft.Graph. sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 896 + minimumTests = 1452 allowedSkips = 0 allowedNotRun = 0 } @@ -1000,7 +1000,7 @@ Describe 'Test workflow release-proof generation' { $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 896 + $proof.testRun.summary.total | Should -Be 1452 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse } From d340dcaf7a96a343e00d12e18095e96a27151265 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 15:15:56 -0400 Subject: [PATCH 36/79] fix: scan compiled package privacy surface --- CHANGELOG.md | 5 + scripts/Publish-GraphKitToGallery.ps1 | 81 +---- .../private/Test-GraphKitPackagePrivacy.ps1 | 310 ++++++++++++++++++ tests/QA/ReleaseProof.tests.ps1 | 144 ++++++++ 4 files changed, 474 insertions(+), 66 deletions(-) create mode 100644 scripts/private/Test-GraphKitPackagePrivacy.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5045f..c8064ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 and C# plus printable ASCII/UTF-8 and UTF-16LE strings in + every shipped DLL are checked for private paths, identifiers, GUIDs, and contextual certificate + thumbprints. Diagnostics retain only fixed categories and SHA-256 evidence fingerprints, and + legitimate 40-hex source or vendor revisions are no longer 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, diff --git a/scripts/Publish-GraphKitToGallery.ps1 b/scripts/Publish-GraphKitToGallery.ps1 index bd817df..d8ba8a6 100644 --- a/scripts/Publish-GraphKitToGallery.ps1 +++ b/scripts/Publish-GraphKitToGallery.ps1 @@ -186,72 +186,19 @@ 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.' } - 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 + . $privacyScannerPath + $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 } } @@ -285,7 +232,9 @@ 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 '' diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 new file mode 100644 index 0000000..1583bcb --- /dev/null +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -0,0 +1,310 @@ +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 + ) + + 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._-]+|[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 + } + } + + $guidPattern = '\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' + foreach ($match in [regex]::Matches($Text, $guidPattern)) { + if ($AllowedGuids.Contains($match.Value) -or + (Test-GraphKitPackagePrivacyPlaceholderGuid -Value $match.Value)) { + 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' + } + foreach ($token in [regex]::Matches($Text, '[A-Za-z0-9][A-Za-z0-9-]{3,}')) { + $tokenDigest = (Get-GraphKitPackagePrivacyDigest -Value $token.Value.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.Value + } + } +} + +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 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 = [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', + $ModuleGuid.ToString('D') + )) { + $null = $allowedGuids.Add($allowedGuid) + } + + $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 + + Add-Type -AssemblyName System.IO.Compression.FileSystem + 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() + $memory = [System.IO.MemoryStream]::new() + try { + $entryStream.CopyTo($memory) + $bytes = $memory.ToArray() + } + catch { + throw "Package privacy scan failed closed while reading an entry (entry sha256: $entryDigest)." + } + finally { + $memory.Dispose() + $entryStream.Dispose() + } + if ($bytes.LongLength -ne $entry.Length) { + throw "Package privacy scan rejected an entry whose byte count changed while reading (entry sha256: $entryDigest)." + } + + 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/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index ad7796b..0238285 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -41,6 +41,51 @@ BeforeAll { } } + 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) @@ -132,6 +177,8 @@ BeforeAll { -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') @@ -1135,6 +1182,103 @@ Describe 'Both publisher paths consume the canonical proof verifier' { (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) + } + + 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' + $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 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, $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 From 604ac0b029f3d94f0935c59090edc9fd33d216cd Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 15:25:57 -0400 Subject: [PATCH 37/79] fix: scan authored auth source for privacy --- CHANGELOG.md | 9 +- scripts/Publish-GraphKitToGallery.ps1 | 14 ++ .../private/Test-GraphKitPackagePrivacy.ps1 | 132 ++++++++++++++++-- tests/QA/ReleaseProof.tests.ps1 | 40 +++++- tests/QA/SourceHygiene.tests.ps1 | 41 ++++++ 5 files changed, 221 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8064ab..9333c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 and C# plus printable ASCII/UTF-8 and UTF-16LE strings in - every shipped DLL are checked for private paths, identifiers, GUIDs, and contextual certificate - thumbprints. Diagnostics retain only fixed categories and SHA-256 evidence fingerprints, and - legitimate 40-hex source or vendor revisions are no longer treated as thumbprints. + 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, diff --git a/scripts/Publish-GraphKitToGallery.ps1 b/scripts/Publish-GraphKitToGallery.ps1 index d8ba8a6..dd7ca4e 100644 --- a/scripts/Publish-GraphKitToGallery.ps1 +++ b/scripts/Publish-GraphKitToGallery.ps1 @@ -200,6 +200,20 @@ if ($packageExists) { Write-Host (" {0}: {1} [entry sha256:{2}; value redacted sha256:{3}]" -f ` $finding.Encoding, $finding.Category, $entryEvidence, $valueEvidence) -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 + } } # --- the version is not already on the gallery ------------------------------------------- diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 index 1583bcb..0d37010 100644 --- a/scripts/private/Test-GraphKitPackagePrivacy.ps1 +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -19,6 +19,12 @@ function Test-GraphKitPackagePrivacyPlaceholderGuid { [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 + } + foreach ($segment in @($Value -split '-')) { if (@($segment.ToCharArray() | Select-Object -Unique).Count -gt 1) { return $false @@ -170,6 +176,122 @@ function ConvertFrom-GraphKitPackagePrintableAscii { 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', + $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 + + 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 + [System.IO.Path]::GetExtension($sourceFile.Name) -ine '.cs') { + 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 authored CSharp 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 CSharp files and failed closed.' + } + + return [pscustomobject] [ordered] @{ + Passed = $findings.Count -eq 0 + Findings = @($findings) + SourceFilesScanned = $sourceFilesScanned + BytesScanned = $scannedBytes + } +} + function Test-GraphKitPackagePrivacy { [CmdletBinding()] param( @@ -186,15 +308,7 @@ function Test-GraphKitPackagePrivacy { $findings = [System.Collections.Generic.List[object]]::new() $findingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - $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', - $ModuleGuid.ToString('D') - )) { - $null = $allowedGuids.Add($allowedGuid) - } + $allowedGuids = Get-GraphKitPackagePrivacyAllowedGuidSet -ModuleGuid $ModuleGuid $textExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($textExtension in @( diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 0238285..3a433a6 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -164,8 +164,13 @@ BeforeAll { $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, $buildDir -Force | Out-Null + 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') @@ -193,7 +198,7 @@ BeforeAll { -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 add .gitignore scripts tests + & 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() @@ -387,6 +392,7 @@ $requiredAssembliesLine RequiredModules = @(@{ ModuleName = 'Microsoft.Graph. Version = $version BaseVersion = $baseVersion ModuleDir = $moduleDir + AuthSourceDir = $authSourceDir PackagePath = $packagePath ProofPath = $proofPath NUnitPath = $nunitPath @@ -1242,6 +1248,36 @@ internal static class Fixture { } } + 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' diff --git a/tests/QA/SourceHygiene.tests.ps1 b/tests/QA/SourceHygiene.tests.ps1 index 96f44ea..77de48b 100644 --- a/tests/QA/SourceHygiene.tests.ps1 +++ b/tests/QA/SourceHygiene.tests.ps1 @@ -10,6 +10,47 @@ 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 CSharp privacy' { + + It 'passes the reusable strict source privacy scan for every authored CSharp file' { + $authSourceRoot = Join-Path $script:repoRoot 'src/GraphKit.Auth' + $expectedSourceFiles = @( + Get-ChildItem -LiteralPath $authSourceRoot -Recurse -File -Force | + Where-Object { + $_.Extension -ieq '.cs' -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 when an authored CSharp file is not strict UTF-8' { + $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]::WriteAllBytes( + (Join-Path $fixtureRoot 'Invalid.cs'), + [byte[]] @(0x63, 0x6c, 0x61, 0x73, 0x73, 0x20, 0xc3, 0x28) + ) + + { + Test-GraphKitAuthSourcePrivacy -SourceRoot $fixtureRoot -ModuleGuid ([guid]::Empty) + } | Should -Throw '*strict UTF-8*' + } + finally { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } } Describe 'Source hygiene' { From dbbc0addf1bdf49cdd2324253ee49da0feb26221 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 16:18:29 -0400 Subject: [PATCH 38/79] test: synchronize portable suite floor --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42422af..86645cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ 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 1452 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1460 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index e7cfbf2..7924cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1452 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. +**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 1460 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. 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. diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 9bf3905..e3e41f7 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1452 +$minimumTests = 1460 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 5371a7f..62e6bd4 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1452 +$minimumTests = 1460 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index 67953e9..cfa30fd 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 = 1452) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1460) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 3a433a6..20c8384 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -150,7 +150,7 @@ BeforeAll { [switch] $IncludeGraphKitAuth, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1452 + [int] $Total = 1460 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -365,7 +365,7 @@ $requiredAssembliesLine RequiredModules = @(@{ ModuleName = 'Microsoft.Graph. sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1452 + minimumTests = 1460 allowedSkips = 0 allowedNotRun = 0 } @@ -1053,7 +1053,7 @@ Describe 'Test workflow release-proof generation' { $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 1452 + $proof.testRun.summary.total | Should -Be 1460 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse } From 22410ffcba402b401c69d95dbb7e738bcc7ee69d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 18:08:05 -0400 Subject: [PATCH 39/79] fix: address auth boundary review findings --- .build/GraphKitAuth.tasks.ps1 | 54 +++++++++++++---- .github/workflows/ci.yml | 3 +- AGENTS.md | 26 ++++---- .../plans/2026-08-19-r1-outcome-composites.md | 2 +- .../plans/2026-08-30-r8-graphkit-auth.md | 2 +- .../2026-08-30-r8-graphkit-auth-design.md | 2 +- scripts/Get-GraphKitTrainVersion.ps1 | 4 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Publish-GraphKitPackage.ps1 | 60 ++++++++++++++----- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- scripts/private/GraphKit.AuthStageCapture.cs | 55 ++++++++++++++++- scripts/private/GraphKit.SourceCapture.cs | 42 ++++++++++++- .../private/Test-GraphKitPackagePrivacy.ps1 | 18 +++++- source/Data/Operations/Group.Get.psd1 | 8 +-- source/Private/Get-GraphVaultCredential.ps1 | 1 + .../Initialize-GraphModuleLifecycle.ps1 | 8 +++ source/Private/Invoke-GraphRetry.ps1 | 17 +++++- .../Private/TokenSources/GraphTokenSource.ps1 | 21 ++----- source/Private/Wait-GraphThrottleGate.ps1 | 24 ++++++-- source/Public/Register-GraphTenant.ps1 | 4 +- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 8 --- .../GraphTokenSourceTests.cs | 25 +++----- .../GraphKit.Auth.Tests/OwnershipTests.cs | 2 +- .../GraphKit.Auth/GraphTokenSource.cs | 30 +++++++--- .../GraphKit.Auth/MsalTokenClient.cs | 19 +++++- tests/Adapter/TokenIdentityPipeline.Tests.ps1 | 8 +-- tests/QA/GraphKitAuthPackage.tests.ps1 | 53 +++++++++++++++- tests/QA/ImportOrderMatrix.tests.ps1 | 1 + tests/QA/MinimumTestsRatchetSync.tests.ps1 | 8 ++- tests/QA/PackageDependencies.tests.ps1 | 2 +- tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 17 ++++-- tests/QA/SourceHygiene.tests.ps1 | 22 +++++-- tests/QA/TrainVersion.tests.ps1 | 17 ++++-- .../Auth/Get-GraphVaultCredential.Tests.ps1 | 1 + tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 18 ++++-- tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 | 22 +++---- .../Operations/DescriptorInvariants.Tests.ps1 | 13 ++-- .../Unit/Profiles/Get-GraphContext.Tests.ps1 | 7 ++- .../Profiles/Register-GraphTenant.Tests.ps1 | 10 ++++ .../Unit/Profiles/Test-GraphTenant.Tests.ps1 | 12 ++-- tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 | 2 +- tests/Unit/Throttle/ThrottleGate.Tests.ps1 | 41 ++++++++++++- .../TokenSources/GraphTokenSource.Tests.ps1 | 11 +++- .../Transport/Invoke-GraphRetry.Tests.ps1 | 10 +++- 45 files changed, 534 insertions(+), 182 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index c6fbd11..4f9df8b 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -59,12 +59,12 @@ function Initialize-GraphKitAuthStageCapture { throw "The generated GraphKit.Auth capture type '$expectedTypeName' already exists." } $compiled = @(Add-Type -TypeDefinition $template.Replace($marker, $namespace) -PassThru -ErrorAction Stop) - $matches = @($compiled | Where-Object FullName -CEQ $expectedTypeName) + $compiledMatches = @($compiled | Where-Object FullName -CEQ $expectedTypeName) $loaded = @([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ }) - if ($matches.Count -ne 1 -or $loaded.Count -ne 1 -or -not [object]::ReferenceEquals($matches[0], $loaded[0])) { + 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 = $matches[0] + $script:GraphKitAuthStageCaptureType = $compiledMatches[0] } function Get-GraphKitAuthOutputRoot { @@ -1179,6 +1179,7 @@ function New-GraphKitAuthAbiTestFixture { BaselineState = $baselineState StatusBefore = @($statusBefore) CreatedPaths = [Collections.Generic.List[string]]::new() + CreatedDirectories = [Collections.Generic.List[string]]::new() Completed = $false ExpectedEvidence = [ordered]@{} } @@ -1187,7 +1188,21 @@ function New-GraphKitAuthAbiTestFixture { $relativeFile = [string]$entry.Value $destinationFile = Join-Path $RepositoryRoot $relativeFile $destination = Split-Path $destinationFile -Parent - $null = [IO.Directory]::CreateDirectory($destination) + $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 ) @@ -1274,6 +1289,11 @@ function Remove-GraphKitAuthAbiTestFixture { '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) { @@ -1296,15 +1316,25 @@ function Remove-GraphKitAuthAbiTestFixture { } [IO.File]::Delete($file) } - $createdParents = @($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 - } | Sort-Object Length -Descending) + $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'") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86645cb..1379804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: 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 @@ -111,7 +112,7 @@ 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 1460 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1462 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index 7924cb8..f234cb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1460 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. +**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 1462 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. 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. @@ -58,9 +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. The approved end state - resolves it before parallel work begins; the post-`0.3.0` legacy PowerShell token source is - temporarily same-runspace-only until `GraphKit.Auth` supplies the compiled boundary. +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. @@ -78,10 +79,10 @@ Preserve these boundaries: - 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`. Until `GraphKit.Auth` lands, built-in PowerShell token sources must be created and - used in the same runspace; the public sender rejects a crossed 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. + `-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: @@ -123,11 +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. -- The target contract resolves immutable contexts before asynchronous/runspace work. Current - legacy PowerShell token sources are same-runspace-only fail-fast containment; do not enable or - claim cross-runspace context use until the compiled `GraphKit.Auth` source passes that gate. - Shared throttle state must remain 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/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 index 4ea1522..6f9cf63 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -98,7 +98,7 @@ Run: ```powershell ./build.ps1 -Tasks pack -Invoke-Pester ./tests/QA/PackageIdentity.tests.ps1,./tests/QA/ReleaseProof.tests.ps1 -Output Detailed +./build.ps1 -Tasks test ``` Expected: failures naming stable `0.3.0`, missing source revision, and prerelease package discovery. 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 index 21b2089..729e678 100644 --- a/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md +++ b/docs/superpowers/specs/2026-08-30-r8-graphkit-auth-design.md @@ -2,7 +2,7 @@ **Date:** 2026-08-30 -**Status:** Approved by the active end-to-end product-program goal. Deterministic implementation is complete and green; protected live parity, exact-SHA CI, and publication remain approval-gated. +**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 diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index b1872ba..3ded352 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -13,8 +13,8 @@ function Invoke-GraphKitGitBytes { $process = [Diagnostics.Process]::new(); $process.StartInfo = $start; $null = $process.Start() if ($InputBytes.Length) { $process.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) } $process.StandardInput.Close(); $output = [IO.MemoryStream]::new(); $process.StandardOutput.BaseStream.CopyTo($output) - $error = $process.StandardError.ReadToEnd(); $process.WaitForExit() - if ($process.ExitCode -notin $AllowedExitCodes) { throw "git $($Arguments -join ' ') failed: $error" } + $standardError = $process.StandardError.ReadToEnd(); $process.WaitForExit() + if ($process.ExitCode -notin $AllowedExitCodes) { throw "git $($Arguments -join ' ') failed: $standardError" } return ,$output.ToArray() } diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index e3e41f7..434e719 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1460 +$minimumTests = 1462 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Publish-GraphKitPackage.ps1 b/scripts/Publish-GraphKitPackage.ps1 index 20129c6..c85bdab 100644 --- a/scripts/Publish-GraphKitPackage.ps1 +++ b/scripts/Publish-GraphKitPackage.ps1 @@ -184,23 +184,17 @@ 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) { + if (Test-Path -LiteralPath $target -PathType Leaf) { $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() if ($existingHash -ceq $hash) { - Write-Host ' Already published with identical bytes; nothing to do.' -ForegroundColor Green + $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." } } - 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 - Write-Host " Published to $target" -ForegroundColor Green - } if (-not $SkipTestProof) { if (Test-Path -LiteralPath $proofTarget -PathType Leaf) { @@ -215,9 +209,34 @@ switch ($Channel) { $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 = [System.IO.Path]::GetFullPath($Destination) @@ -235,7 +254,7 @@ 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')) { $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 @@ -245,14 +264,23 @@ switch ($Channel) { throw "Release $tag already exists in $Destination. Publish a new version rather than replacing one under an existing pin, or pass -Force." } - $uploadArguments = @( + $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, $verifiedProofSnapshot.FullName, + $package.FullName, '--repo', $Destination ) - if ($Force) { $uploadArguments += '--clobber' } - & gh @uploadArguments - if ($LASTEXITCODE -ne 0) { throw "gh release upload failed for $Destination $tag." } + 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 } diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 62e6bd4..43fa6d1 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1460 +$minimumTests = 1462 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 91869f4..544fe6d 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -655,7 +655,7 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) } byte[] stat = new byte[256]; - if (fstat(handle.DangerousGetHandle().ToInt32(), stat) != 0) + if (InvokeUnixFStat(handle.DangerousGetHandle().ToInt32(), stat, path) != 0) { throw new IOException($"Could not fstat '{path}' (errno {Marshal.GetLastWin32Error()})."); } @@ -673,7 +673,19 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) inode = BitConverter.ToUInt64(stat, 8); length = BitConverter.ToInt64(stat, 96); } - else + 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); + length = BitConverter.ToInt64(stat, 48); + } + else if (OperatingSystem.IsLinux() && + RuntimeInformation.ProcessArchitecture == Architecture.X64) { device = BitConverter.ToUInt64(stat, 0); inode = BitConverter.ToUInt64(stat, 8); @@ -681,6 +693,11 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) mode = BitConverter.ToUInt32(stat, 24); 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; @@ -722,7 +739,7 @@ private static string GetUnixPhysicalPath(string path, string expectedIdentity, using SafeFileHandle rebound = OpenReadNoFollow(path, directory); byte[] stat = new byte[256]; - if (fstat(rebound.DangerousGetHandle().ToInt32(), stat) != 0) + if (InvokeUnixFStat(rebound.DangerousGetHandle().ToInt32(), stat, path) != 0) { throw new IOException($"Could not rebind physical path '{path}' (errno {Marshal.GetLastWin32Error()})."); } @@ -736,6 +753,35 @@ private static string GetUnixPhysicalPath(string path, string expectedIdentity, 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}'."); + } + return fstat(descriptor, stat); + } + 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); @@ -962,6 +1008,9 @@ private static extern uint GetFinalPathNameByHandleW(SafeFileHandle file, String [DllImport("libc", SetLastError = true)] private static extern int fstat(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); diff --git a/scripts/private/GraphKit.SourceCapture.cs b/scripts/private/GraphKit.SourceCapture.cs index 5a8b72d..2400c5a 100644 --- a/scripts/private/GraphKit.SourceCapture.cs +++ b/scripts/private/GraphKit.SourceCapture.cs @@ -415,6 +415,9 @@ internal static class UnixNative [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); @@ -473,7 +476,24 @@ internal static SourceMetadata GetMetadata(SafeFileHandle handle) int descriptor = handle.DangerousGetHandle().ToInt32(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - if (LinuxStatx(descriptor, string.Empty, AtEmptyPath | AtSymlinkNoFollow, RequiredStatxMask, out Statx metadata) != 0) + 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."); } @@ -493,7 +513,25 @@ internal static SourceMetadata GetMetadata(SafeFileHandle handle) if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - if (DarwinFStat(descriptor, out DarwinStat metadata) != 0) + 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."); } diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 index 0d37010..f6334bc 100644 --- a/scripts/private/Test-GraphKitPackagePrivacy.ps1 +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -188,6 +188,13 @@ function Get-GraphKitPackagePrivacyAllowedGuidSet { '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) @@ -227,12 +234,17 @@ function Test-GraphKitAuthSourcePrivacy { $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 - [System.IO.Path]::GetExtension($sourceFile.Name) -ine '.cs') { + -not $authoredTextExtensions.Contains([System.IO.Path]::GetExtension($sourceFile.Name))) { continue } @@ -267,7 +279,7 @@ function Test-GraphKitAuthSourcePrivacy { $text = $strictUtf8.GetString($bytes) } catch { - throw "GraphKit.Auth privacy scan rejected authored CSharp that is not strict UTF-8 (source sha256: $sourcePathDigest)." + 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) @@ -281,7 +293,7 @@ function Test-GraphKitAuthSourcePrivacy { } if ($sourceFilesScanned -eq 0) { - throw 'GraphKit.Auth privacy scan found no authored CSharp files and failed closed.' + throw 'GraphKit.Auth privacy scan found no authored project files and failed closed.' } return [pscustomobject] [ordered] @{ diff --git a/source/Data/Operations/Group.Get.psd1 b/source/Data/Operations/Group.Get.psd1 index 8bd3b54..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' diff --git a/source/Private/Get-GraphVaultCredential.ps1 b/source/Private/Get-GraphVaultCredential.ps1 index 51ed6d5..ff66dd1 100644 --- a/source/Private/Get-GraphVaultCredential.ps1 +++ b/source/Private/Get-GraphVaultCredential.ps1 @@ -343,6 +343,7 @@ function Assert-GraphSecretVersionSupported { 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." diff --git a/source/Private/Initialize-GraphModuleLifecycle.ps1 b/source/Private/Initialize-GraphModuleLifecycle.ps1 index e14cf72..6a0910d 100644 --- a/source/Private/Initialize-GraphModuleLifecycle.ps1 +++ b/source/Private/Initialize-GraphModuleLifecycle.ps1 @@ -460,6 +460,10 @@ public sealed class ModuleLifecycleState } 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() @@ -549,6 +553,10 @@ function Complete-GraphModuleCleanup { } function Stop-GraphModule { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Stops private in-process lifecycle state during module removal.' + )] [CmdletBinding()] param( [object] $State = $script:GraphKitModuleLifecycle, diff --git a/source/Private/Invoke-GraphRetry.ps1 b/source/Private/Invoke-GraphRetry.ps1 index 43731e4..94311f5 100644 --- a/source/Private/Invoke-GraphRetry.ps1 +++ b/source/Private/Invoke-GraphRetry.ps1 @@ -196,6 +196,15 @@ function Invoke-GraphRetry { } 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) @@ -606,7 +615,12 @@ function Invoke-GraphRetry { $requestedDelaySeconds = [double] $delayInfo.DelaySeconds $boundedDelaySeconds = [Math]::Min($requestedDelaySeconds, $remainingDelaySeconds) try { - & $delay $boundedDelaySeconds -CancellationToken $CancellationToken + if ($delayAcceptsCancellationToken) { + & $delay $boundedDelaySeconds $CancellationToken + } + else { + & $delay $boundedDelaySeconds + } } catch { $delayFailure = $_.Exception @@ -691,6 +705,7 @@ function Invoke-GraphRetry { Data = $data Outcome = $outcome Certainty = $certaintyFinal + Truncated = $false Telemetry = @($telemetry) Provenance = $provenance } diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index 0bda03d..b0fbe37 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -1058,23 +1058,14 @@ function New-GraphTokenSource { 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 $factoryProfile -Cloud $Cloud ` - -ExpectedCredentialGeneration $generation - } - 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( + $MsalFactory, 'Certificate', $audience, $clientId, $generation) } 'ClientSecret' { - $factory = $MsalFactory - if ($null -eq $factory) { - $factory = New-GraphMsalApplicationFactory -Profile $Profile -Cloud $Cloud ` - -ExpectedCredentialGeneration $generation - } - return [ConfidentialClientTokenSource]::new($factory, 'ClientSecret', $audience, $clientId, $generation) + return [ConfidentialClientTokenSource]::new( + $MsalFactory, 'ClientSecret', $audience, $clientId, $generation) } 'ManagedIdentity' { return [ManagedIdentityTokenSource]::new( diff --git a/source/Private/Wait-GraphThrottleGate.ps1 b/source/Private/Wait-GraphThrottleGate.ps1 index 5e7c4dd..da83400 100644 --- a/source/Private/Wait-GraphThrottleGate.ps1 +++ b/source/Private/Wait-GraphThrottleGate.ps1 @@ -70,9 +70,20 @@ function Wait-GraphThrottleGate { } # Production waits block on the token wait handle, so cancellation wakes the - # thread without polling or duration-based guesses. Injected delays retain the - # virtual-time seam and receive the same token; cancellation is checked again - # immediately after every injected step. + # 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) @@ -90,7 +101,12 @@ function Wait-GraphThrottleGate { } } else { - & $Delay -Milliseconds $Milliseconds -CancellationToken $CancellationToken + if ($delayAcceptsCancellationToken) { + & $Delay $Milliseconds $CancellationToken + } + else { + & $Delay $Milliseconds + } $CancellationToken.ThrowIfCancellationRequested() } }.GetNewClosure() diff --git a/source/Public/Register-GraphTenant.ps1 b/source/Public/Register-GraphTenant.ps1 index a4c3012..8dd963b 100644 --- a/source/Public/Register-GraphTenant.ps1 +++ b/source/Public/Register-GraphTenant.ps1 @@ -273,7 +273,9 @@ function Register-GraphTenant { $hasPasswordVault = -not [string]::IsNullOrEmpty($CertificatePasswordVaultName) $hasPasswordName = -not [string]::IsNullOrEmpty($CertificatePasswordSecretName) - if ($hasPasswordVault -ne $hasPasswordName) { + $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) { diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index 9415850..4653a82 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -9,8 +9,6 @@ namespace GraphKit.Auth; public sealed class GraphAuthHost : IDisposable { public const string ContractMarker = "GraphKit.Auth.Abi/1"; - - private const string ExpectedContractMarker = "GraphKit.Auth.Abi/1"; private const string FactoryTypeName = "GraphKit.Auth.GraphTokenSourceFactory"; private const int Running = 0; private const int ShutdownOwnerDisposingSources = 1; @@ -385,12 +383,6 @@ private static Assembly ValidateDefaultContractsAssembly(string physicalPayloadR $"the loaded contracts assembly is named '{loadedIdentity.Name}'."); } - if (!string.Equals(ContractMarker, ExpectedContractMarker, StringComparison.Ordinal)) - { - throw IncompatibleContracts( - $"the loaded contract marker is '{ContractMarker}', not '{ExpectedContractMarker}'."); - } - string candidatePath = Path.Combine( physicalPayloadRoot, GraphAuthLoadContext.ContractsFileName); diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs index 903d541..546fc55 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs @@ -47,12 +47,13 @@ public void OrdinaryAcquireReusesAValidCachedResult() } [Theory] - [InlineData(600, 60)] - [InlineData(3600, 300)] - [InlineData(7200, 300)] + [InlineData(600, 60, 1.7647058823529411)] + [InlineData(3600, 300, 8.823529411764707)] + [InlineData(7200, 300, 8.823529411764707)] public void AdaptiveRefreshUsesTheBoundedLifetimeSkew( int lifetimeSeconds, - int expectedBaseSkewSeconds) + int expectedBaseSkewSeconds, + double expectedSpreadSeconds) { var clock = new FakeClock(InitialNow); GraphTokenResult first = Result( @@ -65,14 +66,13 @@ public void AdaptiveRefreshUsesTheBoundedLifetimeSkew( using var source = CreateRefreshableSource(client, clock); source.Acquire(false, CancellationToken.None); - double spread = EarlySpreadSeconds(first.TokenFingerprint, expectedBaseSkewSeconds); clock.UtcNow = first.ExpiresOnUtc - .AddSeconds(-(expectedBaseSkewSeconds + spread)) + .AddSeconds(-(expectedBaseSkewSeconds + expectedSpreadSeconds)) .AddMilliseconds(-1); Assert.Equal("adaptive", source.Acquire(false, CancellationToken.None).AccessToken); clock.UtcNow = first.ExpiresOnUtc - .AddSeconds(-(expectedBaseSkewSeconds + spread)) + .AddSeconds(-(expectedBaseSkewSeconds + expectedSpreadSeconds)) .AddMilliseconds(1); Assert.Equal("refreshed", source.Acquire(false, CancellationToken.None).AccessToken); Assert.Equal(2, client.AcquireCount); @@ -89,9 +89,8 @@ public void FingerprintDerivedSpreadRefreshesEarlierAndDeterministically() using var source = CreateRefreshableSource(client, clock); source.Acquire(false, CancellationToken.None); - double spread = EarlySpreadSeconds(first.TokenFingerprint, 60); - Assert.InRange(spread, 0, 6); - clock.UtcNow = first.ExpiresOnUtc.AddSeconds(-(60 + spread)); + const double expectedSpreadSeconds = 1.4588235294117646; + clock.UtcNow = first.ExpiresOnUtc.AddSeconds(-(60 + expectedSpreadSeconds)); Assert.Equal("replacement", source.Acquire(false, CancellationToken.None).AccessToken); } @@ -594,12 +593,6 @@ internal static string Fingerprint(string token) .ToLowerInvariant(); } - private static double EarlySpreadSeconds(string fingerprint, double baseSkewSeconds) - { - int bucket = Convert.ToInt32(fingerprint[..2], 16); - return baseSkewSeconds * 0.1 * (bucket / 255d); - } - internal sealed class FakeClock(DateTimeOffset utcNow) { internal DateTimeOffset UtcNow { get; set; } = utcNow; diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs index 592d076..6ff3608 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs @@ -326,7 +326,7 @@ public async Task ConcurrentOwnedCredentialReuseHasOneWinnerAndOneDisposal(Graph Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); second = Task.Run(() => CaptureCreate(secondFactory, duplicateRequest)); _ = await second.WaitAsync(TimeSpan.FromSeconds(5)); - duplicateRejectedBeforeWinnerCompleted = second.IsCompleted; + duplicateRejectedBeforeWinnerCompleted = !first.IsCompleted; } catch (Exception exception) { diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs index 3131428..0bde2de 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs @@ -11,6 +11,7 @@ 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(); @@ -153,7 +154,15 @@ public void Dispose() // continues and only a sanitized lifecycle failure may cross the ABI. } - _operationsDrained.Wait(); + 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); @@ -456,11 +465,14 @@ private TResult Read(Func read) private OperationLease BeginOperation(CancellationToken callerCancellation) { - ThrowIfDisposed(); - int active = Interlocked.Increment(ref _activeOperations); - if (active == 1) + lock (_drainGate) { - _operationsDrained.Reset(); + ThrowIfDisposed(); + _activeOperations++; + if (_activeOperations == 1) + { + _operationsDrained.Reset(); + } } try @@ -480,9 +492,13 @@ private OperationLease BeginOperation(CancellationToken callerCancellation) private void ExitOperation() { - if (Interlocked.Decrement(ref _activeOperations) == 0) + lock (_drainGate) { - _operationsDrained.Set(); + _activeOperations--; + if (_activeOperations == 0) + { + _operationsDrained.Set(); + } } } diff --git a/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs b/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs index 5d0885b..8a918f1 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs @@ -141,7 +141,7 @@ public GraphTokenResult Acquire( result.AccessToken, result.ExpiresOn, _utcNow(), - _scope, + result.Scopes, _credentialGeneration); } catch (OperationCanceledException) @@ -271,9 +271,24 @@ internal static GraphTokenResult Create( 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 { @@ -285,7 +300,7 @@ internal static GraphTokenResult Create( ExpiresOnUtc = expiresOnUtc, ReceivedOnUtc = receivedOnUtc, TokenType = "Bearer", - Scopes = [scope], + Scopes = grantedScopes, VerifiedTenantId = null, TokenFingerprint = fingerprint, CredentialGeneration = credentialGeneration diff --git a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 index fbb7703..526d1bd 100644 --- a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 +++ b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 @@ -78,14 +78,14 @@ BeforeAll { if ($null -eq $Server) { return @() } $captured = @() - 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.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 { } diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 93e3e12..eb855c1 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -252,7 +252,13 @@ BeforeAll { '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); [IO.File]::Delete($targetPath); [IO.File]::WriteAllBytes($targetPath, $bytes) } + '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') $null = New-Item -ItemType HardLink -Path $outsideLink -Target $targetPath -ErrorAction Stop @@ -531,7 +537,11 @@ $defaultMsalReferenceUnchanged = $defaultMsalAfter.Count -eq 1 -and 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 - Test-Path -LiteralPath (Join-Path $script:repoRoot 'scripts/private/GraphKit.AuthStageCapture.cs') -PathType Leaf | Should -BeTrue + $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\(' { Assert-GraphKitAuthStageCommands } | Should -Not -Throw } @@ -1274,9 +1284,22 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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 - [IO.Directory]::Move($versionRootB, (Join-Path $stageRootA $upperVersion)) + try { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootA, $true, $true) + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootB, $true, $true) + [IO.Directory]::Move($versionRootB, (Join-Path $stageRootA $upperVersion)) + } + finally { + if (Test-Path -LiteralPath $stageRootA -PathType Container) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootA, $true, $false) + } + if (Test-Path -LiteralPath $stageRootB -PathType Container) { + $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootB, $true, $false) + } + } $movedSecondStage = Join-Path (Join-Path $stageRootA $upperVersion) ([IO.Path]::GetFileName($second.StagePath)) { Test-GraphKitAuthSealedStage -StagePath $first.StagePath -FullVersion $lowerVersion } | Should -Not -Throw @@ -1518,6 +1541,30 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { [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 'deletes only a recorded projection and preserves an unrecorded partial materialization' { Initialize-GraphKitAuthStageCapture $root = Join-Path $TestDrive ('projection-partial-state-' + [guid]::NewGuid().ToString('N')) diff --git a/tests/QA/ImportOrderMatrix.tests.ps1 b/tests/QA/ImportOrderMatrix.tests.ps1 index d6d5aff..1cf32f4 100644 --- a/tests/QA/ImportOrderMatrix.tests.ps1 +++ b/tests/QA/ImportOrderMatrix.tests.ps1 @@ -214,6 +214,7 @@ if (`$msal) { `$result.DetectedMsalVersion = `$msal.GetName().Version.ToString() 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 diff --git a/tests/QA/MinimumTestsRatchetSync.tests.ps1 b/tests/QA/MinimumTestsRatchetSync.tests.ps1 index 83660b5..088bc98 100644 --- a/tests/QA/MinimumTestsRatchetSync.tests.ps1 +++ b/tests/QA/MinimumTestsRatchetSync.tests.ps1 @@ -53,7 +53,13 @@ Describe 'MinimumTests ratchet synchronization' -Tag 'QA' { if ($LASTEXITCODE -ne 0) { throw "Independent Pester discovery failed: $($discoveryOutput -join ' ')" } - $discovery = $discoveryOutput[-1] | ConvertFrom-Json + $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 } diff --git a/tests/QA/PackageDependencies.tests.ps1 b/tests/QA/PackageDependencies.tests.ps1 index 9dff84e..e81c336 100644 --- a/tests/QA/PackageDependencies.tests.ps1 +++ b/tests/QA/PackageDependencies.tests.ps1 @@ -109,7 +109,7 @@ Describe 'Packed GraphKit dependency contract' -Tag 'QA' { $result.ExitCode | Should -Be 0 -Because $result.Output $result.Data.Imported | Should -BeTrue - $result.Data.ModuleBase | Should -Be (Join-Path $modulePath "GraphKit/$script:baseVersion") + $result.Data.ModuleBase | Should -Be (Join-Path (Join-Path $modulePath 'GraphKit') $script:baseVersion) $result.Data.OperationName | Should -Be 'ManagedDevice.List' $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' diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index cfa30fd..61a6bf7 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 = 1460) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1462) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 20c8384..ce03da6 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -150,7 +150,7 @@ BeforeAll { [switch] $IncludeGraphKitAuth, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1460 + [int] $Total = 1462 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -365,7 +365,7 @@ $requiredAssembliesLine RequiredModules = @(@{ ModuleName = 'Microsoft.Graph. sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1460 + minimumTests = 1462 allowedSkips = 0 allowedNotRun = 0 } @@ -1053,7 +1053,7 @@ Describe 'Test workflow release-proof generation' { $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 1460 + $proof.testRun.summary.total | Should -Be 1462 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse } @@ -1324,6 +1324,15 @@ internal static class PrivateFixture { } $privatePublisher = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/Publish-GraphKitPackage.ps1') -Raw $privatePublisher | Should -Not -Match '--clobber:' - $privatePublisher | Should -Match '\$uploadArguments \+= ''--clobber''' + $privatePublisher | Should -Match '\$proofUploadArguments \+= ''--clobber''' + $privatePublisher | Should -Match '\$packageUploadArguments \+= ''--clobber''' + $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/SourceHygiene.tests.ps1 b/tests/QA/SourceHygiene.tests.ps1 index 77de48b..299754f 100644 --- a/tests/QA/SourceHygiene.tests.ps1 +++ b/tests/QA/SourceHygiene.tests.ps1 @@ -13,14 +13,15 @@ BeforeAll { . (Join-Path $script:repoRoot 'scripts/private/Test-GraphKitPackagePrivacy.ps1') } -Describe 'GraphKit.Auth authored CSharp privacy' { +Describe 'GraphKit.Auth authored project-source privacy' { - It 'passes the reusable strict source privacy scan for every authored CSharp file' { + 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 -ieq '.cs' -and + $_.Extension -iin $authoredExtensions -and $_.FullName -notmatch '[\\/](?:bin|obj)[\\/]' } ) @@ -34,18 +35,27 @@ Describe 'GraphKit.Auth authored CSharp privacy' { @($result.Findings).Count | Should -Be 0 } - It 'fails closed when an authored CSharp file is not strict UTF-8' { + 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.cs'), - [byte[]] @(0x63, 0x6c, 0x61, 0x73, 0x73, 0x20, 0xc3, 0x28) + (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 diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index 49b566d..8cc0211 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -627,8 +627,8 @@ $source Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 - $result.Output | Should -Match 'source-capture helper inside RepositoryRoot requires' - $result.Output | Should -Match 'exactly one exact raw inventory record' + $result.Output | Should -Match 'source-capture helper inside RepositoryRoot requires|Cannot root-anchored no-follow capture source entry' + $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment.*Scripts' } It 'binds a physically internal proof helper when RepositoryRoot is a Unix symlink or Windows junction alias' { @@ -647,8 +647,8 @@ $source Assert-R8PortableGitShimInvoked -ShimDirectory $shimDirectory $result.ExitCode | Should -Not -Be 0 - $result.Output | Should -Match 'source-capture helper inside RepositoryRoot requires' - $result.Output | Should -Match 'exactly one exact raw inventory record' + $result.Output | Should -Match 'source-capture helper inside RepositoryRoot requires|Cannot root-anchored no-follow capture source entry' + $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment.*Scripts' } It 'allows a genuinely external proof helper when RepositoryRoot is a filesystem alias' { @@ -1050,8 +1050,8 @@ $source } finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } - $state.sourceStateSha256 | Should -Be '514a2ebe272e5d6e099617f784559b6056f8f8b2600a203ad777df08cbe605ec' - $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d514a2ebe272e$' + $state.sourceStateSha256 | Should -Be 'a3bf0d85293ed96fd0b8fbef7336beb2dccdc081bb2d878386d2fa5cb46dba10' + $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.da3bf0d85293e$' } } @@ -1059,11 +1059,16 @@ 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 $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\)' } It 'rejects Windows reserved-device, ADS, and suspicious short-alias path forms without a platform skip' { diff --git a/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 b/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 index 57f33f0..d438bc4 100644 --- a/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 +++ b/tests/Unit/Auth/Get-GraphVaultCredential.Tests.ps1 @@ -145,6 +145,7 @@ Describe 'Get-GraphVaultCredential' { } } | 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 } diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index 9b5b008..55ffe1d 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -2245,11 +2245,19 @@ switch ($Scenario) { } 'SamePathReplacement' { $residentMvid = [GraphKit.Auth.GraphAuthHost].Assembly.ManifestModule.ModuleVersionId.ToString('D') - [IO.File]::Copy( - (Resolve-Path -LiteralPath $ReplacementContractsPath).ProviderPath, - (Resolve-Path -LiteralPath $ContractsPath).ProviderPath, - $true - ) + $resolvedReplacement = (Resolve-Path -LiteralPath $ReplacementContractsPath).ProviderPath + $resolvedContracts = (Resolve-Path -LiteralPath $ContractsPath).ProviderPath + if ($IsWindows) { + [IO.File]::Copy($resolvedReplacement, $resolvedContracts, $true) + } + else { + # Never truncate an assembly that CoreCLR may have memory-mapped: Linux can + # terminate with SIGBUS when a mapped page disappears. An atomic rename gives + # the path new bytes while the resident assembly retains its original inode. + $atomicReplacement = "$resolvedContracts.replacement.$([guid]::NewGuid().ToString('N'))" + [IO.File]::Copy($resolvedReplacement, $atomicReplacement) + [IO.File]::Move($atomicReplacement, $resolvedContracts, $true) + } $message = Get-Rejection { $replacementHost = [GraphKit.Auth.GraphAuthHost]::new($PayloadRoot, [version]'1.0.0.0', [timespan]::FromSeconds(2)) $replacementHost.Dispose() diff --git a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 index 21b3398..feabc8f 100644 --- a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 @@ -5,11 +5,11 @@ function global:Get-Task7JsonProperty { [Parameter(Mandatory)] [string] $Location ) - $matches = @($Element.EnumerateObject() | Where-Object Name -CEQ $Name) - if ($matches.Count -ne 1) { + $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 $matches[0].Value + return $propertyMatches[0].Value } function global:Assert-Task7NoDuplicateJsonProperties { @@ -326,12 +326,12 @@ function global:Read-Task7ParityMatrixJson { ) } - $input = Get-Task7JsonProperty -Element $row -Name input -Location "row '$id'" - Assert-Task7NoDuplicateJsonProperties -Element $input -Location "row '$id'.input" - Assert-Task7ExactJsonFields -Element $input -Expected $inputFields ` + $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 $input -Name $name -Location "row '$id'.input" + $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') { @@ -343,11 +343,11 @@ function global:Read-Task7ParityMatrixJson { } } } - $inputFlags = Get-Task7JsonProperty -Element $input -Name forceFlags ` + $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 $input -Name cancelCaller ` + $cancel = Get-Task7JsonProperty -Element $inputElement -Name cancelCaller ` -Location "row '$id'.input" Assert-Task7JsonKind -Element $cancel -Allowed @('True', 'False') ` -Location "row '$id'.input.cancelCaller" @@ -355,13 +355,13 @@ function global:Read-Task7ParityMatrixJson { 'fingerprintInput', 'adoptToken', 'adoptGeneration', 'adoptReceivedOnUtc', 'adoptExpiresOnUtc', 'adoptTenantProof' )) { - $value = Get-Task7JsonProperty -Element $input -Name $name ` + $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 $input -Name $name ` + $value = Get-Task7JsonProperty -Element $inputElement -Name $name ` -Location "row '$id'.input" if ($value.ValueKind -eq [System.Text.Json.JsonValueKind]::String) { Assert-Task7StrictTimestamp -Element $value ` diff --git a/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 b/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 index 0686e8c..cec4694 100644 --- a/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 +++ b/tests/Unit/Operations/DescriptorInvariants.Tests.ps1 @@ -334,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' } @@ -363,4 +361,3 @@ Describe 'The TenantPulse-unblocking reads keep their official paths' { $d.ApiVersion | Should -Be 'v1.0' } } - diff --git a/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 index 88bbc4f..619efa6 100644 --- a/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 +++ b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 @@ -29,9 +29,12 @@ public static class Task6CredentialFixture using X509Certificate2 source = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); - return X509CertificateLoader.LoadPkcs12( +#pragma warning disable SYSLIB0057 + return new X509Certificate2( source.Export(X509ContentType.Pkcs12), - password: null); + (string)null, + X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 } public static SecureString CreateSecret() diff --git a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 index 1c20ad1..c0d0a26 100644 --- a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 @@ -66,6 +66,16 @@ Describe 'Register-GraphTenant' { -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' { diff --git a/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 index 0f609c0..1e058a5 100644 --- a/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 @@ -110,17 +110,17 @@ Describe 'Test-GraphTenant' { Credential = @{ ManagedIdentityClientId = '11111111-2222-3333-4444-555555555555' } } ) { - $profile = @{ + $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) { - $profile.ManagedIdentityClientId = $TopLevelSelector + $tenantProfileUnderTest.ManagedIdentityClientId = $TopLevelSelector } - Test-GraphTenant -TenantProfile $profile | Should -BeFalse + Test-GraphTenant -TenantProfile $tenantProfileUnderTest | Should -BeFalse } It 'rejects a present canonical nested selector with a null, empty, or whitespace value' -ForEach @( @@ -167,20 +167,20 @@ Describe 'Test-GraphTenant' { } ) { $credential = @{} - $profile = @{ + $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') { - $profile[$SelectorName] = $null + $tenantProfileUnderTest[$SelectorName] = $null } else { $credential[$SelectorName] = $null } - Test-GraphTenant -TenantProfile $profile | Should -BeFalse + Test-GraphTenant -TenantProfile $tenantProfileUnderTest | Should -BeFalse } It 'documents false plus corrective re-registration for invalid successor metadata' { diff --git a/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 index cf12457..17cfa9e 100644 --- a/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 @@ -26,7 +26,7 @@ Describe 'Use-GraphTenant' { AuthMethod = 'ClientSecret' Material = ConvertTo-SecureString 'use-graph-tenant-test' -AsPlainText -Force OwnsMaterial = $true - CredentialGeneration = 'g1|ClientSecret|use-graph-tenant-test' + CredentialGeneration = 'g1|ClientSecret|fixture' } } } diff --git a/tests/Unit/Throttle/ThrottleGate.Tests.ps1 b/tests/Unit/Throttle/ThrottleGate.Tests.ps1 index 55239d2..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 @@ -169,14 +200,18 @@ Describe 'Wait-GraphThrottleGate' { $coordinator.ApplyCooldown($scope.CoarseKey, 60, $UtcNow) $cts = [System.Threading.CancellationTokenSource]::new() $cts.Cancel() - $delayCalls = 0 + $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 { param($Milliseconds) $delayCalls++ } + -Delay $delay } catch { $failure = $_.Exception @@ -194,7 +229,7 @@ Describe 'Wait-GraphThrottleGate' { [pscustomobject] @{ IsCancellation = $isCancellation - DelayCalls = $delayCalls + DelayCalls = $delayCalls.Count InFlight = $coordinator.GetInFlight($scope.LeafKey) } } diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index 13d8b16..63e2739 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -30,7 +30,12 @@ public static class Task6CredentialFixture using X509Certificate2 source = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); - return X509CertificateLoader.LoadPkcs12(source.Export(X509ContentType.Pkcs12), null); +#pragma warning disable SYSLIB0057 + return new X509Certificate2( + source.Export(X509ContentType.Pkcs12), + (string)null, + X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 } public static SecureString CreateSecret() @@ -135,10 +140,12 @@ public static class Task6PfxFixture public static string GetThumbprint(byte[] pfx, string password) { - using X509Certificate2 certificate = X509CertificateLoader.LoadPkcs12( +#pragma warning disable SYSLIB0057 + using X509Certificate2 certificate = new( pfx, password, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 return certificate.Thumbprint; } } diff --git a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 index 3cc2beb..e9d0c56 100644 --- a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 +++ b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 @@ -118,7 +118,12 @@ 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 } } } @@ -714,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 } @@ -725,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' { From f69e4364e23b10edebd992baf6b3a16cd8b54aaa Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 18:58:18 -0400 Subject: [PATCH 40/79] fix: close r8 review and portability gaps --- .build/GraphKitAuth.tasks.ps1 | 3 + scripts/Invoke-GraphKitAuthParity.ps1 | 7 +- scripts/Publish-GraphKitPackage.ps1 | 4 +- scripts/Publish-GraphKitToGallery.ps1 | 7 ++ scripts/private/GraphKit.AuthStageCapture.cs | 47 +++++++++--- .../private/Test-GraphKitPackagePrivacy.ps1 | 49 +++++++++++-- source/Public/Get-GraphContext.ps1 | 3 + .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 68 ++++++++++-------- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 29 ++++++++ tests/QA/GraphKitAuthPackage.tests.ps1 | 22 +++++- tests/QA/PackageIdentity.tests.ps1 | 6 ++ tests/QA/PublishChannel.tests.ps1 | 12 ++++ tests/QA/ReleaseProof.tests.ps1 | 17 +++++ tests/QA/TrainVersion.tests.ps1 | 12 +++- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 72 +++++++++++++++++++ .../Unit/Profiles/Get-GraphContext.Tests.ps1 | 11 +++ 16 files changed, 316 insertions(+), 53 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index 4f9df8b..f8e88e7 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -1237,6 +1237,9 @@ function New-GraphKitAuthAbiTestFixture { 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.' diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 3063620..e95df47 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -32,6 +32,8 @@ $script:GraphKitAuthParityAdapterChecks = @( 'providerMsalSameContext','publicAbiExact') $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' +$script:GraphKitAuthParityExpectedNativeSourceSha256 = + '3a6e486e888fdb81044105094546884fe5c662afa55025b55c4850bf2c0e444e' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -704,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) @@ -727,6 +729,9 @@ H4sIAAAAAAAAE+09a3PbOJLf8ysQVWpM1Sga28lmctZoch7HTlyb2C7L2dzdTCoFk5DFC0Vq+PBjbf/3 $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" diff --git a/scripts/Publish-GraphKitPackage.ps1 b/scripts/Publish-GraphKitPackage.ps1 index c85bdab..660c4a6 100644 --- a/scripts/Publish-GraphKitPackage.ps1 +++ b/scripts/Publish-GraphKitPackage.ps1 @@ -285,7 +285,9 @@ switch ($Channel) { } $publishedSource = "https://github.com/$Destination/releases/tag/$tag" - $publishedProofSource = "https://github.com/$Destination/releases/download/$tag/$proofAssetName" + if (-not $SkipTestProof) { + $publishedProofSource = "https://github.com/$Destination/releases/download/$tag/$proofAssetName" + } } } diff --git a/scripts/Publish-GraphKitToGallery.ps1 b/scripts/Publish-GraphKitToGallery.ps1 index dd7ca4e..ddf7f4b 100644 --- a/scripts/Publish-GraphKitToGallery.ps1 +++ b/scripts/Publish-GraphKitToGallery.ps1 @@ -191,6 +191,13 @@ if ($packageExists) { 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)" diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 544fe6d..e3fa848 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -59,10 +59,28 @@ public static class GraphKitAuthStageCapture private const uint FileAttributeReparsePoint = 0x00000400; public static GraphKitAuthPathEvidence InspectFile(string rootPath, string relativePath) - => Inspect(rootPath, relativePath, expectDirectory: false); + => 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); + => Inspect(rootPath, relativePath, expectDirectory: true, hashContent: false); public static bool HasInitialOwnerOnlyAccess(GraphKitAuthPathEvidence evidence) { @@ -207,7 +225,8 @@ public static GraphKitAuthCopyEvidence CopyFileCreateNew( string sourceRelativePath, string destinationRoot, string destinationRelativePath, - bool requireInitialOwnerOnly = false) + bool requireInitialOwnerOnly = false, + long maximumLength = long.MaxValue) { string sourcePath = ResolveRelative(sourceRoot, sourceRelativePath); string destinationPath = ResolveRelative(destinationRoot, destinationRelativePath); @@ -220,6 +239,10 @@ public static GraphKitAuthCopyEvidence CopyFileCreateNew( { 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); SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; @@ -234,11 +257,16 @@ public static GraphKitAuthCopyEvidence CopyFileCreateNew( long offset = 0; while (offset < sourceBefore.Length) { - int read = RandomAccess.Read(sourceHandle, buffer, offset); + 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; } @@ -442,19 +470,22 @@ public static void MoveDirectoryCreateNew( private static GraphKitAuthPathEvidence Inspect( string rootPath, string relativePath, - bool expectDirectory) + bool expectDirectory, + bool hashContent) { string fullPath = ResolveRelative(rootPath, relativePath); EnsureAncestors(rootPath, relativePath); using SafeFileHandle handle = OpenReadNoFollow(fullPath, expectDirectory); - return EvidenceFromHandle(handle, fullPath, relativePath, expectDirectory); + return EvidenceFromHandle( + handle, fullPath, relativePath, expectDirectory, hashContent); } private static GraphKitAuthPathEvidence EvidenceFromHandle( SafeFileHandle handle, string fullPath, string relativePath, - bool expectDirectory) + bool expectDirectory, + bool hashContent = true) { NativeFacts before = GetNativeFacts(handle, fullPath); if (before.IsDirectory != expectDirectory || @@ -465,7 +496,7 @@ private static GraphKitAuthPathEvidence EvidenceFromHandle( } string hash = string.Empty; - if (!expectDirectory) + if (!expectDirectory && hashContent) { hash = HashHandle(handle, before.Length); } diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 index f6334bc..14ea477 100644 --- a/scripts/private/Test-GraphKitPackagePrivacy.ps1 +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -304,6 +304,46 @@ function Test-GraphKitAuthSourcePrivacy { } } +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( @@ -371,21 +411,16 @@ function Test-GraphKitPackagePrivacy { } $entryStream = $entry.Open() - $memory = [System.IO.MemoryStream]::new() try { - $entryStream.CopyTo($memory) - $bytes = $memory.ToArray() + $bytes = Read-GraphKitPackagePrivacyEntryBytesBounded ` + -EntryStream $entryStream -DeclaredLength ([long] $entry.Length) } catch { throw "Package privacy scan failed closed while reading an entry (entry sha256: $entryDigest)." } finally { - $memory.Dispose() $entryStream.Dispose() } - if ($bytes.LongLength -ne $entry.Length) { - throw "Package privacy scan rejected an entry whose byte count changed while reading (entry sha256: $entryDigest)." - } if ($extension -ine '.dll') { try { diff --git a/source/Public/Get-GraphContext.ps1 b/source/Public/Get-GraphContext.ps1 index 74afb04..3c09705 100644 --- a/source/Public/Get-GraphContext.ps1 +++ b/source/Public/Get-GraphContext.ps1 @@ -113,6 +113,9 @@ 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 } diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index 4653a82..eb61aed 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -129,52 +129,58 @@ public IGraphTokenSource CreateSource(GraphTokenRequest request) bool providerFactoryInvoked = false; try { + IGraphTokenSourceFactory factory; lock (_gate) { ThrowIfStopping(); - IGraphTokenSourceFactory factory = _factory ?? + 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."); - } + IGraphTokenSource? source; + try + { + providerFactoryInvoked = true; + source = factory.Create(request); + } + catch (Exception exception) + { + throw ProviderBoundaryFailure.Recreate( + exception, + CancellationToken.None, + "provider_construction_failed", + "Provider"); + } - try + 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 { - try - { - source.Dispose(); - } - catch - { - throw CreateProviderDisposalFailure(); - } - - throw; + throw CreateProviderDisposalFailure(); } + + throw; } } catch diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 15b2fd5..56286e3 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -1702,6 +1702,35 @@ Describe 'Task 8 protected GraphKit.Auth parity runner contract' { $functionNames | Should -Contain 'Get-GraphKitAuthParityPublicAbiSha256' $runnerText = [IO.File]::ReadAllText($script:runnerPath) + $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( diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index eb855c1..5064336 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -537,11 +537,14 @@ $defaultMsalReferenceUnchanged = $defaultMsalAfter.Count -eq 1 -and 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\(' + $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 } @@ -1287,12 +1290,27 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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) - [IO.Directory]::Move($versionRootB, (Join-Path $stageRootA $upperVersion)) + # 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) { $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootA, $true, $false) } @@ -1300,7 +1318,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootB, $true, $false) } } - $movedSecondStage = Join-Path (Join-Path $stageRootA $upperVersion) ([IO.Path]::GetFileName($second.StagePath)) + $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 } | diff --git a/tests/QA/PackageIdentity.tests.ps1 b/tests/QA/PackageIdentity.tests.ps1 index c8c8013..7899473 100644 --- a/tests/QA/PackageIdentity.tests.ps1 +++ b/tests/QA/PackageIdentity.tests.ps1 @@ -8,7 +8,13 @@ BeforeAll { $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." + } $script:builtManifestPath = Join-Path $script:repoRoot "output/module/GraphKit/$script:baseVersion/GraphKit.psd1" $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$script:expectedVersion.nupkg" diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index 61a6bf7..26f4772 100644 --- a/tests/QA/PublishChannel.tests.ps1 +++ b/tests/QA/PublishChannel.tests.ps1 @@ -142,5 +142,17 @@ Describe 'Publish-GraphKitPackage refusals' { $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/*' } } diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index ce03da6..de6c1d4 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -1217,6 +1217,20 @@ Describe 'Both publisher paths consume the canonical proof verifier' { $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' { @@ -1323,9 +1337,12 @@ internal static class PrivateFixture { $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) diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index 8cc0211..28e9bda 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -409,7 +409,11 @@ internal static class GitShimLauncher if ($source.Contains($marker)) { $namespace = 'GraphKit.R8.QA.N' + [guid]::NewGuid().ToString('N') $types = @(Add-Type -TypeDefinition $source.Replace($marker, $namespace) -PassThru) - $script:sourceCaptureType = @($types | Where-Object FullName -CEQ "$namespace.SourceCapture") + $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])) { @@ -628,7 +632,7 @@ $source 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' - $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment.*Scripts' + $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment[\s\S]*Scripts' } It 'binds a physically internal proof helper when RepositoryRoot is a Unix symlink or Windows junction alias' { @@ -648,7 +652,7 @@ $source 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' - $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment.*Scripts' + $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment[\s\S]*Scripts' } It 'allows a genuinely external proof helper when RepositoryRoot is a filesystem alias' { @@ -1058,6 +1062,8 @@ $source 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 diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index 55ffe1d..4061560 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -583,6 +583,7 @@ public static class GraphKitAuthRuntimeHarness 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 @@ -593,6 +594,7 @@ public static class GraphKitAuthRuntimeHarness StoppedHost = stopped, MissingFactory = missingFactory, PostProviderFailure = postProvider, + BlockedFactoryShutdown = blockedFactoryShutdown, SanitizedCleanupFailure = sanitized, WeakKeys = weakKeys }); @@ -787,6 +789,70 @@ public static class GraphKitAuthRuntimeHarness } } + 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); @@ -3202,6 +3268,12 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { $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' diff --git a/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 index 619efa6..fc3e128 100644 --- a/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 +++ b/tests/Unit/Profiles/Get-GraphContext.Tests.ps1 @@ -257,6 +257,17 @@ Describe 'Get-GraphContext' { $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() From 479989e46335070557be4fe05cbe31583ec7de85 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 20:08:21 -0400 Subject: [PATCH 41/79] fix: resolve r8 cross-platform review findings --- build.ps1 | 38 +++++++++++++++-- scripts/Get-GraphKitTrainVersion.ps1 | 8 +++- scripts/Invoke-GraphKitAuthParity.ps1 | 4 +- scripts/Publish-GraphKitPackage.ps1 | 6 +-- scripts/private/GraphKit.AuthStageCapture.cs | 11 ++++- scripts/private/GraphKit.SourceCapture.cs | 6 ++- .../private/Test-GraphKitPackagePrivacy.ps1 | 3 +- .../Initialize-GraphModuleLifecycle.ps1 | 32 +++++++++++---- .../TokenSources/New-GraphAuthTokenSource.ps1 | 38 ++++++++++++----- source/Public/Test-GraphTenant.ps1 | 5 ++- .../GraphTokenSourceParityTests.cs | 5 ++- .../GraphKit.Auth/GraphTokenSource.cs | 5 ++- .../GraphModuleLifecycleSender.Tests.ps1 | 2 +- .../GraphKitAuthRunspace.Tests.ps1 | 8 ++-- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 16 ++++---- tests/QA/GraphKitAuthPackage.tests.ps1 | 14 ++++++- tests/QA/PackageIdentity.tests.ps1 | 41 +++++++++++++++++++ tests/QA/PublishChannel.tests.ps1 | 9 ++++ tests/QA/ReleaseProof.tests.ps1 | 4 +- tests/QA/SourceHygiene.tests.ps1 | 2 +- tests/QA/TrainVersion.tests.ps1 | 34 ++++++++++++++- tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 | 2 +- .../Profiles/Register-GraphTenant.Tests.ps1 | 3 +- .../Unit/Profiles/Test-GraphTenant.Tests.ps1 | 16 ++++++++ .../Transport/GraphModuleLifecycle.Tests.ps1 | 39 ++++++++++++++---- .../Transport/Invoke-GraphRetry.Tests.ps1 | 4 +- 26 files changed, 287 insertions(+), 68 deletions(-) diff --git a/build.ps1 b/build.ps1 index 9e68da7..454929d 100755 --- a/build.ps1 +++ b/build.ps1 @@ -379,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) { @@ -540,7 +571,8 @@ begin Write-Verbose -Message "Bootstrap completed. Handing back to InvokeBuild." $versionScript = Join-Path $PSScriptRoot 'scripts/Get-GraphKitTrainVersion.ps1' - $env:ModuleVersion = (& $versionScript -RepositoryRoot $PSScriptRoot).Trim() + $env:ModuleVersion = Get-GraphKitValidatedTrainVersion ` + -VersionScript $versionScript -RepositoryRoot $PSScriptRoot if ($PSBoundParameters.ContainsKey('ResolveDependency')) { diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index 3ded352..4d5a1fb 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -12,8 +12,12 @@ function Invoke-GraphKitGitBytes { foreach ($argument in $Arguments) { $null = $start.ArgumentList.Add($argument) } $process = [Diagnostics.Process]::new(); $process.StartInfo = $start; $null = $process.Start() if ($InputBytes.Length) { $process.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) } - $process.StandardInput.Close(); $output = [IO.MemoryStream]::new(); $process.StandardOutput.BaseStream.CopyTo($output) - $standardError = $process.StandardError.ReadToEnd(); $process.WaitForExit() + $process.StandardInput.Close() + $standardErrorTask = $process.StandardError.ReadToEndAsync() + $output = [IO.MemoryStream]::new() + $process.StandardOutput.BaseStream.CopyTo($output) + $standardError = $standardErrorTask.GetAwaiter().GetResult() + $process.WaitForExit() if ($process.ExitCode -notin $AllowedExitCodes) { throw "git $($Arguments -join ' ') failed: $standardError" } return ,$output.ToArray() } diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index e95df47..1444b5c 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - '3a6e486e888fdb81044105094546884fe5c662afa55025b55c4850bf2c0e444e' + '0bfc3f8631cd26cc74d4a445ae12a5221995fadeb811217616a4da51595029c2' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) diff --git a/scripts/Publish-GraphKitPackage.ps1 b/scripts/Publish-GraphKitPackage.ps1 index 660c4a6..7248d76 100644 --- a/scripts/Publish-GraphKitPackage.ps1 +++ b/scripts/Publish-GraphKitPackage.ps1 @@ -243,9 +243,6 @@ switch ($Channel) { } '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'." } @@ -255,6 +252,9 @@ 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 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 diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index e3fa848..6c2133a 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -821,7 +821,16 @@ private static string GetWindowsPhysicalPath(SafeFileHandle handle) { throw new IOException($"Could not resolve the opened Windows path (Win32 {Marshal.GetLastWin32Error()})."); } - string value = builder.ToString(); + 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; } diff --git a/scripts/private/GraphKit.SourceCapture.cs b/scripts/private/GraphKit.SourceCapture.cs index 2400c5a..ccb51ea 100644 --- a/scripts/private/GraphKit.SourceCapture.cs +++ b/scripts/private/GraphKit.SourceCapture.cs @@ -654,6 +654,7 @@ 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; @@ -715,7 +716,7 @@ internal static SafeFileHandle OpenRoot(string root) { SafeFileHandle handle = CreateFileW( root, - FileReadAttributes | Synchronize, + FileTraverse | FileReadAttributes | Synchronize, ShareRead | ShareWrite | ShareDelete, IntPtr.Zero, OpenExisting, @@ -755,7 +756,8 @@ internal static SafeFileHandle OpenRelative(SafeFileHandle parent, string segmen ObjectName = unicodeStringPointer, Attributes = 0 }; - uint access = FileReadAttributes | Synchronize | (directory ? FileListDirectory : GenericRead | FileReadData); + uint access = FileReadAttributes | Synchronize | + (directory ? FileListDirectory | FileTraverse : GenericRead | FileReadData); uint options = FileOpenReparsePoint | FileSynchronousIoNonAlert | (directory ? FileDirectoryFile : FileNonDirectoryFile); int status = NtCreateFile( out raw, diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 index 14ea477..274cdcc 100644 --- a/scripts/private/Test-GraphKitPackagePrivacy.ps1 +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -100,7 +100,7 @@ function Test-GraphKitPackagePrivacyText { ) $fixedPatterns = [ordered] @{ - 'local user path' = '(?i)(?:/Users/[A-Za-z0-9._-]+|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)' + '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) { @@ -376,7 +376,6 @@ function Test-GraphKitPackagePrivacy { [int] $textEntriesScanned = 0 [int] $binaryEntriesScanned = 0 - Add-Type -AssemblyName System.IO.Compression.FileSystem try { $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).ProviderPath) } diff --git a/source/Private/Initialize-GraphModuleLifecycle.ps1 b/source/Private/Initialize-GraphModuleLifecycle.ps1 index 6a0910d..e342e89 100644 --- a/source/Private/Initialize-GraphModuleLifecycle.ps1 +++ b/source/Private/Initialize-GraphModuleLifecycle.ps1 @@ -8,12 +8,12 @@ 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 for CleanupDone only up to its caller-provided + 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.1' +$script:GraphKitModuleLifecycleContractMarker = 'GraphKit.ModuleLifecycle.RuntimeV1/2026-08-30.2' function Assert-GraphModuleLifecycleTypeContract { [CmdletBinding()] @@ -66,7 +66,6 @@ function Assert-GraphModuleLifecycleTypeContract { @{ Name = 'SyncRoot'; PropertyType = [object] } @{ Name = 'ShutdownCts'; PropertyType = [System.Threading.CancellationTokenSource] } @{ Name = 'Drained'; PropertyType = [System.Threading.ManualResetEventSlim] } - @{ Name = 'CleanupDone'; 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] } @@ -100,6 +99,7 @@ function Assert-GraphModuleLifecycleTypeContract { @{ 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) { @@ -161,7 +161,7 @@ public sealed class ModuleLifecycleState { public static string ContractMarker { - get { return "GraphKit.ModuleLifecycle.RuntimeV1/2026-08-30.1"; } + get { return "GraphKit.ModuleLifecycle.RuntimeV1/2026-08-30.2"; } } private readonly object _stateSync = new object(); @@ -170,6 +170,8 @@ public sealed class ModuleLifecycleState 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; @@ -184,7 +186,6 @@ public sealed class ModuleLifecycleState { ShutdownCts = new CancellationTokenSource(); Drained = new ManualResetEventSlim(true); - CleanupDone = new ManualResetEventSlim(false); } // The HTTP-client cache has its own lock because its factory is PowerShell @@ -192,7 +193,6 @@ public sealed class ModuleLifecycleState public object SyncRoot { get { return _syncRoot; } } public CancellationTokenSource ShutdownCts { get; private set; } public ManualResetEventSlim Drained { get; private set; } - public ManualResetEventSlim CleanupDone { get; private set; } public List OwnedResources { get { return _ownedResources; } } public Dictionary HttpClients { get { return _httpClients; } } @@ -344,6 +344,15 @@ public sealed class ModuleLifecycleState 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) @@ -391,7 +400,7 @@ public sealed class ModuleLifecycleState // 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 CleanupDone wait. + // this cleanup task; the caller observes the bounded WaitForCleanup wait. _cleanupTask = Task.Run(() => DisposeResources(resources)); } @@ -413,8 +422,13 @@ public sealed class ModuleLifecycleState } finally { + try { ShutdownCts.Dispose(); } + catch (Exception ex) { AddFailure(ex); } + try { Drained.Dispose(); } + catch (Exception ex) { AddFailure(ex); } + lock (_stateSync) { _cleanupComplete = true; } - CleanupDone.Set(); + _cleanupCompletion.TrySetResult(true); } } @@ -579,7 +593,7 @@ function Stop-GraphModule { } $remaining = [Math]::Max(0, $DrainTimeoutMilliseconds - [int] $watch.ElapsedMilliseconds) - $cleanupObserved = $State.CleanupDone.Wait($remaining) + $cleanupObserved = $State.WaitForCleanup($remaining) $watch.Stop() if (-not $cleanupObserved) { diff --git a/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 b/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 index aeeea55..30b1e7e 100644 --- a/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 +++ b/source/Private/TokenSources/New-GraphAuthTokenSource.ps1 @@ -2,6 +2,22 @@ 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])] @@ -46,31 +62,31 @@ function Assert-GraphTenantProfileAuthSchema { } } if ($unsupportedSelectors.Count -ne 0) { - throw "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." + 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 "AuthMethod '$authMethod' requires a non-empty, non-zero top-level ClientId. Re-register the profile with -ClientId." + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "AuthMethod '$authMethod' requires a non-empty, non-zero top-level ClientId. Re-register the profile with -ClientId.") } if ($hasNestedClientId) { - throw "AuthMethod '$authMethod' must not declare ManagedIdentityClientId or Credential.ClientId. Re-register the profile with only the top-level application ClientId." + 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 "AuthMethod '$authMethod' ClientId '$topLevelClientId' is not a valid GUID. Re-register the profile with a non-zero application ClientId." + 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 "AuthMethod '$authMethod' requires a non-zero ClientId. Re-register the profile with the application ClientId." + 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 "AuthMethod 'ManagedIdentity' must not declare top-level ClientId. Re-register the profile and use -ManagedIdentityClientId only for a user-assigned identity." + 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 @@ -80,14 +96,14 @@ function Assert-GraphTenantProfileAuthSchema { } if ($null -ne $selector) { if ([string]::IsNullOrWhiteSpace($selector)) { - throw '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.' + 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 "ManagedIdentityClientId / Credential.ClientId '$selector' is not a valid GUID. Re-register the profile with a non-zero user-assigned managed-identity client GUID." + 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 'ManagedIdentity requires a non-zero ManagedIdentityClientId / Credential.ClientId for user-assigned identity. Re-register the profile or omit the selector for system-assigned identity.' + 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') } @@ -95,12 +111,12 @@ function Assert-GraphTenantProfileAuthSchema { } 'BearerToken' { if ($hasNonNullTopLevelClientId -or $hasNestedClientId) { - throw "AuthMethod 'BearerToken' must not declare ClientId, ManagedIdentityClientId, or Credential.ClientId. Re-register the profile without a client identity selector." + 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 "Unknown AuthMethod '$authMethod'. Re-register the profile with Certificate, ClientSecret, ManagedIdentity, or BearerToken." + throw (New-GraphTenantProfileAuthSchemaErrorRecord -Message "Unknown AuthMethod '$authMethod'. Re-register the profile with Certificate, ClientSecret, ManagedIdentity, or BearerToken.") } } diff --git a/source/Public/Test-GraphTenant.ps1 b/source/Public/Test-GraphTenant.ps1 index 362896a..63ec495 100644 --- a/source/Public/Test-GraphTenant.ps1 +++ b/source/Public/Test-GraphTenant.ps1 @@ -98,7 +98,10 @@ function Test-GraphTenant { $null = Assert-GraphTenantProfileAuthSchema -Profile $TenantProfile } catch { - return $false + if ($_.FullyQualifiedErrorId -ceq 'GraphKit.InvalidTenantProfileAuthSchema') { + return $false + } + throw } return $true diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs index d0ed375..cbd3356 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs @@ -16,7 +16,10 @@ public sealed class GraphTokenSourceParityTests private const string MatrixSha256 = "c6953120ea3a29966acabf671a193e7ff51b38d561fb0028a2a585177dea0eb0"; private static readonly DateTimeOffset InjectedNow = - DateTimeOffset.Parse("2026-08-31T12:00:00+00:00", null); + 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 }); diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs index 0bde2de..317264e 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs @@ -203,7 +203,10 @@ public void Dispose() } _disposalCancellation.Dispose(); - _operationsDrained.Dispose(); + lock (_drainGate) + { + _operationsDrained.Dispose(); + } Volatile.Write(ref _disposeState, 2); if (cleanupFailed) diff --git a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 index e8a79e3..76630a1 100644 --- a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 +++ b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 @@ -410,7 +410,7 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { $handler.Exited.Task.IsCompleted | Should -BeTrue $result.TransportException | Should -Not -BeNullOrEmpty $result.TransportException.Data['GraphKit.OperationCancellation'] | Should -BeTrue - $state.CleanupDone.Wait(5000) | Should -BeTrue + $state.WaitForCleanup(5000) | Should -BeTrue $state.ActiveOperations | Should -Be 0 $state.CleanupComplete | Should -BeTrue $state.OwnedResources.Count | Should -Be 0 diff --git a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 index 9cae885..b1e92c1 100644 --- a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 +++ b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 @@ -345,7 +345,7 @@ public sealed class Task7OfflineHandler : HttpMessageHandler $cleanupError = ($_ | Out-String) } } - $cleanupObserved = $null -ne $state -and $state.CleanupDone.Wait(5000) + $cleanupObserved = $null -ne $state -and $state.WaitForCleanup(5000) $moduleAbsent = $null -eq (Get-Module -Name GraphKit) if ($null -ne $state) { $stopRequested = [bool] $state.StopRequested @@ -500,7 +500,7 @@ public sealed class Task7OfflineHandler : HttpMessageHandler $cleanupError = ($_ | Out-String) } } - $cleanupObserved = $null -ne $state -and $state.CleanupDone.Wait(5000) + $cleanupObserved = $null -ne $state -and $state.WaitForCleanup(5000) $moduleAbsent = $null -eq (Get-Module -Name GraphKit) if ($null -ne $state) { $stopRequested = [bool] $state.StopRequested @@ -652,7 +652,7 @@ public sealed class Task7OfflineHandler : HttpMessageHandler $cleanupError = ($_ | Out-String) } } - $cleanupObserved = $null -ne $state -and $state.CleanupDone.Wait(5000) + $cleanupObserved = $null -ne $state -and $state.WaitForCleanup(5000) $moduleAbsent = $null -eq (Get-Module -Name GraphKit) if ($null -ne $state) { $stopRequested = [bool] $state.StopRequested @@ -1452,7 +1452,7 @@ Describe 'GraphKit.Auth exact parent-source thread-runspace use' -Tag Concurrenc Remove-Module -ModuleInfo $module -Force -ErrorAction Stop $moduleRemoved = $true - $cleanupObserved = $state.CleanupDone.Wait(5000) + $cleanupObserved = $state.WaitForCleanup(5000) $cleanupComplete = [bool] $state.CleanupComplete $activeOperations = [int] $state.ActiveOperations $ownedResourceCount = [int] $state.OwnedResources.Count diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 56286e3..5408f2b 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -121,7 +121,7 @@ $task8EvidenceMutationCases = @( @{ 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 = '7' } + @{ Kind = 'string-count'; Value = 'task8-string-count-sentinel' } ) BeforeAll { @@ -1117,7 +1117,7 @@ switch ($HookKind) { 'EvidenceMutation' { $hooks.MutateEvidence = { param($record) - if ($MutationValue -ceq '7') { + if ($MutationValue -ceq 'task8-string-count-sentinel') { $record.read.rowCount = $MutationValue } elseif ($MutationValue -ceq '0.4.0-task8-secret-sentinel') { @@ -2083,7 +2083,7 @@ Describe 'Task 8 guarded parameter and package binding' { -AuthMode Certificate -DryRun Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected - $result.Output | Should -Not -Match [regex]::Escape($sentinel) + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | Should -Be 0 } @@ -2106,7 +2106,7 @@ Describe 'Task 8 guarded parameter and package binding' { -AuthMode $sentinel -DryRun Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected - $result.Output | Should -Not -Match [regex]::Escape($sentinel) + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | Should -Be 0 } @@ -2120,7 +2120,7 @@ Describe 'Task 8 guarded parameter and package binding' { Assert-Task8SafeFailure -Invocation $result -Stage Artifact -Code ArtifactRejected $result.Data.execution | Should -BeExactly 'Live' - $result.Output | Should -Not -Match [regex]::Escape($sentinel) + $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) @((Get-Task8TraceRecords $result.TracePath) | Where-Object event -eq 'root-created').Count | Should -Be 0 } @@ -2405,8 +2405,8 @@ Describe 'Task 8 isolated import, routing, and cleanup' { $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) + $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' { @@ -2946,7 +2946,7 @@ Describe 'Task 8 evidence schema and stream guard' { Assert-Task8SafeFailure -Invocation $result -Stage Evidence -Code EvidenceRejected ` -PackageSha256 $candidate.PackageSha256 - $result.Output | Should -Not -Match [regex]::Escape($Value) + $result.Output | Should -Not -Match ([regex]::Escape($Value)) } It 'captures success, error, warning, verbose, debug, information, and host sentinels' { diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 5064336..a3b7b06 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -1312,7 +1312,9 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $script:GraphKitAuthStageCaptureType::SetOwnerOnly($movedVersionRootB, $true, $false) } if (Test-Path -LiteralPath $stageRootA -PathType Container) { - $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stageRootA, $true, $false) + # 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) @@ -1353,6 +1355,16 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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' } It 'reports an existing atomic destination as a collision and changes neither directory' { diff --git a/tests/QA/PackageIdentity.tests.ps1 b/tests/QA/PackageIdentity.tests.ps1 index 7899473..34a1f62 100644 --- a/tests/QA/PackageIdentity.tests.ps1 +++ b/tests/QA/PackageIdentity.tests.ps1 @@ -15,6 +15,9 @@ BeforeAll { 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" @@ -55,6 +58,44 @@ Describe 'GraphKit release package identity' -Tag 'QA' { 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 the base module directory and packages the full r8 identity' { diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index 26f4772..94eef82 100644 --- a/tests/QA/PublishChannel.tests.ps1 +++ b/tests/QA/PublishChannel.tests.ps1 @@ -154,5 +154,14 @@ Describe 'Publish-GraphKitPackage refusals' { $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 index de6c1d4..5f9c559 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -1238,6 +1238,7 @@ Describe 'Both publisher paths consume the canonical proof verifier' { $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' ` @@ -1246,6 +1247,7 @@ 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"; } "@ @@ -1257,7 +1259,7 @@ internal static class Fixture { $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)) { + foreach ($sentinel in @($privateGuid, $thumbprint, $localPath, $linuxLocalPath, $internalProject)) { $result.Output | Should -Not -Match ([regex]::Escape($sentinel)) } } diff --git a/tests/QA/SourceHygiene.tests.ps1 b/tests/QA/SourceHygiene.tests.ps1 index 299754f..ba65306 100644 --- a/tests/QA/SourceHygiene.tests.ps1 +++ b/tests/QA/SourceHygiene.tests.ps1 @@ -127,7 +127,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 index 28e9bda..47e16ee 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -86,6 +86,7 @@ BeforeAll { 'helper-case-alias', 'case-collision', 'normalization-collision', + 'stderr-flood', 'reverse-untracked' )] [string] $Mode, [hashtable] $Configuration = @{} @@ -268,6 +269,17 @@ switch ($payload.Mode) { } 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) + } 'reverse-untracked' { $result = Invoke-RealGit $gitArguments if ($isOthers -and $result.ExitCode -eq 0) { @@ -560,6 +572,21 @@ Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { $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}$' } It 'ignores a malicious ambient legacy helper and remains deterministic across repeated calls in one process' { @@ -1054,8 +1081,8 @@ $source } finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } - $state.sourceStateSha256 | Should -Be 'a3bf0d85293ed96fd0b8fbef7336beb2dccdc081bb2d878386d2fa5cb46dba10' - $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.da3bf0d85293e$' + $state.sourceStateSha256 | Should -Be '2580391fda4b94fd209d16941dda7e9472f049a5fa5f9bb5dfe8f73d63f7844a' + $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d2580391fda4b$' } } @@ -1075,6 +1102,9 @@ Describe 'GraphKit R8 root-anchored source capture' -Tag 'QA' { $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' { diff --git a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 index feabc8f..8031fcc 100644 --- a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 @@ -927,7 +927,7 @@ public sealed class Task7LegacyAuthenticationResult } finally { if ($null -ne $module) { Remove-Module $module -Force -ErrorAction SilentlyContinue } - $cleaned = $null -ne $state -and $state.CleanupDone.Wait(5000) + $cleaned = $null -ne $state -and $state.WaitForCleanup(5000) $module = $null $state = $null } diff --git a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 index c0d0a26..6e5d9d5 100644 --- a/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Register-GraphTenant.Tests.ps1 @@ -62,7 +62,8 @@ Describe 'Register-GraphTenant' { $invalidStore = Join-Path $TestDrive ("profiles-{0}.json" -f [guid]::NewGuid()) { Register-GraphTenant -ProfileId 'invalid-vault-cert' -Name 'Invalid' -Kind 'lab' ` - -TenantId $script:tenantId -Environment 'Global' -AuthMethod 'Certificate' ` + -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*' diff --git a/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 index 1e058a5..b670184 100644 --- a/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Test-GraphTenant.Tests.ps1 @@ -189,6 +189,22 @@ Describe 'Test-GraphTenant' { $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' { diff --git a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 index 7a79400..9f88473 100644 --- a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +++ b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 @@ -158,7 +158,7 @@ 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.1' + $expectedMarker = 'GraphKit.ModuleLifecycle.RuntimeV1/2026-08-30.2' $stateType = $expectedTypeName -as [type] $stateType | Should -Not -BeNullOrEmpty @@ -206,7 +206,8 @@ Describe 'GraphKit module lifecycle' { } $sharedState } -ArgumentList $script:BuiltManifest, $stateKey - $state.ShutdownCts.Token.WaitHandle.WaitOne(5000) | Should -BeTrue -Because 'Stop must signal the module lifetime before waiting for the active operation' + $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 @@ -244,6 +245,28 @@ Describe 'GraphKit module lifecycle' { } $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) @@ -298,7 +321,7 @@ Describe 'GraphKit module lifecycle' { Exit-GraphModuleOperation -State $State } - $state.CleanupDone.Wait(5000) | Should -BeTrue + $state.WaitForCleanup(5000) | Should -BeTrue $state.CleanupComplete | Should -BeTrue $owned.DisposeCount | Should -Be 1 } @@ -344,7 +367,7 @@ Describe 'GraphKit module lifecycle' { $blocker.Release.Set() $state.CancellationTask.Wait(5000) | Should -BeTrue - $state.CleanupDone.Wait(5000) | Should -BeTrue + $state.WaitForCleanup(5000) | Should -BeTrue $state.CleanupComplete | Should -BeTrue $owned.DisposeCount | Should -Be 1 } @@ -391,7 +414,7 @@ Describe 'GraphKit module lifecycle' { $stopFailure | Should -Not -BeNullOrEmpty $stopFailure.ToString() | Should -Match ([regex]::Escape($sentinel)) - $state.CleanupDone.IsSet | Should -BeTrue + $state.WaitForCleanup(0) | Should -BeTrue $state.CleanupComplete | Should -BeTrue $state.CancellationObserved | Should -BeTrue $owned.DisposeCount | Should -Be 1 @@ -440,7 +463,7 @@ Describe 'GraphKit module lifecycle' { $null = $stopJob | Receive-Job -ErrorAction Stop $completedBeforeRelease | Should -BeTrue -Because 'blocking Dispose must run outside the bounded module-removal path' - $state.CleanupDone.Wait(5000) | Should -BeTrue + $state.WaitForCleanup(5000) | Should -BeTrue $state.CleanupComplete | Should -BeTrue $owned.Completed.IsSet | Should -BeTrue $owned.DisposeCount | Should -Be 1 @@ -494,7 +517,7 @@ Describe 'GraphKit module lifecycle' { } } | Should -Throw -ExceptionType ([System.AggregateException]) -ExpectedMessage '*dispose-failed-source1*' - $state.CleanupDone.IsSet | Should -BeTrue + $state.WaitForCleanup(0) | Should -BeTrue $state.CleanupComplete | Should -BeTrue $state.ActiveOperations | Should -Be 0 $state.OwnedResources.Count | Should -Be 0 @@ -655,7 +678,7 @@ Describe 'GraphKit module lifecycle' { ExactResourceReferences = $result.ExactResourceReferences ResourceTypes = $result.ResourceTypes SourceRejectedCount = $rejectedCount - CleanupObserved = $result.State.CleanupDone.Wait(5000) + CleanupObserved = $result.State.WaitForCleanup(5000) CleanupComplete = $result.State.CleanupComplete ActiveOperations = $result.State.ActiveOperations OwnedResourceCount = $result.State.OwnedResources.Count diff --git a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 index e9d0c56..d27d79d 100644 --- a/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 +++ b/tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 @@ -94,7 +94,7 @@ BeforeAll { function New-TestSend { return { - param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $TokenAcquisitionKey, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding) + param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $TokenAcquisitionKey, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding, $TenantBindingContext) $script:sendCount++ $script:lastSendHeaders = $Headers $script:lastTokenAcquisitionKey = $TokenAcquisitionKey @@ -557,7 +557,7 @@ Describe 'Invoke-GraphRetry (virtual clock)' { $script:cancelDuringSendSource = $cts $injections = New-TestInjections $injections.Send = { - param($Uri, $Method, $Headers, $Body, $CancellationToken, $CredentialPolicy, $TokenSource, $ForceRefresh, $TokenAcquisitionKey, $ExpectedAuthority, $TargetTenantId, $VerifyTenantBinding) + 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') } From 8a621f381de2f03fbd45e7f845000bbeeb22d05f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 20:41:06 -0400 Subject: [PATCH 42/79] fix: close remaining r8 review findings --- scripts/Get-GraphKitTrainVersion.ps1 | 15 ++++-- scripts/Invoke-GraphKitAuthParity.ps1 | 20 +++---- scripts/private/GraphKit.AuthStageCapture.cs | 29 +++++++++- .../Private/TokenSources/GraphTokenSource.ps1 | 8 ++- .../GraphTokenSourceProxy.cs | 2 +- tests/QA/GraphKitAuthPackage.tests.ps1 | 4 ++ tests/QA/TrainVersion.tests.ps1 | 54 ++++++++++++++++--- tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 | 5 +- .../TokenSources/GraphTokenSource.Tests.ps1 | 45 +++++++++++----- 9 files changed, 147 insertions(+), 35 deletions(-) diff --git a/scripts/Get-GraphKitTrainVersion.ps1 b/scripts/Get-GraphKitTrainVersion.ps1 index 4d5a1fb..a0885d7 100644 --- a/scripts/Get-GraphKitTrainVersion.ps1 +++ b/scripts/Get-GraphKitTrainVersion.ps1 @@ -11,11 +11,20 @@ function Invoke-GraphKitGitBytes { $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() - if ($InputBytes.Length) { $process.StandardInput.BaseStream.Write($InputBytes, 0, $InputBytes.Length) } - $process.StandardInput.Close() $standardErrorTask = $process.StandardError.ReadToEndAsync() $output = [IO.MemoryStream]::new() - $process.StandardOutput.BaseStream.CopyTo($output) + $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" } diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 1444b5c..415c5c5 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -337,28 +337,28 @@ function Get-GraphKitAuthParityPublicAbiRecords { "nullable=$(Get-GraphKitAuthParityAbiNullabilityDisplay -Info $nullabilityContext.Create($returnParameter))") } - foreach ($event in @($type.GetEvents($flags) | Sort-Object Name)) { + foreach ($eventInfo in @($type.GetEvents($flags) | Sort-Object Name)) { $accessors = [Collections.Generic.List[string]]::new() - if ($null -ne $event.AddMethod -and $event.AddMethod.IsPublic) { + if ($null -ne $eventInfo.AddMethod -and $eventInfo.AddMethod.IsPublic) { $accessors.Add('add') } - if ($null -ne $event.RemoveMethod -and $event.RemoveMethod.IsPublic) { + if ($null -ne $eventInfo.RemoveMethod -and $eventInfo.RemoveMethod.IsPublic) { $accessors.Add('remove') } $lines.Add( - "EVENT|$($type.FullName)|$($event.Name)|" + - "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $event.EventHandlerType)|" + + "EVENT|$($type.FullName)|$($eventInfo.Name)|" + + "$(Get-GraphKitAuthParityAbiTypeDisplayName -Type $eventInfo.EventHandlerType)|" + "$($accessors -join ',')") - $eventAccessor = if ($null -ne $event.AddMethod) { - $event.AddMethod + $eventAccessor = if ($null -ne $eventInfo.AddMethod) { + $eventInfo.AddMethod } else { - $event.RemoveMethod + $eventInfo.RemoveMethod } $lines.Add( - "EVENT-META|$($type.FullName)::$($event.Name)|" + + "EVENT-META|$($type.FullName)::$($eventInfo.Name)|" + "static=$($eventAccessor.IsStatic.ToString().ToLowerInvariant())|" + - "nullable=$(Get-GraphKitAuthParityAbiNullabilityDisplay -Info $nullabilityContext.Create($event))") + "nullable=$(Get-GraphKitAuthParityAbiNullabilityDisplay -Info $nullabilityContext.Create($eventInfo))") } foreach ($field in @($type.GetFields($flags) | diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 6c2133a..9dd3d53 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -803,7 +803,31 @@ private static int InvokeUnixFStat(int descriptor, byte[] stat, string path) throw new PlatformNotSupportedException( $"GraphKit.Auth stage capture cannot inspect Unix metadata on '{RuntimeInformation.OSDescription}'."); } - return fstat(descriptor, stat); + 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) { @@ -1048,6 +1072,9 @@ private static extern uint GetFinalPathNameByHandleW(SafeFileHandle file, String [DllImport("libc", SetLastError = true)] private static extern int fstat(int descriptor, [Out] byte[] stat); + [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); diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index b0fbe37..87c1069 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -1023,6 +1023,7 @@ function New-GraphTokenSource { } $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 @@ -1037,6 +1038,11 @@ function New-GraphTokenSource { $factoryCredential = $Profile.Credential.Clone() $factoryCredential.PfxPath = [string] $snapshot.Path $factoryProfile.Credential = $factoryCredential + $callerFactory = $MsalFactory + $canonicalFactoryProfile = $factoryProfile + $resolvedMsalFactory = { + & $callerFactory $canonicalFactoryProfile + }.GetNewClosure() } finally { if ($snapshot.Bytes -is [byte[]]) { @@ -1061,7 +1067,7 @@ function New-GraphTokenSource { # A caller-supplied factory selects this legacy same-runspace compatibility # path. Built-in authentication returned through GraphKit.Auth above. return [ConfidentialClientTokenSource]::new( - $MsalFactory, 'Certificate', $audience, $clientId, $generation) + $resolvedMsalFactory, 'Certificate', $audience, $clientId, $generation) } 'ClientSecret' { return [ConfidentialClientTokenSource]::new( diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs index 84e7a0f..deeda55 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -293,7 +293,7 @@ internal static Exception Recreate( graphFailure.RetryAfter is { } retryAfter && retryAfter >= TimeSpan.Zero ? retryAfter : null, - SafeCorrelation(graphFailure.CorrelationId) ?? string.Empty); + SafeCorrelation(graphFailure.CorrelationId)); } return new GraphAuthException( diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index a3b7b06..f5a5945 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -543,6 +543,10 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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 diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index 47e16ee..e528527 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -87,6 +87,7 @@ BeforeAll { 'case-collision', 'normalization-collision', 'stderr-flood', + 'stdin-stdout-flood', 'reverse-untracked' )] [string] $Mode, [hashtable] $Configuration = @{} @@ -280,6 +281,24 @@ switch ($payload.Mode) { } 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) + $input = $inputBytes.ToArray() + $stdout.Write($input, 0, $input.Length) + $stdout.Flush() + exit 0 + } + Write-Result (Invoke-RealGit $gitArguments) + } 'reverse-untracked' { $result = Invoke-RealGit $gitArguments if ($isOthers -and $result.ExitCode -eq 0) { @@ -587,6 +606,29 @@ Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { -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}$' + + $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' { @@ -658,8 +700,8 @@ $source 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' - $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment[\s\S]*Scripts' + $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' { @@ -678,8 +720,8 @@ $source 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' - $result.Output | Should -Match 'exactly one exact raw inventory record|Source path segment[\s\S]*Scripts' + $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' { @@ -1081,8 +1123,8 @@ $source } finally { $env:GRAPHKIT_TEST_CAPTURE_IDENTITY = $savedIdentity } - $state.sourceStateSha256 | Should -Be '2580391fda4b94fd209d16941dda7e9472f049a5fa5f9bb5dfe8f73d63f7844a' - $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d2580391fda4b$' + $state.sourceStateSha256 | Should -Be '88365586a59840cef20946c650bc5973567bf9be407728c3568af70d4d4cfcea' + $state.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}\.d88365586a598$' } } diff --git a/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 b/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 index 17cfa9e..78dac32 100644 --- a/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 +++ b/tests/Unit/Profiles/Use-GraphTenant.Tests.ps1 @@ -22,9 +22,12 @@ Describe 'Use-GraphTenant' { BeforeEach { Mock Get-GraphVaultCredential -ModuleName GraphKit { + $material = [Security.SecureString]::new() + $material.AppendChar('x') + $material.MakeReadOnly() [pscustomobject]@{ AuthMethod = 'ClientSecret' - Material = ConvertTo-SecureString 'use-graph-tenant-test' -AsPlainText -Force + Material = $material OwnsMaterial = $true CredentialGeneration = 'g1|ClientSecret|fixture' } diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index 63e2739..b41e7e4 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -39,9 +39,14 @@ public static class Task6CredentialFixture } public static SecureString CreateSecret() + { + return CreateSecret("task6-secret"); + } + + public static SecureString CreateSecret(string value) { SecureString secret = new(); - foreach (char value in "task6-secret") secret.AppendChar(value); + foreach (char character in value) secret.AppendChar(character); secret.MakeReadOnly(); return secret; } @@ -752,7 +757,7 @@ Describe 'GraphTokenSource' { } } Mock Resolve-GraphVaultPassword -ModuleName GraphKit { - ConvertTo-SecureString $passwordText -AsPlainText -Force + [GraphKit.Tests.Task6CredentialFixture]::CreateSecret($passwordText) } $source = $null @@ -1221,7 +1226,8 @@ Describe 'GraphTokenSource' { $null = $ready.Signal() $null = $go.Wait() & (Get-Module GraphKit) { - Invoke-GraphTokenSingleFlight -Key $key -AcquireScript { + 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') @@ -1230,7 +1236,7 @@ Describe 'GraphTokenSource' { $null = $release.Wait() [pscustomobject]@{ Token = [guid]::NewGuid().ToString() } } - } + } $key } -ArgumentList $key, $script:BuiltManifest } @@ -1296,7 +1302,8 @@ Describe 'GraphTokenSource' { $null = $go.Wait() try { $null = & (Get-Module GraphKit) { - Invoke-GraphTokenSingleFlight -Key $key -AcquireScript { + 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') @@ -1305,7 +1312,7 @@ Describe 'GraphTokenSource' { $null = $release.Wait() throw 'acquisition failed' } - } + } $key 'ok' } catch { @@ -1797,10 +1804,12 @@ Describe 'GraphTokenSource' { 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' + [System.AppDomain]::CurrentDomain.SetData($captureKey, $null) $null = New-Item -ItemType Directory -Path $original, $elsewhere -Force [System.IO.File]::WriteAllBytes((Join-Path $original 'credential.pfx'), [byte[]] @(1, 3, 3, 7)) - $source = InModuleScope GraphKit -Parameters @{ Origin = $original } { - param($Origin) + $source = InModuleScope GraphKit -Parameters @{ Origin = $original; CaptureKey = $captureKey } { + param($Origin, $CaptureKey) Push-Location $Origin try { New-GraphTokenSource -Profile @{ @@ -1814,7 +1823,17 @@ Describe 'GraphTokenSource' { } -Cloud @{ Resource = 'https://graph.microsoft.com' Authority = 'https://login.microsoftonline.com' - } -MsalFactory { throw 'canonical-path capture test must not acquire' } + } -MsalFactory { + param($FactoryProfile) + $capturedPath = if ($null -eq $FactoryProfile) { + '' + } + else { + [string] $FactoryProfile.Credential.PfxPath + } + [System.AppDomain]::CurrentDomain.SetData($CaptureKey, $capturedPath) + [pscustomobject] @{ Kind = 'compatibility-factory-fixture' } + }.GetNewClosure() } finally { Pop-Location @@ -1823,13 +1842,15 @@ Describe 'GraphTokenSource' { Push-Location $elsewhere try { + $null = $source.GetApplication() $source.CredentialGeneration | Should -Match '^g1\|Certificate\.PFX\|.+\|71:sha256:[0-9a-f]{64}\|5:vault\|8:password\|2:v1$' - $source.CredentialGeneration | Should -Match ([regex]::Escape( - [System.IO.Path]::GetFullPath((Join-Path $original 'credential.pfx')) - )) + $canonicalPath = [System.IO.Path]::GetFullPath((Join-Path $original 'credential.pfx')) + $source.CredentialGeneration | Should -Match ([regex]::Escape($canonicalPath)) + [System.AppDomain]::CurrentDomain.GetData($captureKey) | Should -BeExactly $canonicalPath } finally { Pop-Location + [System.AppDomain]::CurrentDomain.SetData($captureKey, $null) } } From 27b02b4c8fef9cd1df96dfbf00d0263b5114eab8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 21:08:05 -0400 Subject: [PATCH 43/79] fix: close exact-head r8 review findings --- scripts/Invoke-GraphKitAuthParity.ps1 | 4 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 23 ++++++--- .../Private/TokenSources/GraphTokenSource.ps1 | 13 ++--- .../GraphTokenSourceProxy.cs | 2 +- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 7 ++- tests/QA/ReleaseProof.tests.ps1 | 49 +++++++++++++++++-- .../TokenSources/GraphTokenSource.Tests.ps1 | 14 +++--- 8 files changed, 85 insertions(+), 29 deletions(-) diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 415c5c5..7b83ea4 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - '0bfc3f8631cd26cc74d4a445ae12a5221995fadeb811217616a4da51595029c2' + 'ec7b8e8fb971d702766b6f6cb474c5d509d60909cfc3bf6c97ab78952600daba' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 434e719..40bb6b0 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -410,8 +410,8 @@ try { throw "The staged tested release proof failed canonical verification: $flatVerificationOutput" } - Remove-Item -LiteralPath $candidatePath -Force [System.IO.File]::Move($stagedProofPath, $proofPath, $true) + Remove-Item -LiteralPath $candidatePath -Force } finally { Remove-Item -LiteralPath $stagedProofPath -Force -ErrorAction SilentlyContinue diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 43fa6d1..6a6a5ef 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -414,7 +414,7 @@ try { if ($null -eq $metadataNode) { throw "Package '$($package.Name)' has no canonical nuspec metadata node." } - $supportedMetadataNames = @( + $requiredMetadataNames = @( 'id', 'version', 'authors', @@ -424,9 +424,10 @@ try { 'description', 'releaseNotes', 'copyright', - 'tags', - 'dependencies' + 'tags' ) + $supportedMetadataNames = @($requiredMetadataNames) + 'dependencies' + $dependenciesRequired = @($builtManifest.RequiredModules).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.' @@ -438,7 +439,11 @@ try { throw "Package nuspec contains an unsupported nuspec metadata field '$($metadataChild.LocalName)' or duplicate field." } } - if ($metadataNameSet.Count -ne $supportedMetadataNames.Count) { + $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 { @@ -548,11 +553,17 @@ try { } $dependencyContainers = @($metadataNode.SelectNodes('n:dependencies', $namespace)) - if ($dependencyContainers.Count -ne 1) { + 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) - foreach ($dependencyNode in @($dependencyContainers[0].ChildNodes | Where-Object NodeType -EQ ([System.Xml.XmlNodeType]::Element))) { + $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 diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index 87c1069..91401f1 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -560,9 +560,9 @@ function Get-GraphFingerprint { function Get-GraphPfxSnapshot { <# Read a persisted PFX once and bind its canonical path, exact bytes and - SHA-256 identity together. Callers that construct a certificate use the - returned Bytes property rather than reopening the path, so the material - cannot change between generation verification and import. + 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])] @@ -649,9 +649,10 @@ function Get-GraphCredentialGeneration { [Parameter(Mandatory)] [hashtable] $TenantProfile, - # Internal snapshot seam: the PFX resolver has already read the exact - # bytes it will import, so it supplies their digest/path to avoid a - # second path read and a generation-to-load TOCTOU window. + # 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 diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs index deeda55..84e7a0f 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -293,7 +293,7 @@ internal static Exception Recreate( graphFailure.RetryAfter is { } retryAfter && retryAfter >= TimeSpan.Zero ? retryAfter : null, - SafeCorrelation(graphFailure.CorrelationId)); + SafeCorrelation(graphFailure.CorrelationId) ?? string.Empty); } return new GraphAuthException( diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 5408f2b..a1c657f 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -1962,8 +1962,11 @@ Describe 'Task 8 canonical GraphKit.Auth ABI gate' -Tag 'Task8Abi' { } It 'projects the exact 161-record Task 7 contract surface and expected digest' { - $contractsPath = Join-Path $script:repoRoot ` - 'output/module/GraphKit/0.4.0/Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' + $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) diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 5f9c559..1717984 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -148,6 +148,7 @@ BeforeAll { [bool] $Executed = $true, [switch] $ForGenerator, [switch] $IncludeGraphKitAuth, + [switch] $NoRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, [int] $Total = 1462 @@ -208,6 +209,18 @@ internal static class Fixture { internal const string Value = "public fixture"; " RequiredAssemblies = @('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')`n" } else { '' } + $requiredModulesLine = if ($NoRequiredModules) { + ' RequiredModules = @()' + } + else { + " RequiredModules = @(@{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' })" + } + $dependenciesMarkup = if ($NoRequiredModules) { + '' + } + else { + '' + } $payloads = [ordered] @{ 'Data/Operations/Probe.List.psd1' = "@{ SchemaVersion = 1; Type = 'Probe'; Operation = 'List' }`n" @@ -222,7 +235,7 @@ internal static class Fixture { internal const string Value = "public fixture"; Copyright = '(c) Fixture Author' Description = 'Fixture GraphKit release-proof module package.' FunctionsToExport = @('Get-GraphProbe') -$requiredAssembliesLine RequiredModules = @(@{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' }) +$requiredAssembliesLine$requiredModulesLine PrivateData = @{ PSData = @{ Tags = @('Fixture', 'Graph') LicenseUri = 'https://opensource.org/licenses/MIT' @@ -264,7 +277,7 @@ $requiredAssembliesLine RequiredModules = @(@{ ModuleName = 'Microsoft.Graph. } 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 +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 '' @@ -588,7 +601,7 @@ Describe 'Canonical tested release proof' { } } - It 'accepts one proof binding the module, package, full result, and every shipped file' { + It 'accepts one proof binding every shipped file and exact optional dependency metadata' { $script:fixture = New-GraphKitReleaseProofFixture $result = Invoke-GraphKitReleaseProofVerifier -Fixture $script:fixture @@ -596,6 +609,18 @@ Describe 'Canonical tested release proof' { $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 } It 'accepts GraphKit.Auth runtime bytes when the data-file Hashtable declares the exact contracts prerequisite' { @@ -1034,7 +1059,7 @@ Describe 'Test workflow release-proof generation' { Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeTrue } - It 'finalize emits the one proof only after the captured candidate and result pair pass' { + 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) @@ -1056,6 +1081,22 @@ Describe 'Test workflow release-proof generation' { $proof.testRun.summary.total | Should -Be 1462 $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' { diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index b41e7e4..b084d6d 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -39,14 +39,9 @@ public static class Task6CredentialFixture } public static SecureString CreateSecret() - { - return CreateSecret("task6-secret"); - } - - public static SecureString CreateSecret(string value) { SecureString secret = new(); - foreach (char character in value) secret.AppendChar(character); + foreach (char character in "task6-secret") secret.AppendChar(character); secret.MakeReadOnly(); return secret; } @@ -757,7 +752,12 @@ Describe 'GraphTokenSource' { } } Mock Resolve-GraphVaultPassword -ModuleName GraphKit { - [GraphKit.Tests.Task6CredentialFixture]::CreateSecret($passwordText) + $secret = [Security.SecureString]::new() + foreach ($character in $passwordText.ToCharArray()) { + $secret.AppendChar($character) + } + $secret.MakeReadOnly() + $secret } $source = $null From dddd9cb859201a09c0b07ac5a140a0990416291d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 21:41:06 -0400 Subject: [PATCH 44/79] fix: harden r8 cleanup and deadline bounds --- .build/GraphKitAuth.tasks.ps1 | 23 ++++-- source/Private/Confirm-GraphTenantBinding.ps1 | 6 +- tests/QA/GraphKitAuthPackage.tests.ps1 | 71 +++++++++++++++++++ .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 8 +++ .../Transport/GraphModuleLifecycle.Tests.ps1 | 2 +- 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index f8e88e7..b64508d 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -990,7 +990,11 @@ function Invoke-GraphKitAuthPrepareClean { function Invoke-GraphKitAuthLiteralQuarantine { param([Parameter(Mandatory)][string] $RepositoryRoot) - $quarantine = Join-Path ([IO.Path]::GetTempPath()) ('graphkit-auth-task5-' + [guid]::NewGuid().ToString('N')) + # 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. + $quarantine = Join-Path (Join-Path $RepositoryRoot 'output') ` + ('GraphKit.Auth.quarantine-' + [guid]::NewGuid().ToString('N')) $null = [IO.Directory]::CreateDirectory($quarantine) $relativeRoots = @( 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin' @@ -1382,6 +1386,7 @@ if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyConti $payloadSource = Join-Path $publishRoot 'payload' $resultRoot = Join-Path $authOutput "dotnet-test/$runId" $quarantine = $null + $primaryFailure = $null try { $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot (Join-Path $BuildRoot 'output') if ((& dotnet --version) -cne '10.0.400') { throw 'GraphKit.Auth requires dotnet SDK 10.0.400 exactly.' } @@ -1456,11 +1461,21 @@ if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyConti $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 { if ($null -eq $quarantine) { - $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot - $script:GraphKitAuthQuarantine = $quarantine - Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'." + try { + $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot + $script:GraphKitAuthQuarantine = $quarantine + Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'." + } + catch { + if ($null -eq $primaryFailure) { throw } + Write-Warning 'GraphKit.Auth generated-root quarantine also failed; the earlier build failure remains authoritative.' + } } } } diff --git a/source/Private/Confirm-GraphTenantBinding.ps1 b/source/Private/Confirm-GraphTenantBinding.ps1 index eb7d666..12db594 100644 --- a/source/Private/Confirm-GraphTenantBinding.ps1 +++ b/source/Private/Confirm-GraphTenantBinding.ps1 @@ -212,11 +212,13 @@ function Confirm-GraphTenantBinding { if ($null -eq $transport) { $transport = { param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) - $deadlineSeconds = [int] [Math]::Ceiling(([TimeSpan] $RemainingDeadline).TotalSeconds) + $deadlineSecondsValue = [Math]::Min( + 86400.0, + [Math]::Ceiling(([TimeSpan] $RemainingDeadline).TotalSeconds)) + $deadlineSeconds = [int] $deadlineSecondsValue if ($deadlineSeconds -lt 1) { throw (New-GraphTenantBindingDeadlineException) } - $deadlineSeconds = [Math]::Min(86400, $deadlineSeconds) Invoke-GraphRetry -Context $Context -Descriptor $Descriptor -Uri $Uri -Method GET ` -Headers @{} -Body $null -CancellationToken $CancellationToken ` -DeadlineSeconds $deadlineSeconds diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index f5a5945..0600178 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -550,6 +550,77 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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 + } + } + } + + It 'preserves the primary build failure when fallback quarantine also fails' { + $observed = & { + $tasks = @{} + function task { + param([string] $Name, [scriptblock] $Action) + $tasks[$Name] = $Action + } + + . $script:taskPath + + $cleanupCalls = [Collections.Generic.List[string]]::new() + function Initialize-GraphKitAuthStageCapture {} + function Initialize-GraphKitAuthBuildAuthorityRoot { + throw [InvalidOperationException]::new('injected primary build failure') + } + 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')) + $caught = $null + try { + & $tasks['Build_GraphKitAuth'] + } + catch { + $caught = $_ + } + + [pscustomobject]@{ + Error = $caught + CleanupCalls = $cleanupCalls.Count + } + } + + $observed.CleanupCalls | Should -Be 1 + $observed.Error | Should -Not -BeNullOrEmpty + $observed.Error.Exception.GetType().FullName | Should -BeExactly 'System.InvalidOperationException' + $observed.Error.Exception.Message | Should -BeExactly 'injected primary build failure' } It 'refuses an existing full-version stage without changing its bytes' { diff --git a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index ea56211..fc93305 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -390,6 +390,14 @@ Describe 'Confirm-GraphTenantBinding' { $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 } It 'forwards the caller cancellation token into the proof retry pipeline' { diff --git a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 index 9f88473..422149d 100644 --- a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +++ b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 @@ -455,7 +455,7 @@ Describe 'GraphKit module lifecycle' { } -ArgumentList $script:BuiltManifest, $stateKey $owned.Started.Wait(5000) | Should -BeTrue -Because 'cleanup must eventually attempt disposal' - $completedBeforeRelease = $null -ne ($stopJob | Wait-Job -Timeout 1) + $completedBeforeRelease = $null -ne ($stopJob | Wait-Job -Timeout 10) $owned.Release.Set() $completedJobs = @($stopJob | Wait-Job -Timeout 10) From 09c1a2a12f1984660f93da48688c8062fbd8dbe1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 21:50:46 -0400 Subject: [PATCH 45/79] test: isolate build failure regression --- tests/QA/GraphKitAuthPackage.tests.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 0600178..3edd0e2 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -584,10 +584,11 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { It 'preserves the primary build failure when fallback quarantine also fails' { $observed = & { $tasks = @{} - function task { + function Capture-GraphKitAuthTask { param([string] $Name, [scriptblock] $Action) $tasks[$Name] = $Action } + Set-Alias -Name task -Value Capture-GraphKitAuthTask -Scope Local . $script:taskPath From 100f7b40103c461a07352f6d3cb222ff6449f2d8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 21:59:04 -0400 Subject: [PATCH 46/79] test: preserve the portable test floor --- tests/QA/GraphKitAuthPackage.tests.ps1 | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 3edd0e2..b55a8e0 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -579,9 +579,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Remove-Item -LiteralPath $fixtureRoot -Recurse -Force } } - } - It 'preserves the primary build failure when fallback quarantine also fails' { $observed = & { $tasks = @{} function Capture-GraphKitAuthTask { From e691127646b170c40b2df9d146c7b6c471b2c29a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 22:27:35 -0400 Subject: [PATCH 47/79] fix: close final r8 review edge cases --- .build/GraphKitAuth.tasks.ps1 | 7 +- source/Private/Confirm-GraphTenantBinding.ps1 | 5 +- tests/QA/GraphKitAuthPackage.tests.ps1 | 69 +++++++++++++++---- .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 9 +++ .../TokenSources/GraphTokenSource.Tests.ps1 | 32 ++++----- 5 files changed, 88 insertions(+), 34 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index b64508d..f3d6ee7 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -1389,7 +1389,12 @@ if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyConti $primaryFailure = $null try { $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot (Join-Path $BuildRoot 'output') - if ((& dotnet --version) -cne '10.0.400') { throw 'GraphKit.Auth requires dotnet SDK 10.0.400 exactly.' } + $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 diff --git a/source/Private/Confirm-GraphTenantBinding.ps1 b/source/Private/Confirm-GraphTenantBinding.ps1 index 12db594..860b9cd 100644 --- a/source/Private/Confirm-GraphTenantBinding.ps1 +++ b/source/Private/Confirm-GraphTenantBinding.ps1 @@ -228,12 +228,13 @@ function Confirm-GraphTenantBinding { # 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. - $proofCloud = if ($null -ne $Context.PSObject.Properties['Cloud']) { + $contextCloud = if ($null -ne $Context.PSObject.Properties['Cloud']) { [string] $Context.Cloud } else { - 'TenantProof' + '' } + $proofCloud = if ([string]::IsNullOrWhiteSpace($contextCloud)) { 'TenantProof' } else { $contextCloud } $proofClientId = if ($null -ne $Context.PSObject.Properties['ClientId']) { $Context.ClientId } diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index b55a8e0..a0e294e 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -20,7 +20,7 @@ $graphKitAuthArchiveAliasCases = @( 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll' ) } @{ Kind = 'Unicode normalization alias'; Entries = @( - "Assemblies/GraphKit.Auth/probé.dll" + "Assemblies/GraphKit.Auth/prob$([char]0x00E9).dll" "Assemblies/GraphKit.Auth/probe$([char]0x0301).dll" ) } ) @@ -59,8 +59,8 @@ $portableVersionAliasCases = @( } @{ Kind = 'NFC' - ExpectedName = '0.4.0-r8.fixture.vérsion-alias' - AliasName = '0.4.0-r8.fixture.vérsion-alias'.Normalize([Text.NormalizationForm]::FormD) + 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 { @() } @@ -140,9 +140,9 @@ BeforeAll { param([string] $PackagePath, [string] $EntryPath) $archive = [IO.Compression.ZipFile]::OpenRead($PackagePath) try { - $matches = @($archive.Entries | Where-Object FullName -CEQ $EntryPath) - if ($matches.Count -ne 1) { throw "Expected one '$EntryPath' archive entry." } - $stream = $matches[0].Open() + $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() } @@ -284,7 +284,7 @@ BeforeAll { } } 'unicode-alias' { - [IO.File]::Copy($targetPath, (Join-Path $payloadPath "probé.dll")) + [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 @@ -602,24 +602,63 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } $BuildRoot = Join-Path $TestDrive ('primary-failure-' + [guid]::NewGuid().ToString('N')) - $caught = $null + $primaryFailure = $null try { & $tasks['Build_GraphKitAuth'] } catch { - $caught = $_ + $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]@{ - Error = $caught - CleanupCalls = $cleanupCalls.Count + PrimaryFailure = $primaryFailure + PrimaryCleanupCalls = $primaryCleanupCalls + NormalizedVersionFailure = $normalizedVersionFailure + DotnetCalls = @($dotnetCalls) } } - $observed.CleanupCalls | Should -Be 1 - $observed.Error | Should -Not -BeNullOrEmpty - $observed.Error.Exception.GetType().FullName | Should -BeExactly 'System.InvalidOperationException' - $observed.Error.Exception.Message | Should -BeExactly 'injected primary build failure' + $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' { diff --git a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index fc93305..260cce4 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -398,6 +398,15 @@ Describe 'Confirm-GraphTenantBinding' { -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' { diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index b084d6d..aeb49a3 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -1865,24 +1865,24 @@ Describe 'GraphTokenSource' { } } - $base = InModuleScope GraphKit -Parameters @{ Profile = $baseProfile } { - param($Profile) - Get-GraphCredentialGeneration -TenantProfile $Profile + $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 @{ Profile = $passwordChanged } { - param($Profile) - Get-GraphCredentialGeneration -TenantProfile $Profile + $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 @{ Profile = $materialChanged } { - param($Profile) - Get-GraphCredentialGeneration -TenantProfile $Profile + $materialGeneration = InModuleScope GraphKit -Parameters @{ TenantProfile = $materialChanged } { + param($TenantProfile) + Get-GraphCredentialGeneration -TenantProfile $TenantProfile } $passwordGeneration | Should -Not -Be $base @@ -1971,7 +1971,7 @@ Describe 'GraphTokenSource' { } $result = InModuleScope GraphKit { - $profile = @{ + $tenantProfile = @{ TenantId = '00000000-0000-0000-0000-000000000001' ClientId = $null AuthMethod = 'BearerToken' @@ -1983,18 +1983,18 @@ Describe 'GraphTokenSource' { Resource = 'https://graph.microsoft.com' Authority = 'https://login.microsoftonline.com' } - $old = New-GraphTokenSource -Profile $profile -Cloud $cloud - $new = New-GraphTokenSource -Profile $profile -Cloud $cloud + $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 $profile.TenantId ` - -Authority $cloud.Authority -Resource $cloud.Resource -ClientId $profile.ClientId ` + 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 $profile.TenantId ` - -Authority $cloud.Authority -Resource $cloud.Resource -ClientId $profile.ClientId ` + NewKey = Get-GraphTokenAcquisitionKey -Environment $cloud.Name -TenantId $tenantProfile.TenantId ` + -Authority $cloud.Authority -Resource $cloud.Resource -ClientId $tenantProfile.ClientId ` -AuthMode BearerToken -Generation $new.CredentialGeneration } } From a06dab873c964717da6ce4f43f20bb0a03cabc37 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 23:01:22 -0400 Subject: [PATCH 48/79] fix: preserve optional release and factory contracts --- scripts/Test-GraphKitReleaseProof.ps1 | 8 +- .../Private/TokenSources/GraphTokenSource.ps1 | 9 ++- tests/QA/ReleaseProof.tests.ps1 | 22 +++++- .../TokenSources/GraphTokenSource.Tests.ps1 | 74 ++++++++++++------- 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 6a6a5ef..4054a28 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -427,7 +427,11 @@ try { 'tags' ) $supportedMetadataNames = @($requiredMetadataNames) + 'dependencies' - $dependenciesRequired = @($builtManifest.RequiredModules).Count -gt 0 + $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.' @@ -514,7 +518,7 @@ try { } $expectedDependencies = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($requiredModule in @($builtManifest.RequiredModules)) { + foreach ($requiredModule in $declaredRequiredModules) { $requiredIsDictionary = $requiredModule -is [System.Collections.IDictionary] $requiredName = if ($requiredModule -is [string]) { [string] $requiredModule diff --git a/source/Private/TokenSources/GraphTokenSource.ps1 b/source/Private/TokenSources/GraphTokenSource.ps1 index 91401f1..90604be 100644 --- a/source/Private/TokenSources/GraphTokenSource.ps1 +++ b/source/Private/TokenSources/GraphTokenSource.ps1 @@ -1041,8 +1041,15 @@ function New-GraphTokenSource { $factoryProfile.Credential = $factoryCredential $callerFactory = $MsalFactory $canonicalFactoryProfile = $factoryProfile + $factoryAcceptsProfile = $null -ne $callerFactory.Ast.ParamBlock -and + $callerFactory.Ast.ParamBlock.Parameters.Count -gt 0 $resolvedMsalFactory = { - & $callerFactory $canonicalFactoryProfile + if ($factoryAcceptsProfile) { + & $callerFactory $canonicalFactoryProfile + } + else { + & $callerFactory + } }.GetNewClosure() } finally { diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 1717984..a5159b7 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -149,6 +149,8 @@ BeforeAll { [switch] $ForGenerator, [switch] $IncludeGraphKitAuth, [switch] $NoRequiredModules, + [switch] $OmitRequiredModules, + [switch] $NullRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, [int] $Total = 1462 @@ -209,13 +211,19 @@ internal static class Fixture { internal const string Value = "public fixture"; " RequiredAssemblies = @('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')`n" } else { '' } - $requiredModulesLine = if ($NoRequiredModules) { + $requiredModulesLine = if ($OmitRequiredModules) { + '' + } + elseif ($NullRequiredModules) { + ' RequiredModules = $null' + } + elseif ($NoRequiredModules) { ' RequiredModules = @()' } else { " RequiredModules = @(@{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.38.1' })" } - $dependenciesMarkup = if ($NoRequiredModules) { + $dependenciesMarkup = if ($NoRequiredModules -or $OmitRequiredModules -or $NullRequiredModules) { '' } else { @@ -621,6 +629,16 @@ Describe 'Canonical tested release proof' { -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' { diff --git a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 index aeb49a3..a07a53d 100644 --- a/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 +++ b/tests/Unit/TokenSources/GraphTokenSource.Tests.ps1 @@ -1805,35 +1805,52 @@ Describe 'GraphTokenSource' { $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)) - $source = InModuleScope GraphKit -Parameters @{ Origin = $original; CaptureKey = $captureKey } { - param($Origin, $CaptureKey) + $sources = InModuleScope GraphKit -Parameters @{ + Origin = $original + CaptureKey = $captureKey + ArgumentCountKey = $argumentCountKey + } { + param($Origin, $CaptureKey, $ArgumentCountKey) Push-Location $Origin try { - New-GraphTokenSource -Profile @{ - 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' - } -MsalFactory { - param($FactoryProfile) - $capturedPath = if ($null -eq $FactoryProfile) { - '' - } - else { - [string] $FactoryProfile.Credential.PfxPath - } - [System.AppDomain]::CurrentDomain.SetData($CaptureKey, $capturedPath) - [pscustomobject] @{ Kind = 'compatibility-factory-fixture' } - }.GetNewClosure() + $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 @@ -1842,15 +1859,18 @@ Describe 'GraphTokenSource' { Push-Location $elsewhere try { - $null = $source.GetApplication() - $source.CredentialGeneration | Should -Match '^g1\|Certificate\.PFX\|.+\|71:sha256:[0-9a-f]{64}\|5:vault\|8:password\|2:v1$' + $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')) - $source.CredentialGeneration | Should -Match ([regex]::Escape($canonicalPath)) + $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) } } From d8fbe42f881951c102e965032c1ee5e42783cbfa Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 23:28:56 -0400 Subject: [PATCH 49/79] test: harden final review harnesses --- tests/QA/ReleaseProof.tests.ps1 | 4 +- tests/QA/TrainVersion.tests.ps1 | 66 +++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index a5159b7..4314440 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -825,7 +825,7 @@ Describe 'Canonical tested release proof' { $script:fixture.PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) try { - Add-GraphKitFixtureArchiveText -Archive $archive -EntryName "Data/probé.ps1" -Content 'composed' + 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 { @@ -913,7 +913,7 @@ Describe 'Canonical tested release proof' { $proof = Get-Content -LiteralPath $script:fixture.ProofPath -Raw | ConvertFrom-Json $hash = ('d' * 64) -join '' $proof.module.files = @($proof.module.files) + @( - [pscustomobject] @{ path = "Data/probé.ps1"; sha256 = $hash } + [pscustomobject] @{ path = "Data/prob$([char]0x00E9).ps1"; sha256 = $hash } [pscustomobject] @{ path = "Data/probe$([char]0x0301).ps1"; sha256 = $hash } ) $proof | ConvertTo-Json -Depth 10 | diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index e528527..7e1a00b 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -42,7 +42,8 @@ BeforeAll { function Get-R8TrainVersionWithTimeout { param( [Parameter(Mandatory)] [string] $RepositoryRoot, - [Parameter(Mandatory)] [int] $TimeoutMilliseconds + [Parameter(Mandatory)] [int] $TimeoutMilliseconds, + [string] $VersionScript = $script:versionScript ) $start = [Diagnostics.ProcessStartInfo]::new() @@ -53,21 +54,38 @@ BeforeAll { $null = $start.ArgumentList.Add('-NoLogo') $null = $start.ArgumentList.Add('-NoProfile') $null = $start.ArgumentList.Add('-File') - $null = $start.ArgumentList.Add($script:versionScript) + $null = $start.ArgumentList.Add($VersionScript) $null = $start.ArgumentList.Add('-RepositoryRoot') $null = $start.ArgumentList.Add($RepositoryRoot) $process = [Diagnostics.Process]::new() $process.StartInfo = $start - $null = $process.Start() - if (-not $process.WaitForExit($TimeoutMilliseconds)) { - $process.Kill($true) - $process.WaitForExit() - return [pscustomobject] @{ Running = $true; ExitCode = $null; Output = '' } + 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 + } } - [pscustomobject] @{ - Running = $false - ExitCode = $process.ExitCode - Output = ($process.StandardOutput.ReadToEnd() + $process.StandardError.ReadToEnd()).Trim() + finally { + $process.Dispose() } } @@ -607,6 +625,32 @@ Describe 'GraphKit R8 train source-entry identity' -Tag 'QA' { $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 From cc9fe4aa2ee0414a7997023cdc9d52ef6a82db59 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 4 Sep 2026 23:52:34 -0400 Subject: [PATCH 50/79] test: bound lifecycle drain beyond scheduling window --- tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 index 422149d..c43b1ff 100644 --- a/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 +++ b/tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1 @@ -202,7 +202,7 @@ Describe 'GraphKit module lifecycle' { $sharedState = [System.AppDomain]::CurrentDomain.GetData($StateKey) & (Get-Module GraphKit) { param($State) - Stop-GraphModule -State $State + Stop-GraphModule -State $State -DrainTimeoutMilliseconds 30000 } $sharedState } -ArgumentList $script:BuiltManifest, $stateKey From b446a2c4b9eec0a1038a243c5d4496ff59491511 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 00:26:40 -0400 Subject: [PATCH 51/79] fix: preserve auth parity fixture bytes on Windows --- .gitattributes | 1 + tests/QA/RepositoryHygiene.Tests.ps1 | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7a09168 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tests/Fixtures/GraphKitAuthParityCases.json text eol=lf 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 = @( From b1a36b73e7d9bbdbcc8bf0d9c479c255655d6510 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 00:39:40 -0400 Subject: [PATCH 52/79] test: ratchet GraphKit suite floor --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1379804..4f73646 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ 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 1462 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1463 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index f234cb7..8123e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1462 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. +**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 1463 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. 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. diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 40bb6b0..3a79748 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1462 +$minimumTests = 1463 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 4054a28..346c9e3 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1462 +$minimumTests = 1463 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index 94eef82..d7d04c2 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 = 1462) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1463) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 4314440..da72c0d 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -153,7 +153,7 @@ BeforeAll { [switch] $NullRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1462 + [int] $Total = 1463 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -386,7 +386,7 @@ $requiredAssembliesLine$requiredModulesLine sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1462 + minimumTests = 1463 allowedSkips = 0 allowedNotRun = 0 } @@ -1096,7 +1096,7 @@ Describe 'Test workflow release-proof generation' { $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 1462 + $proof.testRun.summary.total | Should -Be 1463 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse From b915509e52a8717c440a138284bebb5fed22aae7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 01:10:26 -0400 Subject: [PATCH 53/79] fix: close final review findings --- scripts/Test-GraphKitReleaseProof.ps1 | 7 ++++--- .../GraphKit.Auth.Contracts/GraphAuthHost.cs | 2 +- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 6 +++--- tests/QA/GraphKitAuthPackage.tests.ps1 | 4 ++-- tests/QA/TrainVersion.tests.ps1 | 4 ++-- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 8 ++++++++ .../Pipeline/Invoke-GraphPaging.Tests.ps1 | 20 ++++++++++++++++--- 7 files changed, 37 insertions(+), 14 deletions(-) diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 346c9e3..6118f62 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -489,9 +489,10 @@ try { # Publish-Module writes PowerShellGet's export-discovery tags. The R8 # package task deliberately uses PSResourceGet's SemVer-capable archive - # writer instead, which retains only the manifest-declared tags. Both - # forms are deterministic projections of the same proven manifest; accept - # only either exact projection so metadata tampering remains detectable. + # 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 diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs index eb61aed..2f69cb2 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs @@ -297,7 +297,7 @@ private async Task RunShutdownAsync(TaskCompletionSource shutdownComple _sourceDisposalFailures.Clear(); } - Volatile.Write(ref _state, SourcesDisposedAwaitingDrain); + Interlocked.Exchange(ref _state, SourcesDisposedAwaitingDrain); TryFinalizeUnload(); GraphAuthException? finalizationFailure = await _finalizationCompletion.Task.ConfigureAwait(false); diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index a1c657f..7196c97 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -1636,15 +1636,15 @@ finally { throw 'A Verified descriptor is not bound to the sender before invocation.' } - $sender = Get-Task8ParsedFunction -Path ( + $senderFunction = Get-Task8ParsedFunction -Path ( Join-Path $SourceRoot 'Private/Transport/Send-GraphHttpRequest.ps1') ` -Name Send-GraphHttpRequest - $proofCalls = @($sender.FindAll({ + $proofCalls = @($senderFunction.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -ceq 'Confirm-GraphTenantBinding' }, $true)) - $physicalSends = @($sender.FindAll({ + $physicalSends = @($senderFunction.FindAll({ param($node) $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -and $node.Member.Extent.Text -ceq 'SendAsync' diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index a0e294e..c98c1ea 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -582,11 +582,11 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $observed = & { $tasks = @{} - function Capture-GraphKitAuthTask { + function Register-GraphKitAuthTaskCapture { param([string] $Name, [scriptblock] $Action) $tasks[$Name] = $Action } - Set-Alias -Name task -Value Capture-GraphKitAuthTask -Scope Local + Set-Alias -Name task -Value Register-GraphKitAuthTaskCapture -Scope Local . $script:taskPath diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index 7e1a00b..c1d3828 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -310,8 +310,8 @@ switch ($payload.Mode) { $inputBytes = [IO.MemoryStream]::new() [Console]::OpenStandardInput().CopyTo($inputBytes) - $input = $inputBytes.ToArray() - $stdout.Write($input, 0, $input.Length) + $capturedInput = $inputBytes.ToArray() + $stdout.Write($capturedInput, 0, $capturedInput.Length) $stdout.Flush() exit 0 } diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index 4061560..799c1af 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -2916,6 +2916,14 @@ Describe 'GraphKit.Auth ABI v1 validation and lifetime' -Tag 'Unit' { } 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') diff --git a/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 b/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 index 44422b8..0d830be 100644 --- a/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 +++ b/tests/Unit/Pipeline/Invoke-GraphPaging.Tests.ps1 @@ -85,12 +85,12 @@ BeforeAll { [guid] $TenantId = [guid] '00000000-0000-0000-0000-000000000001', [guid] $ActualTenantId = [guid] '00000000-0000-0000-0000-000000000001', [string] $IdentityState = 'VerifiedForToken', - [AllowNull()] [string] $TokenFingerprint = 'paging-token-fingerprint', - [AllowNull()] [string] $CredentialGeneration = 'paging-credential-generation', + [AllowNull()] [object] $TokenFingerprint = 'paging-token-fingerprint', + [AllowNull()] [object] $CredentialGeneration = 'paging-credential-generation', [string] $Cloud = 'Global' ) - return @{ + $provenance = @{ ProfileId = 'paging-verified' TenantId = $TenantId ActualTenantId = $ActualTenantId @@ -101,6 +101,12 @@ BeforeAll { 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 { @@ -219,6 +225,9 @@ Describe 'Invoke-GraphPaging' { $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 { @@ -261,6 +270,11 @@ Describe 'Invoke-GraphPaging' { $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)) From c5a7a7a778a3c97f0db6bcac3803ce87a135bcb0 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 01:37:58 -0400 Subject: [PATCH 54/79] test: resolve final review gaps --- .build/GraphKitAuth.tasks.ps1 | 5 +++- scripts/private/GraphKit.AuthStageCapture.cs | 12 ++++++++ tests/QA/GraphKitAuthPackage.tests.ps1 | 28 +++++++++++++++++++ .../Pipeline/Invoke-GraphOperation.Tests.ps1 | 17 ++++++++++- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index f3d6ee7..0d0ce37 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -1118,9 +1118,12 @@ function Assert-GraphKitAuthAbiProjectedFileEvidence { [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 $RepositoryRoot $actual.PhysicalPath) -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 diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 9dd3d53..4390c0e 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -82,6 +82,18 @@ public static GraphKitAuthPathEvidence InspectFileMetadata( 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); diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index c98c1ea..c58699a 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -1896,6 +1896,34 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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') { + $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')) + $null = New-Item -ItemType Directory -Path ( + Join-Path $physicalRepository 'source'), (Join-Path $physicalRepository 'destination') -Force + $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) ` + -Path $aliasAncestor -Target $physicalAncestor -ErrorAction Stop + $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')) + $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) ` + -Path $repositoryAlias -Target $physicalRepository -ErrorAction Stop + { Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $repositoryAlias ` + -RelativePath 'destination/candidate.dll' -Expected $aliasCopy.Destination } | + Should -Throw -Because 'the repository root itself must remain one no-follow directory' + } $candidate = Join-Path $destination 'candidate.dll' switch ($Kind) { 'byte mutation' { [IO.File]::WriteAllBytes($candidate, [byte[]](33..64)) } diff --git a/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 b/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 index 6d373a2..b18b687 100644 --- a/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 +++ b/tests/Unit/Pipeline/Invoke-GraphOperation.Tests.ps1 @@ -124,11 +124,26 @@ Describe 'Invoke-GraphOperation' { } 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 $script:Context -Uri 'https://graph.microsoft.com/v1.0/me' -Method GET + $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 } } From 0adb2ed00a91ce719b83f101be32ee950fbf6412 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 01:46:56 -0400 Subject: [PATCH 55/79] fix: synchronize parity capture helper --- scripts/Invoke-GraphKitAuthParity.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 7b83ea4..8916780 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - 'ec7b8e8fb971d702766b6f6cb474c5d509d60909cfc3bf6c97ab78952600daba' + '881fbd6fe5d5cf9280ae0fafc08c2556a82b1f91a45683d0eb6f6168ebe20aa4' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) From e6ec53ad831a819ab7207aa4b4a924a210e89f33 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 02:10:50 -0400 Subject: [PATCH 56/79] test: isolate lifecycle workers from thread job throttle --- .../GraphModuleLifecycleSender.Tests.ps1 | 131 ++++++++++++------ 1 file changed, 91 insertions(+), 40 deletions(-) diff --git a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 index 76630a1..fc6d48b 100644 --- a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 +++ b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 @@ -353,57 +353,92 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { $registered.Count | Should -Be 2 [object]::ReferenceEquals($registered[0], $hostCleanup) | Should -BeTrue [object]::ReferenceEquals($registered[1], $sourceCleanup) | Should -BeTrue - $stateKey = 'GraphKitTest.SenderState.' + [guid]::NewGuid().ToString('N') - $clientKey = 'GraphKitTest.SenderClient.' + [guid]::NewGuid().ToString('N') - [System.AppDomain]::CurrentDomain.SetData($stateKey, $state) - [System.AppDomain]::CurrentDomain.SetData($clientKey, $client) - $sendJob = $null - $stopJob = $null + $sendRunspace = $null + $stopRunspace = $null + $sendPipeline = $null + $stopPipeline = $null + $sendAsync = $null + $stopAsync = $null + $sendReceived = $false + $stopReceived = $false try { - $sendJob = Start-ThreadJob -ScriptBlock { - param($Manifest, $StateKey, $ClientKey) - Import-Module $Manifest -Force -ErrorAction Stop - $sharedState = [System.AppDomain]::CurrentDomain.GetData($StateKey) - $sharedClient = [System.AppDomain]::CurrentDomain.GetData($ClientKey) + # 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($State, $Client) + param($LifecycleState, $InjectedClient) $factory = { param([int] $ConnectTimeoutSeconds) [pscustomobject] @{ - Client = $Client + Client = $InjectedClient OwnedByGraphKit = $false } }.GetNewClosure() Send-GraphHttpRequest -Uri ([uri] 'https://graph.microsoft.com/v1.0/me') ` - -Method GET -CredentialPolicy None -LifecycleState $State ` + -Method GET -CredentialPolicy None -LifecycleState $LifecycleState ` -HttpClientFactory $factory -TimeoutHeadersSeconds 30 -TimeoutBodySeconds 30 - } $sharedState $sharedClient - } -ArgumentList $script:BuiltManifest, $stateKey, $clientKey + } $State $Client + }).AddArgument($state).AddArgument($client) + $sendAsync = $sendPipeline.BeginInvoke() - $handler.Started.Task.Wait(5000) | Should -BeTrue + $handler.Started.Task.Wait(5000) | Should -BeTrue ` + -Because 'dedicated runspace setup completed before the timed physical-send assertion' $state.ActiveOperations | Should -Be 1 - $stopJob = Start-ThreadJob -ScriptBlock { - param($Manifest, $StateKey) - Import-Module $Manifest -Force -ErrorAction Stop - $sharedState = [System.AppDomain]::CurrentDomain.GetData($StateKey) + $stopPipeline = [powershell]::Create() + $stopPipeline.Runspace = $stopRunspace + $null = $stopPipeline.AddScript({ + param($State) & (Get-Module GraphKit) { - param($State) - Stop-GraphModule -State $State - } $sharedState - } -ArgumentList $script:BuiltManifest, $stateKey + param($LifecycleState) + Stop-GraphModule -State $LifecycleState + } $State + }).AddArgument($state) + $stopAsync = $stopPipeline.BeginInvoke() - $stopCompleted = $stopJob | Wait-Job -Timeout 5 - if ($null -eq $stopCompleted) { + if (-not $stopAsync.AsyncWaitHandle.WaitOne(5000)) { $client.CancelPendingRequests() throw 'Stop-GraphModule did not cancel and drain the in-flight sender within five seconds.' } - $sendCompleted = @($sendJob | Wait-Job -Timeout 10) - $sendCompleted.Count | Should -Be 1 - $null = $stopJob | Receive-Job -ErrorAction Stop - $result = $sendJob | Receive-Job -ErrorAction Stop + $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 @@ -425,17 +460,33 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { } finally { try { $client.CancelPendingRequests() } catch { } - if ($null -ne $sendJob) { - $null = @($sendJob | Wait-Job -Timeout 10) - $sendJob | Remove-Job -Force -ErrorAction SilentlyContinue + $client.Dispose() + 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 { } + } } - if ($null -ne $stopJob) { - $null = @($stopJob | Wait-Job -Timeout 10) - $stopJob | Remove-Job -Force -ErrorAction SilentlyContinue + 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() } - [System.AppDomain]::CurrentDomain.SetData($stateKey, $null) - [System.AppDomain]::CurrentDomain.SetData($clientKey, $null) - $client.Dispose() } } } From 9dedd01a1e9ea8b408a906a5f407e6ee05b8e86a Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 02:10:50 -0400 Subject: [PATCH 57/79] docs: clarify verified tenant result contract --- src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs index dc5b102..f618f81 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs @@ -230,6 +230,10 @@ public sealed class GraphTokenResult 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; } From 6e8f8a8dd241258b6801d07f60888b44bbb5adb0 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 02:50:51 -0400 Subject: [PATCH 58/79] fix: resolve final R8 review findings --- .build/GraphKitAuth.tasks.ps1 | 2 +- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 2 +- .../specs/2026-08-14-graphkit-design.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- .../private/Test-GraphKitPackagePrivacy.ps1 | 70 ++++++++- .../GraphTokenSourceProxy.cs | 32 +++- .../GraphKit.Auth.Tests/OwnershipTests.cs | 100 ++++++++++++ .../GraphKit.Auth/GraphTokenSourceFactory.cs | 46 ++++-- tests/Adapter/TokenIdentityPipeline.Tests.ps1 | 117 +++++++++----- tests/QA/GraphKitAuthTestResultGate.tests.ps1 | 16 +- tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +- tests/QA/SourceHygiene.tests.ps1 | 143 ++++++++++++++++++ 16 files changed, 466 insertions(+), 80 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index 0d0ce37..3381307 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -17,7 +17,7 @@ $script:GraphKitAuthStage = $null $script:GraphKitAuthStageCaptureType = $null $script:GraphKitAuthAbiFixtureState = $null $script:GraphKitAuthAbiGitConfigState = $null -$script:GraphKitAuthExpectedTestCount = 74 +$script:GraphKitAuthExpectedTestCount = 77 function Assert-GraphKitAuthTestResult { [CmdletBinding()] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f73646..f540fd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ 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 1463 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1467 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index 8123e4d..bc72ce8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1463 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. +**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 1467 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. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9333c74..6fb89bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Release gates now require exactly 74 passing `GraphKit.Auth` tests, independently reconcile the +- 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. diff --git a/docs/superpowers/specs/2026-08-14-graphkit-design.md b/docs/superpowers/specs/2026-08-14-graphkit-design.md index 904ed87..a9e3b31 100644 --- a/docs/superpowers/specs/2026-08-14-graphkit-design.md +++ b/docs/superpowers/specs/2026-08-14-graphkit-design.md @@ -234,7 +234,7 @@ child runspace: nested PowerShell-class acquisition can hang before its method g 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 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 diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 3a79748..27cb66e 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1463 +$minimumTests = 1467 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 6118f62..86ad7e0 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1463 +$minimumTests = 1467 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 index 274cdcc..3aac8ba 100644 --- a/scripts/private/Test-GraphKitPackagePrivacy.ps1 +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -110,10 +110,19 @@ function Test-GraphKitPackagePrivacyText { } } - $guidPattern = '\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' + # 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)) { + (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 ` @@ -140,13 +149,64 @@ function Test-GraphKitPackagePrivacyText { '9a08498936078c81ec926fedbce5e7c9' = 'customer name (A, short form)' '6ca05670c4afd49e806f7cddbab83b00' = 'lab tenant id' } - foreach ($token in [regex]::Matches($Text, '[A-Za-z0-9][A-Za-z0-9-]{3,}')) { - $tokenDigest = (Get-GraphKitPackagePrivacyDigest -Value $token.Value.ToLowerInvariant()).Substring(0, 32) + $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.Value + -Evidence $token } } } diff --git a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs index 84e7a0f..b66823f 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs @@ -263,6 +263,8 @@ public void 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."; @@ -278,30 +280,48 @@ internal static Exception Recreate( ArgumentNullException.ThrowIfNull(providerFailure); if (providerFailure is OperationCanceledException) { - return new OperationCanceledException( + return PreserveSafeMarkers(providerFailure, new OperationCanceledException( CancellationMessage, innerException: null, - effectiveCancellation); + effectiveCancellation)); } if (providerFailure is GraphAuthException graphFailure) { - return new GraphAuthException( + 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); + SafeCorrelation(graphFailure.CorrelationId) ?? string.Empty)); } - return new GraphAuthException( + return PreserveSafeMarkers(providerFailure, new GraphAuthException( unexpectedCode, unexpectedCategory, SafeMessage, retryAfter: null, - correlationId: 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) diff --git a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs index 6ff3608..74a3549 100644 --- a/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs +++ b/src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs @@ -10,6 +10,8 @@ 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); @@ -426,6 +428,89 @@ public void FactoryFailureDisposesOnlyTransferredMaterial() 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)] @@ -705,6 +790,21 @@ private static CreateOutcome CaptureCreate( } } + 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) diff --git a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs index 1243f7d..4e5bb6e 100644 --- a/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs +++ b/src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs @@ -4,6 +4,8 @@ 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(); @@ -75,28 +77,39 @@ public IGraphTokenSource Create(GraphTokenRequest request) transferredMaterial = null; return source; } - catch (OperationCanceledException) + catch (OperationCanceledException exception) { - CleanupFailedTransfer(client, transferredMaterial); + if (CleanupFailedTransfer(client, transferredMaterial)) + { + MarkCleanupFailure(exception); + } throw; } - catch (GraphAuthException) + catch (GraphAuthException exception) { - CleanupFailedTransfer(client, transferredMaterial); + if (CleanupFailedTransfer(client, transferredMaterial)) + { + MarkCleanupFailure(exception); + } throw; } catch (Exception exception) { - CleanupFailedTransfer(client, transferredMaterial); - throw ProviderFailureSanitizer.Create( + bool cleanupFailed = CleanupFailedTransfer(client, transferredMaterial); + GraphAuthException failure = ProviderFailureSanitizer.Create( exception, "provider_construction_failed", "Provider", _utcNow); + if (cleanupFailed) + { + MarkCleanupFailure(failure); + } + throw failure; } } - private void CleanupFailedTransfer( + private bool CleanupFailedTransfer( ITokenClient? client, IDisposable? transferredMaterial) { @@ -122,14 +135,19 @@ private void CleanupFailedTransfer( } } - if (cleanupFailed) + return cleanupFailed; + } + + private static void MarkCleanupFailure(Exception primaryFailure) + { + try + { + primaryFailure.Data[CleanupFailureDataKey] = true; + } + catch { - throw new GraphAuthException( - "provider_construction_cleanup_failed", - "ProviderLifecycle", - "The isolated authentication provider could not clean up a failed source construction.", - retryAfter: null, - correlationId: null); + // A provider-owned cancellation subtype can override Data. Its metadata must + // never be able to replace the primary cancellation while recording cleanup. } } diff --git a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 index 526d1bd..f282558 100644 --- a/tests/Adapter/TokenIdentityPipeline.Tests.ps1 +++ b/tests/Adapter/TokenIdentityPipeline.Tests.ps1 @@ -1,6 +1,7 @@ BeforeAll { $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath - $built = Get-ChildItem (Join-Path $repoRoot 'output/module/GraphKit') -Directory | + $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." @@ -8,25 +9,46 @@ BeforeAll { 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 Get-TokenPipelineFreePort { - $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) - $listener.Start() - $port = ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port - $listener.Stop() - return $port - } - function Start-TokenPipelineServer { param( - [int] $Port, - [object[]] $Responses + [object[]] $Responses, + [scriptblock] $CandidatePortProvider = { + param([int] $Attempt) + + [System.Security.Cryptography.RandomNumberGenerator]::GetInt32( + 49152, + 65536) + } ) - $listener = [System.Net.HttpListener]::new() - $listener.Prefixes.Add("http://127.0.0.1:$Port/") - $listener.Start() + $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() @@ -67,6 +89,7 @@ BeforeAll { PowerShell = $powershell Handle = $handle Runspace = $runspace + Authority = [uri] "http://127.0.0.1:$port/" } $script:openServers.Add($server) return $server @@ -193,12 +216,41 @@ Describe 'Composed retry and sender token identity' { } } + 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' { - $port = Get-TokenPipelineFreePort - $server = Start-TokenPipelineServer -Port $port -Responses @( + $server = Start-TokenPipelineServer -Responses @( @{ StatusCode = 204; Body = $null } ) - $authority = [uri] "http://127.0.0.1:$port" + $authority = $server.Authority $tokenSource = New-RotatingTokenSource $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor), $authority { @@ -215,11 +267,10 @@ Describe 'Composed retry and sender token identity' { } It 'proves a descriptor-verified GET even when the provider claims the tenant without a cache record' { - $port = Get-TokenPipelineFreePort - $server = Start-TokenPipelineServer -Port $port -Responses @( + $server = Start-TokenPipelineServer -Responses @( @{ StatusCode = 200; Body = '{"value":[]}' } ) - $authority = [uri] "http://127.0.0.1:$port" + $authority = $server.Authority $tokenSource = New-RotatingTokenSource -ClaimedTenantId $script:TenantId.ToString() $script:verifiedGetProofCalls = 0 $script:verifiedGetProofToken = $null @@ -279,8 +330,7 @@ Describe 'Composed retry and sender token identity' { } It 'returns DeadlineExpired and releases admission before acquisition when the inherited proof budget is exhausted' { - $port = Get-TokenPipelineFreePort - $authority = [uri] "http://127.0.0.1:$port" + $authority = $script:NoSendAuthority $tokenSource = New-RotatingTokenSource $script:deadlineProofEntered = 0 $script:deadlineProofSawCancellation = $false @@ -345,8 +395,7 @@ Describe 'Composed retry and sender token identity' { } It 'returns Cancelled and releases admission when a descriptor-verified GET is cancelled during proof' { - $port = Get-TokenPipelineFreePort - $authority = [uri] "http://127.0.0.1:$port" + $authority = $script:NoSendAuthority $cts = [System.Threading.CancellationTokenSource]::new() $tokenSource = New-RotatingTokenSource $tokenSource | Add-Member -MemberType NoteProperty -Name CancellationSource -Value $cts @@ -410,12 +459,11 @@ Describe 'Composed retry and sender token identity' { } It 'uses false then true acquisition flags across one 401 refresh' { - $port = Get-TokenPipelineFreePort - $server = Start-TokenPipelineServer -Port $port -Responses @( + $server = Start-TokenPipelineServer -Responses @( @{ StatusCode = 401; Body = '{"error":{"code":"InvalidAuthenticationToken"}}' } @{ StatusCode = 200; Body = '{"value":[]}' } ) - $authority = [uri] "http://127.0.0.1:$port" + $authority = $server.Authority $tokenSource = New-RotatingTokenSource $injections = @{ Delay = { param([double] $Seconds) } @@ -436,11 +484,10 @@ Describe 'Composed retry and sender token identity' { } It 'does not elevate an unproven provider tenant claim into provenance' { - $port = Get-TokenPipelineFreePort - $server = Start-TokenPipelineServer -Port $port -Responses @( + $server = Start-TokenPipelineServer -Responses @( @{ StatusCode = 200; Body = '{"value":[]}' } ) - $authority = [uri] "http://127.0.0.1:$port" + $authority = $server.Authority $tokenSource = New-RotatingTokenSource -ClaimedTenantId $script:TenantId.ToString() $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor), $authority { @@ -456,12 +503,11 @@ Describe 'Composed retry and sender token identity' { } It 'does not carry an earlier token proof across a 401 refresh' { - $port = Get-TokenPipelineFreePort - $server = Start-TokenPipelineServer -Port $port -Responses @( + $server = Start-TokenPipelineServer -Responses @( @{ StatusCode = 401; Body = '{"error":{"code":"InvalidAuthenticationToken"}}' } @{ StatusCode = 200; Body = '{"value":[]}' } ) - $authority = [uri] "http://127.0.0.1:$port" + $authority = $server.Authority $tokenSource = New-RotatingTokenSource -ClaimedTenantId $script:TenantId.ToString() InModuleScope GraphKit -ArgumentList $script:TenantId { @@ -536,13 +582,12 @@ Describe 'Composed retry and sender token identity' { } It 'proves and sends a mutation with the same exact token' { - $port = Get-TokenPipelineFreePort $tenantBody = '{"value":[{"id":"' + $script:TenantId.ToString() + '"}]}' - $server = Start-TokenPipelineServer -Port $port -Responses @( + $server = Start-TokenPipelineServer -Responses @( @{ StatusCode = 200; Body = $tenantBody } @{ StatusCode = 204; Body = $null } ) - $authority = [uri] "http://127.0.0.1:$port" + $authority = $server.Authority $tokenSource = New-RotatingTokenSource $result = InModuleScope GraphKit -ArgumentList (New-TokenPipelineContext -Authority $authority -TokenSource $tokenSource), (New-TokenPipelineDescriptor -ReplayPolicy NeverReplay -ThrottleClass Write), $authority { diff --git a/tests/QA/GraphKitAuthTestResultGate.tests.ps1 b/tests/QA/GraphKitAuthTestResultGate.tests.ps1 index 80089db..93ed951 100644 --- a/tests/QA/GraphKitAuthTestResultGate.tests.ps1 +++ b/tests/QA/GraphKitAuthTestResultGate.tests.ps1 @@ -24,7 +24,7 @@ BeforeAll { } Describe 'GraphKit.Auth machine-readable test result gate' -Tag 'QA' { - It 'wires the authoritative validator into the build and accepts exactly 74 passing tests' { + 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( @@ -32,22 +32,22 @@ Describe 'GraphKit.Auth machine-readable test result gate' -Tag 'QA' { '(?m)^\s*Assert-GraphKitAuthTestResult\s+-Result\s+\$trx\s*$' )).Count | Should -Be 1 - $result = New-GraphKitAuthTrxResult -Total 74 + $result = New-GraphKitAuthTrxResult -Total 77 { Assert-GraphKitAuthTestResult -Result $result } | Should -Not -Throw } - It 'rejects an all-passing result with only 73 discovered tests' { - $result = New-GraphKitAuthTrxResult -Total 73 + It 'rejects an all-passing result with only 76 discovered tests' { + $result = New-GraphKitAuthTrxResult -Total 76 { Assert-GraphKitAuthTestResult -Result $result } | - Should -Throw '*expected exactly 74*' + Should -Throw '*expected exactly 77*' } - It 'rejects an all-passing result with 75 discovered tests' { - $result = New-GraphKitAuthTrxResult -Total 75 + It 'rejects an all-passing result with 78 discovered tests' { + $result = New-GraphKitAuthTrxResult -Total 78 { Assert-GraphKitAuthTestResult -Result $result } | - Should -Throw '*expected exactly 74*' + Should -Throw '*expected exactly 77*' } } diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index d7d04c2..064c2be 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 = 1463) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1467) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index da72c0d..62da7a5 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -153,7 +153,7 @@ BeforeAll { [switch] $NullRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1463 + [int] $Total = 1467 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -386,7 +386,7 @@ $requiredAssembliesLine$requiredModulesLine sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1463 + minimumTests = 1467 allowedSkips = 0 allowedNotRun = 0 } @@ -1096,7 +1096,7 @@ Describe 'Test workflow release-proof generation' { $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 1463 + $proof.testRun.summary.total | Should -Be 1467 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse diff --git a/tests/QA/SourceHygiene.tests.ps1 b/tests/QA/SourceHygiene.tests.ps1 index ba65306..ba62451 100644 --- a/tests/QA/SourceHygiene.tests.ps1 +++ b/tests/QA/SourceHygiene.tests.ps1 @@ -61,6 +61,149 @@ Describe 'GraphKit.Auth authored project-source privacy' { Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue } } + + 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' { From 5a5bb4034c73d6794ad9e77d9e5e5c92b5a79b6c Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 03:25:02 -0400 Subject: [PATCH 59/79] test: harden async cleanup seams --- .../GraphModuleLifecycleSender.Tests.ps1 | 2 +- .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 index fc6d48b..0369a82 100644 --- a/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 +++ b/tests/Adapter/GraphModuleLifecycleSender.Tests.ps1 @@ -460,7 +460,6 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { } finally { try { $client.CancelPendingRequests() } catch { } - $client.Dispose() if ($null -ne $sendAsync -and -not $sendReceived) { if ($sendAsync.AsyncWaitHandle.WaitOne(10000)) { try { $null = $sendPipeline.EndInvoke($sendAsync) } catch { } @@ -477,6 +476,7 @@ Describe 'Send-GraphHttpRequest module lifecycle adapter' { try { $stopPipeline.Stop() } catch { } } } + try { $client.Dispose() } catch { } if ($null -ne $sendPipeline) { $sendPipeline.Dispose() } if ($null -ne $stopPipeline) { $stopPipeline.Dispose() } if ($null -ne $sendRunspace) { diff --git a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index 260cce4..68e52e4 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -188,7 +188,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) @@ -203,7 +203,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) @@ -221,7 +221,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) @@ -247,7 +247,7 @@ Describe 'Confirm-GraphTenantBinding' { $tokenResult = New-TestTokenResult $tokenResult.$Field = $Value $transport = { - param($Context, $Descriptor, $Uri) + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) $script:proofCalls++ return $script:proofEnvelope } @@ -267,7 +267,7 @@ Describe 'Confirm-GraphTenantBinding' { It 'cannot reuse one empty-metadata binding for two distinct bearer tokens' { $cache = @{} $transport = { - param($Context, $Descriptor, $Uri) + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) $script:proofCalls++ return $script:proofEnvelope } @@ -311,7 +311,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. @@ -330,7 +330,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) @@ -468,7 +468,10 @@ Describe 'Confirm-GraphTenantBinding' { Confirm-GraphTenantBinding -Context $Context -TokenResult $TokenResult ` -ProofCache $Cache -CancellationToken $CancellationToken ` -RemainingDeadline ([TimeSpan]::Zero) ` - -ProofTransport { throw 'proof transport must not run at the cancelled boundary' } + -ProofTransport { + param($Context, $Descriptor, $Uri, $CancellationToken, $RemainingDeadline) + throw 'proof transport must not run at the cancelled boundary' + } return $null } catch { @@ -579,7 +582,7 @@ Describe 'Send-GraphHttpRequest tenant-proof wiring' { $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) From 5825ff9cfb8419815264d52efcb36de60bacade6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 03:49:17 -0400 Subject: [PATCH 60/79] test: isolate parity fan-out workers --- tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 | 158 +++++++++++++------ 1 file changed, 107 insertions(+), 51 deletions(-) diff --git a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 index 8031fcc..1cb2626 100644 --- a/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuthParity.Tests.ps1 @@ -874,7 +874,7 @@ public sealed class Task7LegacyAuthenticationResult [Parameter(Mandatory)] $Row ) $key = 'task7-parity-' + [guid]::NewGuid().ToString('N') - $jobs = @() + $workers = [Collections.Generic.List[object]]::new() $outerTokens = [string[]] @($Row.input.tokens) $outerExpiries = [DateTimeOffset[]] @($Row.input.expiresOnUtc | ForEach-Object { ConvertFrom-Task7TimestampLiteral ([string] $_) @@ -888,54 +888,93 @@ public sealed class Task7LegacyAuthenticationResult ) $waitersObserved = $false try { - $jobs = @( - 1..4 | ForEach-Object { - Start-ThreadJob -ScriptBlock { - param($Manifest, $Key) - $module = $null - $state = $null - $outcome = $null - try { - $module = Import-Module $Manifest -Force -PassThru -ErrorAction Stop - $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) + # 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 + } + finally { + if ($null -ne $module) { + Remove-Module $module -Force -ErrorAction SilentlyContinue } - $outcome | Add-Member NoteProperty ChildCleanup $cleaned - return $outcome - } -ArgumentList $script:BuiltManifest, $key - } - ) + $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 @@ -944,9 +983,14 @@ public sealed class Task7LegacyAuthenticationResult 5000 ) [GraphKit.Tests.Task7LegacyHarness]::ReleaseOuter() - $completed = @($jobs | Wait-Job -Timeout 10) - $completed.Count | Should -Be 4 - $outcomes = @($jobs | Receive-Job) + $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) @@ -986,8 +1030,20 @@ public sealed class Task7LegacyAuthenticationResult } finally { [GraphKit.Tests.Task7LegacyHarness]::CancelOuter() - foreach ($job in $jobs) { - Remove-Job $job -Force -ErrorAction SilentlyContinue + 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() + } } } } From f91f4f28edbf4392558ab60c70f72a946b563eda Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 04:19:07 -0400 Subject: [PATCH 61/79] fix: atomically secure Windows stage files --- scripts/private/GraphKit.AuthStageCapture.cs | 77 +++++++++++++++++++- tests/QA/GraphKitAuthPackage.tests.ps1 | 57 +++++++++++++-- 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 4390c0e..71872fb 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -50,12 +50,16 @@ public sealed class GraphKitAuthWriteEvidence public static class GraphKitAuthStageCapture { private const uint GenericRead = 0x80000000; + private const uint GenericWrite = 0x40000000; 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 FileAttributeReparsePoint = 0x00000400; public static GraphKitAuthPathEvidence InspectFile(string rootPath, string relativePath) @@ -256,7 +260,8 @@ public static GraphKitAuthCopyEvidence CopyFileCreateNew( throw new IOException($"Source '{sourceRelativePath}' exceeds its bounded capture length."); } - using FileStream destinationStream = OpenDestinationCreateNew(destinationPath); + using FileStream destinationStream = OpenDestinationCreateNew( + destinationPath, requireInitialOwnerOnly); SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; GraphKitAuthPathEvidence destinationInitial = EvidenceFromHandle( destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); @@ -318,7 +323,8 @@ public static GraphKitAuthWriteEvidence WriteFileCreateNew( string destinationPath = ResolveRelative(destinationRoot, destinationRelativePath); EnsureAncestors(destinationRoot, destinationRelativePath); - using FileStream destinationStream = OpenDestinationCreateNew(destinationPath); + using FileStream destinationStream = OpenDestinationCreateNew( + destinationPath, requireInitialOwnerOnly); SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; GraphKitAuthPathEvidence destinationInitial = EvidenceFromHandle( destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); @@ -657,8 +663,68 @@ private static SafeFileHandle OpenReadNoFollow(string fullPath, bool directory) return new SafeFileHandle((IntPtr)fd, ownsHandle: true); } - private static FileStream OpenDestinationCreateNew(string destinationPath) + private static FileStream OpenDestinationCreateNew( + string destinationPath, + bool requireInitialOwnerOnly) { + if (OperatingSystem.IsWindows() && requireInitialOwnerOnly) + { + FileSecurity security = new(); + SecurityIdentifier owner = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + owner, + 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 + }; + SafeFileHandle handle = CreateFileWithSecurityW( + destinationPath, + GenericRead | GenericWrite, + ShareRead, + ref attributes, + CreateNew, + FileAttributeNormal | FileFlagWriteThrough, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + if (error == 80 || error == 183) + { + throw new IOException("Atomic owner-only file destination collision."); + } + throw new IOException( + $"Could not atomically create owner-only destination file (Win32 {error})."); + } + try + { + return new FileStream(handle, FileAccess.ReadWrite, bufferSize: 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + finally + { + pinnedDescriptor.Free(); + } + } + var options = new FileStreamOptions { Mode = FileMode.CreateNew, @@ -1065,6 +1131,11 @@ private struct ByHandleFileInformation 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); diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index c58699a..6d525d9 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -1763,6 +1763,10 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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'") @@ -1820,18 +1824,43 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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) + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $worldSid = [Security.Principal.SecurityIdentifier]::new( + [Security.Principal.WellKnownSidType]::WorldSid, $null) + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($currentSid) + $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)) + [Security.AccessControl.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') + $source, 'candidate.dll', $destination, 'candidate.dll', $true) $copy.DestinationInitial.OwnerOnlyAccess | Should -BeTrue + $copy.DestinationInitial.OwnerSid | Should -BeExactly $currentSid.Value $copy.DestinationInitial.CurrentIdentitySid | - Should -BeExactly ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + Should -BeExactly $currentSid.Value + $copy.DestinationInitial.AccessRulesProtected | Should -BeTrue + $copy.DestinationInitial.HasInheritedAccessRules | Should -BeFalse + $captured = Join-Path $destination 'candidate.dll' + $renamed = Join-Path $destination 'candidate-renamed.dll' + [IO.File]::Move($captured, $renamed) + [IO.File]::Delete($renamed) + Test-Path -LiteralPath $renamed | Should -BeFalse } - It 'allows an ordinary Windows inherited-ACL copy only when the sealed initial gate is false' -ForEach $windowsInitialAccessCases -AllowNullOrEmptyForEach { + 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' @@ -1858,10 +1887,24 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Set-Acl -LiteralPath $directory -AclObject $acl } + $ordinary = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( + $source, 'candidate.dll', $destination, 'candidate.dll', $false) + $ordinary.DestinationInitial.OwnerOnlyAccess | Should -BeFalse + + $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 + + $sealedPath = Join-Path $sealedDestination 'candidate.dll' + $sealedHash = (Get-FileHash -LiteralPath $sealedPath -Algorithm SHA256).Hash { $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( - $source, 'candidate.dll', $destination, 'candidate.dll', $false) } | Should -Not -Throw - { $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( - $source, 'candidate.dll', $sealedDestination, 'candidate.dll', $true) } | Should -Throw '*owner-only*' + $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 { From 7e7af10c5fb486a15062c23748ae9b358dcfea4d Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 04:29:53 -0400 Subject: [PATCH 62/79] fix: reject RFC-shaped placeholder identifiers --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- scripts/Invoke-GraphKitAuthParity.ps1 | 4 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- .../private/Test-GraphKitPackagePrivacy.ps1 | 7 ++++ tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +-- tests/QA/SourceHygiene.tests.ps1 | 38 +++++++++++++++++++ 9 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f540fd1..032c51d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ 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 1467 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1468 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index bc72ce8..649fb74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1467 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. +**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 1468 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. 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. diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 8916780..cc228da 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - '881fbd6fe5d5cf9280ae0fafc08c2556a82b1f91a45683d0eb6f6168ebe20aa4' + '4f3c2283640bfe503b5a8e4af68d8aaeb7266a619590afc7d287d8348a4ff459' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 27cb66e..5f8708b 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1467 +$minimumTests = 1468 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 86ad7e0..d426d95 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1467 +$minimumTests = 1468 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/private/Test-GraphKitPackagePrivacy.ps1 b/scripts/private/Test-GraphKitPackagePrivacy.ps1 index 3aac8ba..9e65841 100644 --- a/scripts/private/Test-GraphKitPackagePrivacy.ps1 +++ b/scripts/private/Test-GraphKitPackagePrivacy.ps1 @@ -25,6 +25,13 @@ function Test-GraphKitPackagePrivacyPlaceholderGuid { 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 diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index 064c2be..e160cfe 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 = 1467) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1468) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 62da7a5..9f89b09 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -153,7 +153,7 @@ BeforeAll { [switch] $NullRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1467 + [int] $Total = 1468 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -386,7 +386,7 @@ $requiredAssembliesLine$requiredModulesLine sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1467 + minimumTests = 1468 allowedSkips = 0 allowedNotRun = 0 } @@ -1096,7 +1096,7 @@ Describe 'Test workflow release-proof generation' { $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 1467 + $proof.testRun.summary.total | Should -Be 1468 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse diff --git a/tests/QA/SourceHygiene.tests.ps1 b/tests/QA/SourceHygiene.tests.ps1 index ba62451..3a44702 100644 --- a/tests/QA/SourceHygiene.tests.ps1 +++ b/tests/QA/SourceHygiene.tests.ps1 @@ -62,6 +62,44 @@ Describe 'GraphKit.Auth authored project-source privacy' { } } + 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 { From 2279348268b05eedde66717c6acb55b0f89ce849 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 04:47:30 -0400 Subject: [PATCH 63/79] fix: close final R8 cleanup review gaps --- .build/GraphKitAuth.tasks.ps1 | 81 ++++++++++++++++--- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- tests/QA/GraphKitAuthPackage.tests.ps1 | 30 +++++++ tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +- .../Auth/Confirm-GraphTenantBinding.Tests.ps1 | 20 +++++ 9 files changed, 126 insertions(+), 21 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index 3381307..e370147 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -155,8 +155,10 @@ function Initialize-GraphKitAuthOwnerDirectory { [Parameter(Mandatory)] $ParentEvidence, [Parameter(Mandatory)][string] $ChildName, [Parameter(Mandatory)][string] $Kind, - [scriptblock] $AfterChildInspection + [scriptblock] $AfterChildInspection, + [ref] $CreatedByCall ) + if ($null -ne $CreatedByCall) { $CreatedByCall.Value = $false } Initialize-GraphKitAuthStageCapture Assert-GraphKitAuthSafeSegment -Value $ChildName -Kind $Kind $parent = [IO.Path]::GetFullPath($ParentPath) @@ -224,6 +226,7 @@ function Initialize-GraphKitAuthOwnerDirectory { -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($after)) { throw "The GraphKit.Auth $Kind path changed while owner-only access was applied." } + if ($null -ne $CreatedByCall) { $CreatedByCall.Value = $created } return $after } catch { @@ -703,18 +706,66 @@ function New-GraphKitAuthSealedStage { $authParentName = [IO.Path]::GetFileName($authParent) $authParentEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( $authParentParent, $authParentName) - $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authParent ` - -ParentEvidence $authParentEvidence -ChildName ([IO.Path]::GetFileName($authRoot)) ` - -Kind 'auth root' -AfterChildInspection $AfterOwnedDirectoryCreate $captureRoot = Join-Path $authRoot 'capture' $stageRoot = Join-Path $authRoot 'stage' $versionRoot = Join-Path $stageRoot $FullVersion - $captureRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` - -ParentEvidence $authEvidence -ChildName 'capture' -Kind 'capture root' ` - -AfterChildInspection $AfterOwnedDirectoryCreate - $stageRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` - -ParentEvidence $authEvidence -ChildName 'stage' -Kind 'stage root' ` - -AfterChildInspection $AfterOwnedDirectoryCreate + $authEvidence = $null + $captureRootEvidence = $null + $stageRootEvidence = $null + $authRootCreated = $false + $captureRootCreated = $false + $stageRootCreated = $false + try { + $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authParent ` + -ParentEvidence $authParentEvidence -ChildName ([IO.Path]::GetFileName($authRoot)) ` + -Kind 'auth root' -AfterChildInspection $AfterOwnedDirectoryCreate ` + -CreatedByCall ([ref]$authRootCreated) + $captureRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'capture' -Kind 'capture root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate ` + -CreatedByCall ([ref]$captureRootCreated) + $stageRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'stage' -Kind 'stage root' ` + -AfterChildInspection $AfterOwnedDirectoryCreate ` + -CreatedByCall ([ref]$stageRootCreated) + } + catch { + $primary = $_ + $cleanupFailures = [Collections.Generic.List[string]]::new() + foreach ($ownedRoot in @( + [pscustomobject]@{ + Created = $stageRootCreated; ParentPath = $authRoot + ParentEvidence = $authEvidence; ChildName = 'stage' + ChildEvidence = $stageRootEvidence; Kind = 'stage root initialization cleanup' + } + [pscustomobject]@{ + Created = $captureRootCreated; ParentPath = $authRoot + ParentEvidence = $authEvidence; ChildName = 'capture' + ChildEvidence = $captureRootEvidence; Kind = 'capture root initialization cleanup' + } + [pscustomobject]@{ + Created = $authRootCreated; ParentPath = $authParent + ParentEvidence = $authParentEvidence + ChildName = [IO.Path]::GetFileName($authRoot) + ChildEvidence = $authEvidence; Kind = 'auth root initialization cleanup' + } + )) { + if (-not $ownedRoot.Created -or $null -eq $ownedRoot.ChildEvidence) { continue } + try { + Remove-GraphKitAuthVerifiedEmptyDirectory ` + -ParentPath $ownedRoot.ParentPath ` + -ParentEvidence $ownedRoot.ParentEvidence ` + -ChildName $ownedRoot.ChildName ` + -ChildEvidence $ownedRoot.ChildEvidence ` + -Kind $ownedRoot.Kind + } + catch { $cleanupFailures.Add($_.Exception.Message) } + } + if ($cleanupFailures.Count -ne 0) { + throw "GraphKit.Auth authority initialization failed and ambiguous cleanup was refused: $($cleanupFailures -join ' | ') Original failure: $($primary.Exception.Message)" + } + throw $primary + } $runId = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)).ToLowerInvariant() $capture = Join-Path $captureRoot $runId $payload = Join-Path $capture 'payload' @@ -1138,7 +1189,8 @@ function New-GraphKitAuthAbiTestFixture { [CmdletBinding()] param( [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $OutputRoot + [Parameter(Mandatory)][string] $OutputRoot, + [scriptblock] $AfterFixtureCopy ) Initialize-GraphKitAuthStageCapture $sourceManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $RepositoryRoot 'source/GraphKit.psd1') @@ -1213,6 +1265,11 @@ function New-GraphKitAuthAbiTestFixture { $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( $verified.PayloadPath, $entry.Key, $destination, $entry.Key ) + $script:GraphKitAuthAbiFixtureState.CreatedPaths.Add($destinationFile) + $script:GraphKitAuthAbiFixtureState.ExpectedEvidence[$destinationFile] = $copy.Destination + if ($null -ne $AfterFixtureCopy) { + & $AfterFixtureCopy $relativeFile $copy.Destination + } $manifestRecord = @($verified.Manifest.files | Where-Object { [string]$_.path -ceq "payload/$($entry.Key)" }) @@ -1221,8 +1278,6 @@ function New-GraphKitAuthAbiTestFixture { [long]$copy.Destination.LinkCount -ne 1) { throw "The GraphKit.Auth ABI test fixture '$($entry.Key)' does not match the sealed payload." } - $script:GraphKitAuthAbiFixtureState.CreatedPaths.Add($destinationFile) - $script:GraphKitAuthAbiFixtureState.ExpectedEvidence[$destinationFile] = $copy.Destination & 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." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 032c51d..b157fd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ 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 1468 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1472 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index 649fb74..7bc8459 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1468 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. +**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 1472 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. 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. diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 5f8708b..e719bed 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1468 +$minimumTests = 1472 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index d426d95..02934a9 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1468 +$minimumTests = 1472 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 6d525d9..fcb10da 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -993,6 +993,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } It 'removes identity-bound owned state after injected creation failure' -ForEach @( + @{ FailureKind = 'capture root' } + @{ FailureKind = 'stage root' } @{ FailureKind = 'capture payload' } @{ FailureKind = 'temporary install root' } ) { @@ -1015,6 +1017,10 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $failure | Should -Match ([regex]::Escape("injected $FailureKind creation failure")) $failure | Should -Not -Match 'ambiguous cleanup|Original failure' + if ($FailureKind -in @('capture root', 'stage root')) { + Test-Path -LiteralPath (Join-Path $fixtureOutput 'GraphKit.Auth') | + Should -BeFalse + } foreach ($rootName in @('capture','stage')) { $root = Join-Path $fixtureOutput "GraphKit.Auth/$rootName" if (Test-Path -LiteralPath $root -PathType Container) { @@ -1708,6 +1714,30 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Test-Path -LiteralPath $bin | Should -BeFalse } + It 'removes a copied ABI projection when validation fails immediately after creation' { + Assert-GraphKitAuthStageCommands + $failure = $null + try { + $null = New-GraphKitAuthAbiTestFixture -RepositoryRoot $script:repoRoot ` + -OutputRoot (Join-Path $script:repoRoot 'output') ` + -AfterFixtureCopy { + param($relativeFile) + throw "injected post-copy validation failure for $relativeFile" + } + } + catch { $failure = $_.Exception.Message } + + $failure | Should -Match 'injected post-copy validation failure' + foreach ($binRoot in @( + 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin' + 'src/GraphKit.Auth/GraphKit.Auth/bin' + 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin' + )) { + Test-Path -LiteralPath (Join-Path $script:repoRoot $binRoot) | + Should -BeFalse + } + } + 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')) diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index e160cfe..86efb47 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 = 1468) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1472) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 9f89b09..19e2a5b 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -153,7 +153,7 @@ BeforeAll { [switch] $NullRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1468 + [int] $Total = 1472 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -386,7 +386,7 @@ $requiredAssembliesLine$requiredModulesLine sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1468 + minimumTests = 1472 allowedSkips = 0 allowedNotRun = 0 } @@ -1096,7 +1096,7 @@ Describe 'Test workflow release-proof generation' { $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 1468 + $proof.testRun.summary.total | Should -Be 1472 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse diff --git a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 index 68e52e4..0f79e9f 100644 --- a/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 +++ b/tests/Unit/Auth/Confirm-GraphTenantBinding.Tests.ps1 @@ -20,6 +20,7 @@ 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); } } @@ -79,6 +80,16 @@ namespace GraphKit.Tests '@ } + $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' @@ -180,6 +191,15 @@ namespace GraphKit.Tests 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 From a86db13d1f8ea0c97cf28637053bcbf9c8566acd Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 05:04:46 -0400 Subject: [PATCH 64/79] fix: preserve shared build authority roots --- .build/GraphKitAuth.tasks.ps1 | 122 +++++---------------- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- tests/QA/GraphKitAuthPackage.tests.ps1 | 98 ++++++++++++----- tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +- 8 files changed, 108 insertions(+), 128 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index e370147..83fb1de 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -156,9 +156,8 @@ function Initialize-GraphKitAuthOwnerDirectory { [Parameter(Mandatory)][string] $ChildName, [Parameter(Mandatory)][string] $Kind, [scriptblock] $AfterChildInspection, - [ref] $CreatedByCall + [switch] $PreserveCreatedOnFailure ) - if ($null -ne $CreatedByCall) { $CreatedByCall.Value = $false } Initialize-GraphKitAuthStageCapture Assert-GraphKitAuthSafeSegment -Value $ChildName -Kind $Kind $parent = [IO.Path]::GetFullPath($ParentPath) @@ -226,12 +225,11 @@ function Initialize-GraphKitAuthOwnerDirectory { -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($after)) { throw "The GraphKit.Auth $Kind path changed while owner-only access was applied." } - if ($null -ne $CreatedByCall) { $CreatedByCall.Value = $created } return $after } catch { $primary = $_ - if ($created -and $null -ne $before) { + if ($created -and $null -ne $before -and -not $PreserveCreatedOnFailure) { try { Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $parent ` -ParentEvidence $reopenedParent -ChildName $ChildName ` @@ -260,33 +258,16 @@ function Initialize-GraphKitAuthBuildAuthorityRoot { $outputName = [IO.Path]::GetFileName($output) $outputEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory( $outputParent, $outputName) - $authEntryBefore = Get-GraphKitAuthPortableChildEntry -ParentPath $output ` - -ChildName 'GraphKit.Auth' -Kind 'build auth root' - $authCreated = -not $authEntryBefore.Exists $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $output ` -ParentEvidence $outputEvidence -ChildName 'GraphKit.Auth' ` - -Kind 'build auth root' -AfterChildInspection $AfterChildInspection + -Kind 'build auth root' -AfterChildInspection $AfterChildInspection ` + -PreserveCreatedOnFailure $authRoot = Join-Path $output 'GraphKit.Auth' - try { - $null = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` - -ParentEvidence $authEvidence -ChildName 'capture' ` - -Kind 'build capture root' -AfterChildInspection $AfterChildInspection - return $authEvidence - } - catch { - $primary = $_ - if ($authCreated) { - try { - Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $output ` - -ParentEvidence $outputEvidence -ChildName 'GraphKit.Auth' ` - -ChildEvidence $authEvidence -Kind 'incomplete build authority root cleanup' - } - catch { - throw "GraphKit.Auth build authority initialization failed and ambiguous cleanup was refused: $($_.Exception.Message) Original failure: $($primary.Exception.Message)" - } - } - throw $primary - } + $null = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` + -ParentEvidence $authEvidence -ChildName 'capture' ` + -Kind 'build capture root' -AfterChildInspection $AfterChildInspection ` + -PreserveCreatedOnFailure + return $authEvidence } function Remove-GraphKitAuthVerifiedInstallCandidate { @@ -709,63 +690,16 @@ function New-GraphKitAuthSealedStage { $captureRoot = Join-Path $authRoot 'capture' $stageRoot = Join-Path $authRoot 'stage' $versionRoot = Join-Path $stageRoot $FullVersion - $authEvidence = $null - $captureRootEvidence = $null - $stageRootEvidence = $null - $authRootCreated = $false - $captureRootCreated = $false - $stageRootCreated = $false - try { - $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authParent ` - -ParentEvidence $authParentEvidence -ChildName ([IO.Path]::GetFileName($authRoot)) ` - -Kind 'auth root' -AfterChildInspection $AfterOwnedDirectoryCreate ` - -CreatedByCall ([ref]$authRootCreated) - $captureRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` - -ParentEvidence $authEvidence -ChildName 'capture' -Kind 'capture root' ` - -AfterChildInspection $AfterOwnedDirectoryCreate ` - -CreatedByCall ([ref]$captureRootCreated) - $stageRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot ` - -ParentEvidence $authEvidence -ChildName 'stage' -Kind 'stage root' ` - -AfterChildInspection $AfterOwnedDirectoryCreate ` - -CreatedByCall ([ref]$stageRootCreated) - } - catch { - $primary = $_ - $cleanupFailures = [Collections.Generic.List[string]]::new() - foreach ($ownedRoot in @( - [pscustomobject]@{ - Created = $stageRootCreated; ParentPath = $authRoot - ParentEvidence = $authEvidence; ChildName = 'stage' - ChildEvidence = $stageRootEvidence; Kind = 'stage root initialization cleanup' - } - [pscustomobject]@{ - Created = $captureRootCreated; ParentPath = $authRoot - ParentEvidence = $authEvidence; ChildName = 'capture' - ChildEvidence = $captureRootEvidence; Kind = 'capture root initialization cleanup' - } - [pscustomobject]@{ - Created = $authRootCreated; ParentPath = $authParent - ParentEvidence = $authParentEvidence - ChildName = [IO.Path]::GetFileName($authRoot) - ChildEvidence = $authEvidence; Kind = 'auth root initialization cleanup' - } - )) { - if (-not $ownedRoot.Created -or $null -eq $ownedRoot.ChildEvidence) { continue } - try { - Remove-GraphKitAuthVerifiedEmptyDirectory ` - -ParentPath $ownedRoot.ParentPath ` - -ParentEvidence $ownedRoot.ParentEvidence ` - -ChildName $ownedRoot.ChildName ` - -ChildEvidence $ownedRoot.ChildEvidence ` - -Kind $ownedRoot.Kind - } - catch { $cleanupFailures.Add($_.Exception.Message) } - } - if ($cleanupFailures.Count -ne 0) { - throw "GraphKit.Auth authority initialization failed and ambiguous cleanup was refused: $($cleanupFailures -join ' | ') Original failure: $($primary.Exception.Message)" - } - throw $primary - } + $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' @@ -990,7 +924,9 @@ function Invoke-GraphKitAuthPrepareClean { $captureEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authRoot ` -ChildName 'capture' -Kind 'Prepare capture root' if (-not $captureEntry.Exists) { - throw 'The GraphKit.Auth Prepare capture root is missing from a partial output tree.' + 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)) { @@ -1003,7 +939,13 @@ function Invoke-GraphKitAuthPrepareClean { -ExpectedNames @() -Kind 'Prepare capture root, which must be empty' $stageEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authRoot ` -ChildName 'stage' -Kind 'Prepare stage root' - if (-not $stageEntry.Exists) { return @() } + 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)) { @@ -1189,8 +1131,7 @@ function New-GraphKitAuthAbiTestFixture { [CmdletBinding()] param( [Parameter(Mandatory)][string] $RepositoryRoot, - [Parameter(Mandatory)][string] $OutputRoot, - [scriptblock] $AfterFixtureCopy + [Parameter(Mandatory)][string] $OutputRoot ) Initialize-GraphKitAuthStageCapture $sourceManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $RepositoryRoot 'source/GraphKit.psd1') @@ -1267,9 +1208,6 @@ function New-GraphKitAuthAbiTestFixture { ) $script:GraphKitAuthAbiFixtureState.CreatedPaths.Add($destinationFile) $script:GraphKitAuthAbiFixtureState.ExpectedEvidence[$destinationFile] = $copy.Destination - if ($null -ne $AfterFixtureCopy) { - & $AfterFixtureCopy $relativeFile $copy.Destination - } $manifestRecord = @($verified.Manifest.files | Where-Object { [string]$_.path -ceq "payload/$($entry.Key)" }) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b157fd7..c7cd535 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ 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 1472 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1474 -AllowedSkips 0 if ($LASTEXITCODE -ne 0) { throw 'The standalone whole-result gate failed.' } diff --git a/AGENTS.md b/AGENTS.md index 7bc8459..5f61c28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1472 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. +**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 1474 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. 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. diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index e719bed..1d73e97 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1472 +$minimumTests = 1474 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 02934a9..7651dc1 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1472 +$minimumTests = 1474 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index fcb10da..9e18da9 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -992,7 +992,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } } - It 'removes identity-bound owned state after injected creation failure' -ForEach @( + It 'leaves only recoverable authority state after injected creation failure' -ForEach @( + @{ FailureKind = 'auth root' } @{ FailureKind = 'capture root' } @{ FailureKind = 'stage root' } @{ FailureKind = 'capture payload' } @@ -1017,16 +1018,25 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $failure | Should -Match ([regex]::Escape("injected $FailureKind creation failure")) $failure | Should -Not -Match 'ambiguous cleanup|Original failure' - if ($FailureKind -in @('capture root', 'stage root')) { - Test-Path -LiteralPath (Join-Path $fixtureOutput 'GraphKit.Auth') | - Should -BeFalse - } + $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 $fixtureOutput "GraphKit.Auth/$rootName" + $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 @( @@ -1203,6 +1213,38 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } } + 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')) @@ -1714,28 +1756,28 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Test-Path -LiteralPath $bin | Should -BeFalse } - It 'removes a copied ABI projection when validation fails immediately after creation' { - Assert-GraphKitAuthStageCommands - $failure = $null - try { - $null = New-GraphKitAuthAbiTestFixture -RepositoryRoot $script:repoRoot ` - -OutputRoot (Join-Path $script:repoRoot 'output') ` - -AfterFixtureCopy { - param($relativeFile) - throw "injected post-copy validation failure for $relativeFile" - } - } - catch { $failure = $_.Exception.Message } - - $failure | Should -Match 'injected post-copy validation failure' - foreach ($binRoot in @( - 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin' - 'src/GraphKit.Auth/GraphKit.Auth/bin' - 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin' - )) { - Test-Path -LiteralPath (Join-Path $script:repoRoot $binRoot) | - 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 'deletes only a recorded projection and preserves an unrecorded partial materialization' { diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index 86efb47..6ca361a 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 = 1472) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1474) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 19e2a5b..648e8d8 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -153,7 +153,7 @@ BeforeAll { [switch] $NullRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1472 + [int] $Total = 1474 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -386,7 +386,7 @@ $requiredAssembliesLine$requiredModulesLine sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1472 + minimumTests = 1474 allowedSkips = 0 allowedNotRun = 0 } @@ -1096,7 +1096,7 @@ Describe 'Test workflow release-proof generation' { $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 1472 + $proof.testRun.summary.total | Should -Be 1474 $proof.testRun.summary.notRun | Should -Be 0 Test-Path -LiteralPath (Join-Path $script:fixture.Root 'output/testResults/candidate-release-input.json') | Should -BeFalse From 4574f6e0a315103542e1f76901f6a91f9663b521 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 05:25:29 -0400 Subject: [PATCH 65/79] fix: preserve partial create-new destinations safely --- .build/GraphKitAuth.tasks.ps1 | 317 +++++++++++++- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- .../plans/2026-08-30-r8-graphkit-auth.md | 21 +- scripts/Invoke-GraphKitAuthParity.ps1 | 4 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- scripts/private/GraphKit.AuthStageCapture.cs | 402 +++++++++++++----- tests/QA/GraphKitAuthPackage.tests.ps1 | 398 +++++++++++++++++ tests/QA/PublishChannel.tests.ps1 | 2 +- tests/QA/ReleaseProof.tests.ps1 | 6 +- 11 files changed, 1037 insertions(+), 121 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index 83fb1de..8cc5d4f 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -270,6 +270,90 @@ function Initialize-GraphKitAuthBuildAuthorityRoot { 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( @@ -981,14 +1065,43 @@ function Invoke-GraphKitAuthPrepareClean { 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. - $quarantine = Join-Path (Join-Path $RepositoryRoot 'output') ` - ('GraphKit.Auth.quarantine-' + [guid]::NewGuid().ToString('N')) - $null = [IO.Directory]::CreateDirectory($quarantine) + $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' @@ -1008,6 +1121,145 @@ function Invoke-GraphKitAuthLiteralQuarantine { 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) @@ -1376,15 +1628,22 @@ if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyConti $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() - $authOutput = Join-Path $BuildRoot 'output/GraphKit.Auth' - $publishRoot = Join-Path $authOutput "publish/$runId" - $providerPublish = Join-Path $publishRoot 'provider' - $payloadSource = Join-Path $publishRoot 'payload' - $resultRoot = Join-Path $authOutput "dotnet-test/$runId" + $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 { @@ -1467,6 +1726,7 @@ if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyConti throw } finally { + $cleanupFailures = [Collections.Generic.List[string]]::new() if ($null -eq $quarantine) { try { $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot @@ -1474,8 +1734,45 @@ if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyConti Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'." } catch { - if ($null -eq $primaryFailure) { throw } - Write-Warning 'GraphKit.Auth generated-root quarantine also failed; the earlier build failure remains authoritative.' + $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 } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7cd535..e75d741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ 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 1474 -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.' } diff --git a/AGENTS.md b/AGENTS.md index 5f61c28..ce3d283 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ 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 1474 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. +**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. 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. diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md index 6f9cf63..f82284a 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -378,7 +378,8 @@ to re-grant permissions. The path-based loader is not an adversarial atomic byte Keep mutable compiler output and authorized stage bytes separate: ```text -output/GraphKit.Auth/publish// +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/} ``` @@ -387,7 +388,9 @@ output/GraphKit.Auth/stage///{manifest.jso 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. +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 @@ -457,10 +460,16 @@ may unseal only exact physically contained envelopes whose canonical manifest, d identities, permissions, and closure pass. Missing or forged manifests, partial sealing, links, aliases, and containment ambiguity fail closed before `Clean`. -Machine-readable .NET results live only below a unique -`output/GraphKit.Auth/dotnet-test//`. After capture and before the build task returns, move -only these literal generated roots intact into a recoverable task-specific temporary quarantine, -including on failure: +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 diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index cc228da..d6f1983 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - '4f3c2283640bfe503b5a8e4af68d8aaeb7266a619590afc7d287d8348a4ff459' + '5e0501580cacba66000029b52815f3c3bbd4aa4b324cd16a69c61ff827bc2dbe' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -H4sIAAAAAAAAE+09/XPbNrK/569ANJlamiqq7eTSnF01T3HsxHOJ7bGS5t5rOxmahCy8UKSOhPxxtv/3N4sv4pOkZCVt70WTiSUSWCwWi8VisbtYlCQ7R+9IXORlPqGDjyR7sj0YRxP8JsqSFJe7DxasyPi6pHhm/hrs5WmKY0ryrBy8xhkuSGyVODy2HpwuMkpmeHCYUVzk8zEuLkhsNzMY43hREHo9GMUxLsu9PKNFnoYK7RXXc5qfF9F8eh0qc1KQLCbzyAbyHl/R3QcPsmiGy3kUY/Tp0+vT0cmbfxy+/zT68P7Np/H70ev9T3ujk/cfTvc/HY3e7Y9PRnv7nz7tPngwX5ylJEYljlKcoDiNyhK9BjT+QehoQacnEZ3uX5AEZzF+cPMAIYRkFVoAEqc4jSi5wFAQ3aBzTHcRyQjdRXdoKAoN9mdzer3rqX0yvS5JHKWr1T5iLR8mOKOEXi9ffzyNtv/2bIl6aZ6do7c4O3ewdUuR7PNevshoTUGSUfQhI1fv8gTXFJO0wsWMlCXJMzkgS2B+lucpOixfkQLHNC9sYnmKnuLzRRoVByTFbQrPo6LEJzl0qaH08WWGi48FodFZ2qLbrPiYJMsP796iKHBGJX8sB4Ohyufu6SLF5UmRUxxTbMNw6ryJysNsigtCcaLVb0WV4yy95nWaiu9fRTFdpY4kvKqrWKIdEOCHUxwlUNUue9csT/by+bVfnoSkDhrni8LH6xm+7PZ2W4F4hUtKsgjE/GFGKInSdYELwmlBCxgKvCQx/tie0IiS2NOTMY3O8V40p4tCdaQgFxHFKM6zkqIFyAWxvgL3oCHavHq+yT+7DRUYnViNp001xtOoYOzJiovSW/XFK/Ci/HZ9+Vc4xWaFp8EKewWOKD7Cl2iIwmgcz3G2f0VgKM7RED0JFoTJN6K0IGcLio/yYhalGh7PN2srHqTRObRkyGpee7ueqrL2yyj+vJiP8SzKKIlLXpnXba7MyPx+WuSL82m74Td668EaKA/VTdHPWDTI8IdZOccxBdBdsUwUeU6hTF+uG4Wm0vQYcPgMf5aVu1UNvWgf4St4rQTqDppEaYn7aBqVU1AAcUZ3EC0WuLcy0u8wjZKIRl2Fl90L54WOonrJdJRZdEVmixlXaHhP+eSFD5mgrlEA/YQ2K3JUBeFDp0V+CQIDjYrzxQxn9HhBjyenUXaO969iPAcJ0wUVNZ+YUHtCUsGHrzbwCZICyy9DNRoGIvcaGvZMwwdIIBscCCL87KNaHUEOjysCPOqIxWzjRkfvbgPhqxjjpESElugsX2QJThDhHQTZnLLGBh0vsQpMF0WmSMOL3K3CYYo+X2FuwDwI0P8+mMNLif088rO1UPoOy6NFmh4XH6eE4jFsnbqsRnsmr0a2M0KJ0q0BCiIlKvC/FqTAyaDTR4L3eQO+URQ4TxYp3w0NEfwZvMb0QDzilau6YhcYTTBIBr7bRVP+Z8gWFVgJj/KDPE3zy66E3K8wVdLIYiVJ3YMin3G45izjjfQVsn1Dj15usIPMWmnTTM+xlN1uo4iwR14OGgy6GrgBrEiXhxN4qOa6S5DjOS4iWJ6lUaD8SLIkvyy7FbPA54VqfWAr5999Z5SEz8MwI5pgxiTp+QBIqv9rEaWlW6dfYePuhfpozGrv5bN5VJAyzwbHRUKyKDW7tFMBUXvVISzAW1LfWGr0rO3Gn34Y9T2zZwRMmqtdbV1J76bSxx2qRmhL2YhPPfupsu02hv8BDGwOZpCx9+oYO8iwXNdXDSg6OqraPIKeeJW1eErS5CiaOVxvVPWvDRKoxvY6VDREp7jM0wssTXaiTl9rtaq6n5WLAo+yGJc0L8rast6FiFd4E1yOJMDwYsTNewdRTEsB7SWe5AVAe42p9rart9UXZS097qEOwWCE21sD+sC0Zi2n4HXYqD/OwThS6QMxcAaocUIhKFGeYTQXtk85qKq8peZV3cgowkWRF2bPaoRaCHnVeWlcRqX8YpgP5EcW47NvQnCBcugoGiLRlpyXwJNiqnZ7gw8lLpw5/+JFiHjvpxjFvLKEi4i07k6jEmU5Gh++MujD2FyayMeY28S6DLuaUpUUFSIY2ieVPJYay7zAJS4usBC/URZjd5tgAB8lmojuQhdhTvCh0V44VGEYV/JAfqrKp+R8SssBTHhxmOCW1tCELXc5gJIRyXAh3qBbt8zx2f/imIrHLsyTIp9H54x/efmjPMNuMeOY4/31HA9GMM11XRc+Z9cU//o7SnAZF2ROc2AhRbvXmEpOe6UKvCRZVFwf5MXM5srXe1LUkCzDSVUFBIR4x7CIu1V7ffWKIXnCqlqAaXFt/DYnD3wkmso8UaKo+irmkFPLhQMfsbEcondRUU6jdDAm/8bHk5/cNn7u9lzC6+gYJLCpAqxZHE94j/mgh+AJZlDCe9MpdWdSTElZaxH82GXrBWwGJxqFem1pw8SdRprXmL6NSsrO+Pbhnc0TOjKi8hA93wQhr35uPX/ith/GAT5+gRUsDp9HnRHNZyTmM9teEhLN8BrnaUrgXGcHbdyo9fVuA0VpgaPkGmGwDZaO2HP3kPdA+VFnL1+kCcpyiiKGeJSmYunC/j6YyHbZoKAbRua7nhdbE1Pz1wQ0t7Rp4jlMfVBgbDNBBbj6htMSB1ZDWFcLXC5SUKxmnxNSRJZVibWs6RiDV2DYKvJF+RqLR93e4H1+mNEn275JpQjlvhKWzK29zd4u+uEHtPnjpjndgJUFeg+HugnOT6GVpowxXbZ+bDs9l+axrzEl3OmwBJr3nwa4KLI8PA103mw2dxJxxjN0LXRLK+QFzuc4w8mJ3EOsRSUfTSjTAy2N3GyslU4+jmZYLEsa6BbWOEeH9A6T0LLjKUzdBCULtjXiY6w09HoLqxiNOqsHV2+AqkseMdzUGALtfZvfxlqzeWussA5Toq0W67xyFti42YZEm0HOqi1Z5Y1weyvgWVs17YUy1pOMDt5FV79E6QIvx0mPOo6RnsAOhLLNG0ghZukpOGJoQlIc4B7BFDG3fHLtkD+Mpzj+jJNuFzaaBuq933fNs5p8MikxO/mqXlxOgRxd8eons/O92tWOnZCeRlmSi13JAAZYjYfAdTAqx/Mo6xp48uZ6vb7AyRJwfLkC+C0Wq4r0+1lyPBnTAkez2jHA7GhEzN6SWaweC0aN+QF0jciFj6DW90NGBd946Ywb+cVbS77VRBoD1HNZ9OGQtyF/awWUB1FVRj66NytbYrCZkI4wFCzS0lKm+30g+AEzWZ2OOyaykh2Tnea5tiE1X3lPNUURTZPwgtDfe+Ew47Uw1tgGbDQUZ4fm/DSPSofsoSV5bvxdCQh5jQSePrtGPq1TAYg2WUJ0qFlIWiNlV1yi7fpFibcaNitWNG29OPEqIdui3qDstG/K61DcBct5u6qFUTtCdgfAXqP40pTljyeMOHWLlPe438F8zYfg3h74jsKFRPKfg1sMw0xmbCHRmUw84RyjuT15ZBB8rNnUD0kDDRGLUTUIilsdfAZmpd3mHUHiOoANG89rHVyM+ScmS0AoBlwnLPYPCcvvvkMPw2e4bmeWVPcfdcC3St85btwEOnK3gRLCN3Zn+Jxk6JLQqb5ViLge5J8e0rLMDnRc2jl+JZfiNM3ZQQll8GwxmTDFQumCW0+2Nn/cXkXt88zQeuXvXwtcwqHjEDGF7h2cJb0jWZcjJUD0vTP/cUDpq1MqTQkqGhGa5Wa/wufraJQNwkdXL9splICeGAtLLqLHjCjtUV5eTIKbUDs56SJujBRzz9M5u2a8ouBQNSvXRqMH6aKcvs9fkfKz27SuCrjLdcDusNJqranpGnCmrPvmwMOhjkPDjFttcKV6zqc6oegygk20xpRJaBEMrhy8IfWzedUI0dKn+rVYJtosaUtg9+XXtIemd4JJvwEPHjHacN4FHRTQ7a2H1Kp+xWg+6KsxnHDPTtqvljnmuuQsovGUiRmOZojzxMYQmg66/PtxFpNBzitZ2jSWe1zfDfqIh8FKZmkHo7uWO1nDax+xX/V72fvvRg370XK71OW9p0Qzf9AGc/V94je1/5va/+dV+9vpWnKKo81Q1SaNaZnF9g9fZIVg4eVw8iYqQars5dkFLujgff4GX/EVtDt+M9r+2zNwhJy+gugHKaTg0PVtfgn+LRdRQSLwOjIXcQ07bVmV9u23eXauDK9Gz63FXwcjV3cd75q1ftmFGriD4uxe6/RiPk8JTtjSEeLtwHJtRqX5UV/vUtxqCb7ISWJOR8E90VmZpwsqeI6tiWpeit9yVgadKv3u9jrk3r197nTcZeG5KUYq+WFtq/hQeZUucF1lgUHgvjpjPqwVQAPIC9SV4J0t6AsDDvPbY1F0t+5zHi7nebF/heMFdWHvtIctQNj+u+vDuy1yGv3hHbgM6uXEwAG5e008+y6/qPyhKi3ROQJQZ8OWgDWibELAdKO3LZ9LMlukEcVvSba4OsUQiPIhiy4ikvKVqibqpq4D4cOMOg3Xc85Sj1/t0Ylv1nr3/C4ivqo25Z36ErblhK2oA24YAoEG/bkWglbSkjthf3sdNW65CBZ1EFnFx0I4TlT+FbP8AlcOziHnZhJcjjw7bkkd+Au7AUnaPnLe6CRzfaxqxDV6EVy5D8+zvMB7UYnRzrqW9xrazRYlGFFnEclQDv8wO68pGcrMhaT+3KO70kmZ6+3TawWXc07zOZzfo6ipDYdFww05RetaM4fItSry762sitYS7cLi2NRC1PtWWdgsTykbutPhQBMBGlorhKcxzUDKINnREuYrywPHmXsCkoaqH55ZoBGqnwo26GAp/wGsyyJrkoLVEWxIEPIp/UWjPqAaLOYwz/avPlYCoCIS7DybyGEroybmK7mgLuWt3XaAnGLwedSRe50BbHbUAIZ8T7UXjvfpLuqg772tdIR6EmWmufUSF5ht1YR9v+GAJmjK9bmokqykUZo6OIO9I1/AyjJPoxiD8a/WV1tnPS+fvYvi43ENl2WwE41PpUN1wTS6q0/ZPMBwKouGhykqQH951+evwXf34r0v5Ttdx5jAKcdjjUfQ6T4kKPu0/8+9t8FuPupYXta7EBk2idL0LIo/s7OyiFI8m9PGKVazs67CAqrXemSQK1zb7GT8tU1K72e0uGZL01FOD+BoV1t2DjM4qMSJIFpEt9FCNkJSCKNq6rExMSO63X28tbkp1Y8+4r/8c3TLq7rHzPDUrUGbnVazb8upyw6ROoy2Wt8Fvxwdn+6fvB3t7YMDlKJHims4g80QOjVNolCC2dIWNGLc424mqq7U0OJVmv55iIDOcBwtSoxSchajWE3SM4zSPEpw4pcona9MumCgy80atQtbwv//0SYMMjx5bihZ29s9iD3aPzoe//cY5QXaPzz6ZfR2x+Cigie+/EHblc4LMiOw5Risk6jLsndeoEUGNu+8AOZ0FocgYe/J4Csobw/WsWg20sfX4ZUXzTvDwCnSdLVMy3PfNFXMQGidI1lvtaQyf+lIGquX60jKU5uByUjHY1mxWw5yHUZeCjijrnD9EuyAhtz8Y3HFGuKSVPhRZWx4OLQRs80W3Yd2AXCO9UU4Oe5B3nCndYUzgZiTmao0Q8VN10b3BeooQ0cH7aCOHvvU6d2FtpFiSGFkvBlXJVV99HFmt9tfARdOYQUTytEz46G8uH3ZWJ/m8e+uJRqot7Z4IOlwWIqQICm6YHkQ+ejCroeBE2VdZgTwNLJIw9ak+jk45YaL7sZvv2300cYPG5aB38ghLWmjPzSLW0mjZQX5wCwsMkQPGZOZr1Skjz5gVhE1Zs6QmQWr/EOinHxg9dTNAK3667wyq+qiSnW5emYX1qNXquLaU7eCkZ6zqlE9NquY+bJkDeOppwLkcDbKQh4oo5gn6bOs4EkhZVT1JuaSlX0vzeqhLF0SQuC9p5da2i6js9Vzs5I3GbSs6Xvpqd6Y/suA11TabMDIGS3h6A8rYVKnh4h1RBP0fiWDR05wF3h78efq2WEWF8wSGqXM6UgsIdbjAT/shq9d+G+UnucFodMZHNIOuEvSl43s0Ptg9mPpYI509fiN6Z8icqMzYnqGiNCoW6e4nzyMaeMmDQoNRvM5zhLmV8a72EcyzGHZ6AaxAvpc2FhTLF1IOR1lySkuMe3WOLDVzgEnWHSFPAP1/gQSVIMvgdHASolT4fiebbclKOaPVaKoqNTSGpcBpiIcltIRACcmSoC9oU2IjFglUyeW1pskwbnPmE+jXmRlNAkFWXI6/vo7KvE5kAGkqoHdeJ4S2gUdp6oO6l8ENkXphMLrIpIpMHUnfjUuI7w2H2EJtESdQQcsKp3BoOM/z+VFASLLQE7+jZOu/MrzhOXFbAD/7TUeJt6X0IApbGKODvYeZwqddier2qTxeQOpGeB48sT57IxkTOY6tQSTsQIMhHBWkY/UkOmZ0WznEmhBfh0ukbW02ZPFqBD2anG6PC/whFwBu4Ifyn6WlB+J7K2WTXAeFRHNi71pVNi4QUWrdUb571ENEGurJQk/GNOooBwFjhn4TstOrHtW4zKO5pgHJHvccJy8CBzHOinO/Oqc2PrVc8UswcJ+fxsoGHax4Wy8ilcN1Ay4p1RN9lkxnxOKqm77degv7u/K8YgvQhs3ALYyjER1/hvXy8iYJSW+FDMiFSafdMaCwHQ9RLIEw5zc3BVff1LNVPG6W+Ll99+HBqxqx5BU4nFfwfyVwfnd6vaSBk8FNZxkymaiiZ+BVK4aDtHHPxMf70zWyDdy9kIcp0klMxWDYqR27BOWHBadXbc7y65qOcT7dMBVfNkDo803TPDmo3vS4dp65Ue7GMZ9WV3pcqvf13KrX8biy4RKT2gx+B9c5F53UHXfij/lqveulFvUrRy9XgRvRdlBm71ahDycyik4OCxhG5CSxpBx5RS2rLeLaOgVKed56SRObOd0BJnd0MaNHE/Nx4jLSBaVAf5GQDo0Z7Rryg7p88oQyAadMzLB9F4VSXgpoRcqweLmJgwN/GBX1miqhZ58GUa0BcAtBlMC3HIBxmle4uMMIhqawXFoCtxzF9wElE0gvHZipPp/a7RmsanZM2BOy4A9Sdrd7HI/dkhJ9lmdzNawrOONZvEEYGKKn26Xz63eJOlDYF/JHxvLil+YaiGjzVlhWsUyhLLELCd24WCgFpI7TKwr35J5/0cn8/Yn3v6Wn/tbfu5W+blbKG2ETqUQ8Shw8AmKP/2jX/R3a9ziF0hfLlU8/2szd7i/jBLY/te+i/NuvVfThRKih7S3dhpc2I94ZU2unTb31XKhe1I7M3O91xfuz5LKXMONIbt8DnOGnhVoGyafpsNUuofayDImrc5d+IQRpy5wJ8AOerr592d9RMpReZ3F/rXLjyLzSm2JZDumYmPyp8jwrr5eRAXKGW+IKxg0Ih/z5wHtSZy9qzDggDRRJ6HekXrgiDRRkn1nBa2zXoUrlBK/BrowqnppGUJX2LMLyjDXAt4/0evlIqgbVHONrZ1AZIFBrUqu230se0/gsLfhrr8V46QeghmVpPgwm8BqAYi/vLbci2CLIx9aZRHJJvnKhx+V+BIuN2jjBvqn3bLQfgdlDhV82OWq9HoOgw94sqP46pYRs6ppP4JTZlbzO7gna7PHHLU9FeTmvyoevMzVB4MdkF/ycVJ+Nt0uPO4pjEEkviHnU/TTT+jJdg/dIuPVW1s8CUZRFwoN0aPODavyS54uZniMCxKlR4vZGS52rp7f7fCXfGQTfAVtwXPr8dv8Ep52vI0pux+zXgrO032TBDNZ4yULKqcekXFf/RbmUH85O62EN5kEYzt9ekm69BXSfXMI+py+nEDHE3BlKtlJeZW/wuHwh4nh7CjYAg7XxRetS5bzj/FqnCSeHZJTmd0Kpz/13Rqnv2/26rHbCXnxuMiE3G1siM0OOt4ay7ng2CB07xu/M59wZwG5rDuzbP/tmebJAqLyMLvIP2O2WoxpRKUm3HBFCgMM90LAIX1duEezkJwwHCsRuYKRSX1dMNGT4AsiL9KtnpIsT5yHYNfSRCaTrZAXxPL24e43u/cLreRooSF6SahwMcHF4H3+gROVU1RPHQUfkRPGU2XrmahiR16yPjXU0T2fWIeAOP46z56KOs/tdqRotyrpdf6uN2Re5xMgIouOYEY0o7FTHr2iLdKDkyKHmTIqYnCKiFnm1OEQ6b8Ho2L27GloQH74AZ1DTNVGic75Lhc9e/r4jFARosEYc/TyEHUXJcvGhEYA/NnTHmI+raUNDQbrh4zZSslshhMSUQw5yphnMjhqC/CCE8BthhN+QnCalIPW7KLou7mGcQyzmOLKrWfteUxV2t5cgWGePv9jGeafYXb5agNSQ1xVxxmRFqO4/XS9A9Io8k/SiAL5j3I6lvFdtcFUZjxeSaNzdZ1FlSgtwROSYRQh2J1c8L0HSqNrfmYBh7mesT8eyz0pj8RiWdna8cjdRuBMg60WYHmAuwjh8jEYAlCtD4wDIKZSE8ODu6oEN9Y+9RUX3tpO4ee+wqDNOSVHzjHUonJUZ96mXYXx5sHBgevCAOU1V/tHnRs+B3aumHqdJ/BNU50ttVm49L/m2bcMvZnruDp8MIzUhE/Z+q65JdLhGG90VPoe3jefqYty2FaFTUPrLFhS0HyqYW6/EMNoP4YBM5911eDAeDwXmzPLO145pgqvVIUOet4bnETJWzyh3ad9tLFph1mYN7ybv0T2y8Y/bVxbfYOt7fHVhl9mX6yGv97VgZtwIfwdPGcTtvdkftoFjtJ5xVJ+U6+ITTarDo3CK+uvAq6eM4u5od1DnzUHTXW6TSIDWVazR5/QQjLMh/cHz21C9Npf8at3O8ExMKu/14H+2CZEE/UJWApt5Pxqvv+CPJZG338zXmhHvfI2SbT2FfdJBT4jWfIl2EzfNTV4O9RvXdBOa91I25MtoR2pCcGIv8zS5MlP54ogC+4as8KeGIPlhuupUVX2LQiFkJOh3qfCFA9+6Qyrv83CzHFGu1lZmwu1dtn6LCottsVuRa03LfX28pK0PxNxt4SQjZMZHoy7peFB4E5le5OgAHxizPbsaVtAn6DmyiryqqoyT9QzUTvaoJ4c0n+bjqjdZBUPg1u19ob1FahTT5k4ynTDPEwGNMM0SiIaBfcE1t6hKXNE4/XjgtMD/FcHu0WqHlDpi0z9buOKyHzvKeH5h37BBdgYIZTsS89EMY22+ktMWksj/mNnFbMeiVllEHGlOebvGXxqxzTgL7L0sfbkivGjjxlYPqk6LmU4OE9acKvRWk0Hm7K4rTjgteOt8kBiQqfCgKcNeF6gT5840RCGLnJf2bpxZDF05+cFPo8ortCzhrcfokoj0e++eHavtVp0FIEtymrURDR3TlGXS03VtFv1nep5z6ptNQj8Fc4WJE1UuDDXFl/yZ90n2z8+03VXZi1SFjd2QJ3x9iAgWZ5Ef9TidRmcPupCzZ74OdiL5lHMlFNdnYYFV8Ie8psfxc+fh8iuev+9LgwYvyFcHnby7cjy59uONquiEn0DI3uiDCByTtSOci1IUebCd9FrnGclrSwWlAUvf8jiExlO91+d33578duHo73frK0GA6fHuTm1V9hcCApBox30PUd5MF6ccQTdJnypUyxYDp68R526y69eOC0/hZ0fe1g3GoFj8fBBuGE3qjcPVb65Pjfq0I0Kujtwug/k48errzHlp7DCu5f5DisTHyzlzKTQ6yOj2Jg7L8tzaXQbeM3OfO1bEpbCRfqrrAENLYxVEIxvfycEF1psmRgiuTUGtMTpfLfH3IsetLIgsbTLEqoUHWrPO2Up2tD48JUhIzyIMTdAMGO773rK//u1uLODObHkE19ZrRVYoPJCxECDV8BenqacVqgQqUB00Jr7QJd7qIv/2zRm+5Kzq0Pwu6gEM77jaM5ctyDDgfB6NV5W+Q+QGe7tB8NYK8FJ5THkA8qK6kXqIfM4rvHiTMuPPcoSKOcFz8s3Qd1jZpJKKnhBvY8+cyeKckrmNQSW1h7xc2ga3+3IMKMJsCWOQGFi15sYc1Ur0Wvqzvg6i6dFnpF/a+4FTKzlVjYdZm23ykyD+Wl8pXMnnQx7Ik1fMuiShaoAmAFPdPTzEG1ZkLA/O00VZFFgb+Yd6zi2VfPDQPMtstvonkrmwUczoqsgp9I6+EJKWD3I8MDq12Q+847o7ZDVY87o4rWVyMQaje9kBSFHT/EEF+yuSbdLrjECahoLhDxBdB4ORmBN9+SIWRYES53GajnT9LtKEtoGc5eCihja3AEhXGdC8fLzvSj40B6ttZA5CMQh2XBoC7dQVTsmiZ+BeeKUghDscCWA4A1hMoeg3Sz+q49CXYDYUmPSdQZF5L2BREPsDbp1B46nLxSPe+scwjtTJk/MhGCV6LVTJrLLt0BVUooEV1hth2K+gDJnRklHzzuvT0Bg6+BfAl5jKtWyKkIBnFMh2033Hkq0aZAwRJLn1eAX2CpZrgectX2vmlawfptVxYNGyF/VJyE9JZbzYNV5pnbP7twUKB3+a3aDDRcFWnsX3Ye8blvTagMiYbE90Jr3PS03tegFa05LwcPLdGFXrqI61EPtNFUyliR3l/UqUGKtcbqO+CyEFFWDGNyqu7LV2kmvRZmvUHUENKkeWIqnQh69QPcX4WjHvzh7hqdlpDMPbBa0hoAABd3KVOpbDvqhNdLOSGAZadwhNw0c49bGlq7D4mqvr6FgeCYu3bBtWeH6fU1z7KjT02U3wJ81aK6E1ZWhglW1ZU/Eue4El8omSzeOUpygOI3KMrBMWnKSedxkURpaVGUGO4jm8GxcldMAe+rkZzXTIhkxHQxW5Fvaave+JnzfvlfUb17R2u81BUhjPQsMOmgWsFVOEi0jmy97rkFGT1GeBleSdbc5Y65L5N1WuXI9j3dbZskNjJGnN65Zonqy2yYzrm84PRVbWA1ajfhuXTZcnQ98CrOaU4L9GU/coHNMd32lGG+Z7BEuLEAqFmks6eGVBlS8jNJQJ8QjbXqtjXNDcS9vtKnTyBYNQAwGMMrWCWDNdzkkdY0LJs1ox8ofS/dn1mOT5A9/hnLd+VsIMN1n2fI5ZxnCq2ehHOA1K8DczWf+bXEwFwfNeVGO8m5dYnx95M2CKsTWjlKzk9gr5tgNpa+XPLJbk31eY5zd2rzzBj/t1mecN/ls5dXSm2LfZcZvS+x/8hKrZlbjemhMsXBpJlnFLGsqpaZbuCBwvJpzDYuNPvUai+rTr0VhbQKuVyHxzMJvSsxfRYlxy2pX4+j5PXJwiuuh4c+eXZ402Fee9Kx0dU1LjXuJfX5n3ilv6D8caPiymC99ebxhx/x1TItFTN+yQMQu//MPkiWDMdyzkEEewN7vlsUTKrCxeE9mMEnmi7OUxNxVDDJgGA8gg8XuvVuTtozKomDpo6JJhoKlUIhXIhbLNaU7JXnYAdsLcPe29VAskLbF3xFGu1COFFFGDQFLrUPyDH6ES4FPG59nzeV4QiBfMYaYm7zEX05PmVJfwsicohcwUn+EYahsKQ1FVDtyNF+l6eEMXEK7nc+4yHD6ZHuQpGmnjyAX/JjdliK+QRojCB/rg6UfqMScA+WNb7/7zgXAu63I7KAvPWWxTJ5MUgwelX2Ob4JLuINCGm/YsxJSOplhnIKnpYGv4hVRhaUfI3nG82oR7qfLA4DBQjrKNIcaGYWI4D5GuFGY3Qq3u2Yy9bWLhtEQdTRSdFYkoZFAsA05FfkssrKkf66kWY68JvA/isy1pGTrIyegWoAV5biBtzUlWmC+Eno1qbAsToChbs6LJb5/XUIzFgj7THs7Yrhj89EwWQoeyeRIFY/9ARz0Lr9g6O9fKeZhtwqT7PxATT/xIsOX1bN6rMGlfgWuAZgsV7XBx1+qJR4FZcci/nq8oL/rEYk1rZpiUMZkrIoOj4KBrxcy+GV9yLHOPjo8On61/+zpfQimog7vg9sqrc8+J6SQA1YZEA1WUVmU1ti2iv8X0f5Gg3ZyAH5nyjq7zW9rvvqUzdW6WOQz1W+af8G5qG6KZkTP00Qz3MoTt5R1mrNqhi/dEhm+5CVaoFmLEfPPYKH6guosTod5Ddw9+D/98Zh6zs8AAA== +H4sIAAAAAAAAE+09a3PbtrLf8ysQTSaRpooqO26aY1fJVR078ZzE9lhOc+5tMx6YhCzeUKQOSflR2//9zuJFPEnq4bQ9t/yQWCSwWCwWwO5idzHPo+QCfYyCLM3TcdH7HCUvNnsjPCbvcRLGJN95NKdFRjd5Qab6r95uGsckKKI0yXvvSEKyKDBKHBwZL07mSRFNSe8gKUiWzkYku4wCs5neiATzLCpuesMgIHm+myZFlsa+QrvZzaxILzI8m9z4yhxnURJEM2wCOSXXxc6jRwmeknyGA4LOzt6dDI/f//Pg9Gz46fT92eh0+G7vbHd4fPrpZO/scPhxb3Q83N07O9t59Gg2P4+jAOUExyREQYzzHL0DNP4ZFcN5MTnGxWTvMgpJEpBHt48QQkhUKTJA4oTEuIguCRREt+iCFDsoSqJiB92jAS/U25vOipsdR+3jyU0eBThervYhbfkgJEkRFTeL1x9N8OYPLxeoF6fJBfpAkgsLW7tUlHzdTedJUVEwSgr0KYmuP6YhqSgmaEWyaZTnUZqIAVkA8/M0jdFB/jbKSFCkmUksR9ETcjGPcbYfxaRJ4RnOcnKcQpdqSh9dJST7nEUFPo8bdJsWH0Xh4sO7O88ykhSCPxaDQVFlc/dkHpP8OEsLEhTEhGHVeY/zg2RCsqggoVK/EVWOkviG1akrvneNg2KZOoLwsq5kiWZAgB9OCA6hqln2vn492U1nN+71xLfqoFE6z1y8npCrdmenEYi3JC+iBMMyf5BERYTjdYHzwmlACxgKsiAx/tieFLiIAkdPRgW+ILt4Vswz2ZEsusQFQUGa5AWaw7rA91fgHjRA/etXffbs1FSgdKI1tupqvCUxKQjnZKjR7/c3KmuMJjijDC2Kw7NRXbxEiJffrC7PsFIrbHkr7GYEF+SQXKEB8qNxNCPJ3nUEg3eBBuiFtyBM12FRZNH5vCCHaTbFsYLHq35lxf0YX0BL2urOam9Wj4Oo/TMOvs5nIzLFSREFfEhY3frKlMynkyydX0yaMYzWWwfWQHl3dVH7bZTP0jxi82uc7lJWH6CtnUf6BkMngndaHST5jAQFAGzzzShL0wLKdMXulCmCU4cCh2fwWlRulzXUol1EruGzXLa30RjHOemiCc4nIGaSpNhGRTYnnaWR/kgKHOICtyVeZi+sDyqK8iOVhKb4OprOp0xsYj1lSwQ80Ri1tQLoJ9QvyVEWhKeYZOkVLEtomF3MpyQpjubF0fgEJxdk7zogMxi2NgjC6ViH2uHrITxsT4PHSwoi/hjI0dAQWWlo6DsFHyCBaLDHifDaRbUqghwclQR40uJb5rNbFb37Z4hcB4SEOYqKHJ2n8yQkIYpYB2EHiGljvZaTWBkp5lkiScOK3C/DYZI+32BuwDzw0H8VzOGjwH6G3WzNRcuD/HAex0fZ50lUkBEoaG1aozmTlyPbGqJQSvAABUU5ysi/51FGwl6rizjvswZco8hxHs9jpnMNEPzXe0eKff6KVS7rcl0TjwmsDEynRhP234BuRLB7Hqb7aRynV20BuVtiKlcjg5UEdfezdMrg6rOMNdKVyHY1aX2xwfYyaymzU2nKEKnbtUuEOfJi0GDQ5cD1YBe7OhjDSznXbYIczUiGYUsXpof8c5SE6VXeLpkFnjey9Z6pAjx9qpWE57GfEXUwoyjsuAAIqv97juPcrtMtsbE1ri4a0dq76XSGsyhPk95RFkYJjvUubZdApEY8gE17Q8goC42eodT86YdR1cwdI6DTXOrOVSWdqquLO2QNn+Jai081+8myzdTP/wAG1gfTy9i7VYztZVimH8gGJB0tUW2GoSdOYS2YRHF4iKcW12tV3XuDAKqwvQoVDdAJydP4kgjDIK/TVVotq+4l+TwjwyQgeZFmeWVZ50bEKrz3bkcCoH8zYkbEfRwUOYf2MxmnGUB7Rwrla1ttq8vLGnLcYxWCxgh3dxr0nm4zW0zAa9FRf56CCaaUBwLgDBDjuECQozQhaMYtrGJQZXlDzCu7kRSIZFma6T2rWNR8yMvOCxM2ysUfmpFCPKIYm33jiGQohY6iAeJtiXkJPMmnarvT+5STzJrzb974iHc6IShglQVcFAkb8gTnKEnR6OCtRh/K5sIQPyLM8tam2FWUKldRvgRD+1G5HguJZZaRnGSXhC+/OAmIrSZowIehskS3oYswJ9jQKB8sqlCMy/VAPGXlk+hiUuQ9mPD8yMIuraAJanreg5I4SkjGv6A7u8zR+f+SoOCvbZjHWTrDF5R/WfnDNCF2Me0w5fRmRnpDmOaqrAvP+U1Bfv2CQpIHWTQrUmAhSbt3pBCc9lYW+DlKcHazn2ZTkyvf7YqlJkoSEpZVYIHg3ygWQbtsrys/USSPaVUDcJHdaL/1yQOPQFOaNHKEyz/5HLJq2XDg4YrlAH3EWT7BcW8U/U6Oxj/Zbbxud2zCq+hoJDCpAqyZHY1Zj9mg++BxZpCLd98qda9TTK6yxib4uU33C1AGxwqFOk1pQ5c7hTTvSPEB5wU9SdyDbyZPqMjwygP0qg+LvPy58eqF3b4fB3jcC5a3ODxPWsMinUYBm9nmlhAq5t0gjeMITo+20bNbub/eP0M4zggObxABe2JuLXu2DrkCyk9au+k8DlGSFghTxHEc862LuPugI9umg4JuKZnvO05sdUz1X2OQ3OK6iWcx9X5GiMkEJeDyLxLnxLMbwr6akXweg2A1/RpGGTasSrRlRcbovQXDVpbO83eEv2p3eqfpQVK82HRNKkko+xO3fm7s9js76PvvUf/Hvj7dgJU5eo8HqgnOTaGlpow2XTZ+bDo9F+axbzEl7OmwAJqrTwOSZUnqnwYqb9abOyN+kjSwLXQLC+QZSWckIeGx0CHWIpIPxwWVAw2JXG+skUw+wlPCtyUFdANrnCVDOoeJS9nBBKZuiMI5VY3YGEsJvdrCykejyurBxBug6oJHDLcVhkBTb3PbWCuUt9oK6zAlmmKxyivnHsXNNCSaDHJeqmSlz8PdHYdnqGrKB2msj5Ki9xFf/4LjOVmMk560LCN9BBpIQZU3WIWopSdjiKFxFBMP93CmCJjlk0mH7GUwIcFXErbboGhqqHe+7OhnNel4nBN6WlZ+uJoAOdr800965zuVux09VT3BSZhyraQHAyzHg+PaG+ajGU7aGp6suU6ny3EyFji2XQH8BptVSfq9JDwaj4qM4GnlGBB6NMJnb04tVs85owbsmLtiyYWHU+u7AaWCa7xUxsXu5a0h3ypLGgXUsVn08YC1IX4rBaSfUllGvFqZlY1lsJ6Q1mLIWaShpUz1LkHwA2ayPFG3TGQ5PSY7SVNFIdU/OU81eRFFknCCUL874VDjNTfWmAZsNOBnh/r81I9KB/SlY+UZvK7qPcXR0XXlvRNfeLydNj96IXg6rBfS+mlgGE3nMS7IcZoXrHP7OIrnmWIy+X/DJVXcYUDwks0jGLCueUQDhSQOGtimYaWTHogmmXx0qRA/GiNlVlyg7WpRhrXqN0aXNG0s0rAqPou02qDotGujUKHYYo71dVm7tOJ4YA+AKdkwgSZJn48pcapEG6eTiIX5ml0nnD1wOVDwfcztPWEwDDW0UvFDZTL+hnGM4pLnWbuN2dT1rQ4KIgajKhAkt1r49PRKJTDVcGlIfeD2UL3QuCvCo8mJ1PdLnZmCyaVU+wXdov718Ad030V9h4KugduP5/nkNH0b5V9toI7KHrXvIAEpi4RolubFc66uB+nsBo1ZH7kkh0EPpCr1FfSjRlb06uWh7ew5qPWaMDhE0M1iGt/G43FicsrfbtHl6VP02O9RYXeq01x+1+cqeEmq9pxnt55O3T9DYcTMLefkIkrQVVRMVAUeMzapHidx7kOPWxuR2HIEA3YAGdgyecDD2fp8Ph5TbUCy+saLjf6Pm4quVqmvuXQ2x0JZR3Smwf17TnJg+AGiWtlHOBD+GCVthiQH1XUuxM/dmlutdqhvarwhriL2uyVOPtWwRj1097aRmlizN6g6o09LtLlKoMvHyti+0HNKqNVMpc12NfAFbLat+TvSbA13jCmuHE6fNm0jsdCqr9W0pS6P0bGB0OUWvBQ9XWmAauuu+fN4oOLRcNYuxwBCT2fLRlSgKwzWNIWRQ8fa2GwTYw3Kn802MB+NXZJ9wx2ryS67IJbfZpt9rLsv6fTssRg2rR3rm9eDCd3deUgvYZTM6GphNabkESNh8008JUyFmOIimNDliqFbx53cmgQoeKORqvvAJ5CYk6KWfdLmiNDRaMdfVlbUazgxVI7GyxUwoGRpSxpDkMMU/BpMCVzvH2NeqW3w0k34vaqI3wQET8r8ieDsrQXDECUXLbuUjv+22R/DoQLYzCSLsBnyaA9uDrpMo7C223VK0+K2HF5O9r38Uj1ktw11r0ZOWnZVeD7i7Cvz0GQ1WBf304wGL8F8rV7tPLYWfQJWzlFt6x6Rgq0trmZNdW8pVa9i1gQxwcl8VjNrGp73PmndyvG+r1zdQIckIcJJiAiMBIqjy9JaznBCOM5TXnIHtdB3jvaSlMUGhHzk6G6Oi4JMZwUJe2iXg+I66za61Tvc+0jyHF+Qew/8oyy6gO1DAaAzrQTgmNE0sOHiIiMXuFCCdnQAXXMIPK6KsDsu4Zj4QCPXo8HcwoVwlmbMS5uOpRhGF0VbMFrP5WgBKSIADsKO3K56sF+B1pgEuOBcMgZWJyGiKixshoxtnG0wY8Vz6HRKZVAKQWjFtHGCcMLCSnrCEYAz4yyOgqiIb1BGgvSSZKiYuLvypPU7ydLnoLWW3hU9tBLHeNf82gW+fk1b+1rvCgR6DB7UUUwgoDCb0io/33CZ0rG6pfMCie9GNRQl43R1ti6dT3jwFx1PxjwKnzSVytyMwA/jGd8/p1pluSIJN64K9yHDq+VeP84oPS6oZWKc9rTYzxw9BRf7foc6M+2YhynUwO2v6Q0jNcHB6JaY3N1J0Hd3dKh6h/PpOcmOxnCkmkPljZUH73SVoRKKXnEzIyjNUBwlX1EAZ73uzaTlHj7YUDIynueGXqiszY5gWhSWv4X3KrrlMdv0MGKANlTBlk6ekX/yPFpAOPUF95qHk2MVS/1jG2KMO4b/rAPu63ZnnXN0irOvq03QcZp5Jul6Z2ft+byW8QDRX9Vnr6ufnmpeMYudvWvn6VXIrudg3MKx8Wn56kfif42BWfZIuz6Yj6PxB51cL38A/fd54n/eeSI9Mvz7QPHvA8WVDxSbzQCxBK/HrNLU1P6nMbHzhZ6VJeF7nMMqv5smlyQreqfpe3LN7Oft0fvh5g8vIU568haSo4hNA2IyPqRXEP52ibMIQ1CibcZXsFSM6sIF9kOaXEjfTIsKxhGACkrY+FX8Kyz+y5rqgXsKkqxkqZ/PZnFEQrrc1p21ewz2esqs6q6s3xD/H2uAB7I+tAFekz2peUbzp+DTEJ/naTwv+ATWVX3+Wyx53uB1d1oTFbLhH7eECVHFXRSe6Wt0uTgbZDJt4YrWDAZEmoAJ0gRMaa6AEqAG5A1qC/DWoL3R4ND4aJrh7M5+z1KZOT7sXZNgXtiwt5vD5iDMPAnrw7spcgr94RscLqjl+MABuY1MMQ6bosa0PvMhd9w3WVNjbo89y3jvY/Yl2fb77xENf2eSJrdJp1NYnvcPPuydjd4PT/bO3u592Dvdo+ZnsDvQGRXghLlPmfAyAkmHQrAmZWQW40DxI8jRcPcDuH1itvT3aueQPv9Xmks0I1zTOYTesJQcc7QNf2z15x0RJom+R/0fjGjJbbPmK1Gz35/zCMuXrOqWUhVGbRxMpqmIgakL8WRMaURkrmxRAl8a7VTC8oRDdHtXB8Nr5xWhiGsxFjGzfXpZxnaXJgjLMV3GuRkSoWa38QFTXbFNgVIYFj5Eyfz6hPL3pwRf4ihmonaFUaWqA+ZyoOBQY3bwGD7c+FU69Lt2RqcLk42Iq6pJeau+gG0klJHUgZBSjkCN8aUSglLS2Nv9uYNU1JgTlreohcgy8aI8CLQ8L5iml6RM1uJL1BIZsrLuFm9oBoI68D+YkgRpu8j6opLMlvsq9hb0xqteHFwkaUZ2cU7QdhMdZEXaTec5+JFOcZTAggVxBRBFkFOUaThstTd+e6n4DTtyudMILuOc+ugQd3R0XRsWi/obsopWtXZb4yTJ/m7kJGls3TYshk0lRLVvpWOgEfVtQrc67GnCQ0Njh3A0pvh6Ukhm5if9kxFN7ND2TWq44ekFaqG6qWCC9pZyhwXZLLKmVbAMDPIthGxKP2gGK6gGmznMs73rz+UCUBIJzGZ15HA7P62UTmOhzDNNB8gqBs+Tlu6CIgbQl0dD+WBl0nAfMsPT4uIJdUpRQF+RjFCZlZ9c15iNvJ6mrnQbUZIXOI4tnCE4BJwwuDoDJ0eVeWcMjySbzz7i4GhUwWWgp+DgRCSHYRrV9Vky8zCczCLuYIoS0F8+jcu34LuVeO+h8sBUMSZwytFI4RF0sgdXupzt/Wv3g7ebpZrGGXgHXNTGOI7PcfDVcBBsYpn1pg5kOYQWDRas0mTctXVK7yVFdkO3psO02AdvBtcpGyMaLjbRXDQSxZASrq7H2sTExWb7+Ua/L8SPLmK/3HN0o9Ll1I82Dcqhf62o+LcobZW+c345PDrZO/4w3N0D+4ykR0wqOIPOELAIabOEujsWaDovwDGx5zAil12poMXbOP7zEAGdkwDPc4Li6DwAByk+Sc8JilMc+nxvW9+YdN6kXbdrlC7MFf7/jzShkeHFK03I2tykBsK9w6PRf4/A+Ll3cPjL8MO2xkUZuyrse0Urld69vXUSdVH2TjM0T+BALs2AOa3NwUvYFRl8CeHt0To2zVr6VNs2F9w0qxyT664YWPXKDWogNA6+ja9Kgvy/dFYwo5fruGCg8jYJ7WqBypMi7yBXYdTsDEni+hDsgAbM/GNwxRpyrMlUaqWx4fHARMw0W7QfmwUgtYArW5sVyehM3bau1GywzIlbNxRDxW3bRPcNaklDRwtto5aax63VufepkXxIYWScd9QJqrroY81uu78cLriKcCYUo6fndnPi9rB5y+rHv72WzGadteU2K886WXozsXTB9sDDK/xe8h43F3XN8OCp3bsJqkn5s3fCDBftZ7/99qyLnn3/zDDwa7duCtqoL/XixjWbooJ4oRfmd2oOKJPpn2TWMnXAjCJyzKwh0wuWdynwcuKF0VP7zkzZX+uTXlVdqmSXy3dmYTWnUllceWtX0K4nK2uUr/Uq+t0foob21lEBbr3UysKdFloxxzWZooLjOgytqvOSEVHZ9VGv7rtxREDwfHf0UrmCROts+V6v5Lw+U9R0fXRUr73KRINXV9qOVpG3bAo46styMamSQ/g+oiz0biGDJZNhmT7MzZ+JZwdJkFFLKI6phyTfQozXPXbYDX+24Z9hfJFmUTGZwiFtj/lPKgth84Q3jZOTqn3Q+7FwTpu4Mo1NowSniyevWWde09aQyhk8MU3VPsVSfcCY1ippUKg3nM1IElInWNbFLhJZXHYWTIPKd0CXvy1tivrF5JNhEp6QnBTtCm/byjlgpTBcImdytT+BAFXjS6A1sNQlcHB8T9VtAYp6aOUIZ6VYWuEyQEWEg1w4ApBQR4mFMSrSBL/dI6fixMJykyA48yJzSdTzJMdjX+o/Rsdfv6CcXAAZYFXVsBvN4qhog4xTVgfxD4NNUTihsLooSiSYqhO/CpcRVpuNsACao1avBRaVVq/Xcp/nsqIAkd7AGv1Owrb4k9op4OKRHvyzu7SHdlNCA6agxBzu7z5PJDrNTlaVSePyBpIzwPLkCdLpeZTQNdeqxZmMFqAguLOKeCWHTM0CYDqXQAviz8ECN7DVe7JoFfxeLVaXZxkZR9fAruCHspeE+edI9Fa5GWmGM1yk2e4EZyZuUNFonVL+O1QBxFC1BOF7owJnBUOBYQaBH6IT657VJA/wjLA0mQ43HCvHM8Ox1vfWyvi6fN77BVjY7W8DBf0uNoyNl/GqgZoe95SyyS4t5nJCkdVNvw71w+quHE/YJvTsFsCWhhFc5b9xs8gas+CKL5YZfq0Xm3TahkBlPRQlIYE52d/hf/4kmynTFm7wj9995xuwsh1tpeKvuxLmrxTOF6PbCxo8JVT/hRkmE43dDCTz7jOILv4Zu3hnvEa+EbMXUtHpVNITBEtGasY+/pXDoLPtdmfYVQ1//DU53vtGm8ctRzH5bJ902LZe8bwjCcmiADpifyyvtL9T76vnP1i6BdetbsVxkfX+h2Sp0x1U3jfvvj7OeVf8nZoe4433Vvht1O9UIuTgVO4/f5CDGhBHYZN0pkt5uwhHfZrjwboEqpnTEdxSg57divFUfIzYGkmDQ2TykBmlXd1NV854OYas1zkj4UzvFJG4lxKNZ2CuC/0+jWno9/ub8FsRLdSLJGFEGwDcoDAFwA0bYBCnOTlKIGqoHhyDJsG9ssGNQdgEwisnRrL/d1prBpvqPQPmNAzY47DZLfWrsQPNzbJsdIXCE4CJvvy022xudcZhF2I/eGIkbVtxL6ZKvoH6XOWNYhl8ucsfcNl1WVwqkHBDhodSo9ndpmu/3/Rh7jhtfs/pg991upb7TivuPF383lN4rHtN3feULnCl6QLXmq77atMHvd7UdcWp3zl0uatO/fDWdOXpQ1x72uzqU8/1p/C4hMeomAg8HYKkeLxLcYV8ie7ELyFGMgnSdWzhlEL9RfTrWv3l5N7iL6KlbGOmNXQnBU6KOeT7mV9UdNovcLodfV13ifo5stGdonZDRpyAer+ou61mqkVjbliRE2q4oFLpaDD0qw67f8jv/8wax8L3Dq/FI98jJXnLw/MGOW5jpadSTpfPXqsS2raEVgVi3V76i/b7zQL3uyo9oF2ytD5vK9s1rdSDrnbGrL2dXFEvSrVA2pjozCyPROlUFAeisA9vo63+P152UZQP85skcMuAOkqNk6u48W0ujCyQN9Z8TK8985EG5ygmZuQyOxFswryNM1avdf63Pmd4NgOVrsxGKZQLJSsl34CUbNZalsmqSJwnLWea64UTWle34U13vVKa4rUluF6XwFG/jXhWxQZG+kucoZR2jAvnyhJwxN57NHLutCVztHg2eOlC41xHHlkiBi9J/6YFDSchiSuU4r96qnxQ9rLsPu8h9S1jeHLsF0tTY/klG0umlX+Ct1tpiVHN/YaZ3+Pjo+RHWpN1pS659WTxjNZ2I81MaiKR9bNb6B/ckL5wDlud2WUGGZoqeYAcmaL1qnZaalrTnYfalYuaF2+cfFr6RV2xcZLqbrsNrzsSY9hu30cXE/TTT+jFZgfx3NTi0wdzxnNGkfaiAayZtMovaTyfkhHJIhyz1Nbb16/ut9lHNrIhuYa24L3x+kN6BW9bzsbkcQ89tOKcp7qkcmYyxksUlL6c/NJ4+ZufgrnLmRm7nLmFKNup00vQpSuR7upD0HXk/qYOUo4kT+J5HGo+7pwtwKeK/6F0yfD51D6NwtBhubIqgyen9tbh6al9r3fmNNvxOW/ayPi8LE2I9X6ZzhqLeV6aIFSnS7cPN7fIwbqs+jBu/vBScWCEpfIguUy/ErpHjApcNE0BBYC7bNleIhGUukiOKY7lErnE2YL8c06XnpBcRoGywbG3UZKG1ks4zlCWTJmdy3DyZF6XO6tF1DO00AD9HBXcs5BkvdP0EyMqo6iZ7pSnCnNU2XjJq5gB97RPNXVUh1faISCOu87LLV7nldmOWNqNSmqdf6gNlfsYWIw8RKRBce0OevpUa+yEBS0qm3TvOEthpgyzAHzhAnov4GCA1N+9YTZ9uVWRd+4CQmmf5eiCmZDQy63n51HBI/MoYw5/PkBtuFMAnd+gIQB/udVBNJQhN6HBYH2f0COyaDolYYQLAtnLaEAKqCccPOcE0EIY4ccRicO815hdJH37axhHP4tJrtx42ZzHZKXN/hIMs/Xqj2WYf/nZ5ZsNSAVxZR1rRBqM4ubWegekdsk/jnEB5D9Mi5EI662ModXDsPMCXxB55adM3huScZQQhBFoJ5dM90AxvmFH1eDD4xj7o5EwcrMAXJpKsBmP3D/zHGXT3QKsWHDehAZsCEC03tfO/alIHWmBO2WlAepfb7mK8yAdq/ArV2GQ5qySQ8v7YF7GJ9Egg7bEuL+/v297rkF5JcLqSeuWzYHtaypepyH8pYjOhtjMI7nescSmmtzMZFwVPhjdKqJmTXlXV4lUONoXFZWug/eNyy4mJPhKQq6q0GloHJcJCupvFczND3wYzdcwYOY9LmJwYDxeceXMCIqS8Qg8GEGig151esc4/EDGRXuri571zeg6NVyza/ziWdtr/2sS0eAabEXHlwq/yAxeDn+1hxs7E4GsJxAwEVLdkzopZATHs5Kl3GcnPCWFXnWgFV5afuVw1VSJ1Pt4BXlWHzTZ6Sb5a0RZ5XjnuMgEw3w63X9lEsK2y3o9ONRuhyQAZnX32tMf01Sooz6Go0cTObeY7/JVzQgz5TqcVf0a9dJqEm/tG+pJGTmPkvAh2EzVmmqc3KpVF7TdWDZSdLIFpCM5ISjxF9maHGlJ7SXIgNvoxoJmw3isDZYdpS1HVdq32G1mbDJUu9Lpy4N7dYbd32Rh6i+peM8oc6HSLrvoba+WWmxXVHrTUG7Pr6IiKO3jfrDw2CohJGGmhgfNfwheePxjTCVBAjijzPZyqymgM6i5tIi8rKjM8rONpUbrlZN98u+jGjcg2zPhsVdVa25YX4I61ZThCeOFYZ5ejTolBQ5xgb06gaE7rOuM2sN/TQ6bKzK0gUifJfJ3E38QGnJVRCzt3C8kAxsjRBA/9Ezk02iju8CkNSTiP3ZWUesRn1UaEZeaY/7T28ox9fjkNXcsEOx4TfnRxQw0jWAVl1IcrDcNuFVrraKDda4CSw545XjL9L8kKibcgKcMeJqhszNGNESgiyxEorf4KbwxvF0fVRZwy3uopI5rtehIAhuUVaiJitQ6RV0sI2Gdtuo61XOeVZtiEHgbnM+jOJRZIpi0+DN7136x+eNLVXal1iJpcaMH1AlrD1xuxEn0ZyVNA4XT5Te88p+9XTzDARVOVXEaNlwBe4Co7x3/+XqAzKqr67owYBAvQkLpa0PVkVVuUObLkAxGdw2M6Ik0gIg5UTnKlSB5mUsc2ynGgjTJi9JiUdCcFZ+S4FhEUf9X67ff3vz26XD3N0PVoODU8Gar9hLKBacQNNpC3zGUe6P5OUPQbsKVMcuAZeHJetSqwA69sVreAs2PvqwaDc+xuP8gXLMbVZuHypgJV+iL76IdNVwj3gPysePVdyJyhAc/0NgOaeKDrZyaFDpdPUZixAJMxLk0uvN8pme+5gVUC+Ei/FXWgIaSvcCOBCpDiheLBVpvDFBFiFLb/tZRA09YqBA4saRjV1mlFdig0oynvgCvgN00jhmtUMYzQKmgFfeBNosi4v82acyM8WH3q37EOZjxrQAg6rAFiW24G7n2sUx7g/QsH24wlLVCEioxLXeeomqRasjM5X40P1euRRgmIZRzgmfl66DuUjNJuSo4QZ3ir8yJIp9EswoCC2sP/znQje9mQLDWBNgShyAw0ZvjtLmqlOjUdWd0kwSTLE2i3xX3ArqspUYSNWptN8pMvGnJXKVTK4sYfSNMXyLWHtx6KG/3WH671wO0YUAi7qRkZRBcRpwJ14zj2EbNDzzNN0hqpnoq6Qcf9Ygug5zM5uMK+aP1ILEPrV+R8NI5oncDWo/GdPDPuuBvDu5TUYGvoydkTDLIHejokm2MgJpWqB89F3fH/zkCVRcFQTNm0lrWNH1aroSmwdymoCSGMndgEa4yoTj5eSUKPjZHay1k9gKxSDYYmIubr6oZJ8rOwByxo14IZvgoQHCGlOpD0GwW/9VHoSpwd6ExaVuDwtOdQX45+gXd2QPHAjz56846h/BeX5PHeh7Icuk1M+XSe01BVJKCBBNYTYditoFSZ0ZBR8c3p0+AR3VwbwHOSGRwTqVxyCsI0bpBQluSHJ96v4CqZLgeMNZ2farbwbpNdhUHGj5/VdcK6SixmAeryjOL3WcrHP4rtMGaO5gN3UX1Ia9SaxopIAIW1YHWrPc0VGrRG9qcknmNlWmDVi6jOuRL5TS1NqfCg+VSsJbPjK+ichC9qrq9thqa9FqE+RJVa4GOyheG4CmRR2/Q6ks42nZvzo7haZiJgiWd4LSGgAAJ3UhQ7UwS0SQhBIiDhpHGHnLdwDFqbGxpWywudX0FBSsafaGGTcsKk+8rmqNHnY4u65KquOHb2AnLG6Q5qyrbHg8c3/ZulXWWboIh1DGIcZ57tkljnaQeNxBpWG2PyyGaw6G4SqcB+tZKy61nw9NiOigs7NraKnVfHb5L7+X163e05romB6ntZ55BB8kCVOUwVBJxupKma2R0FGXZzwVZd+oTpdtE3mmUIt3xeqdhcnTPGDl6Y5slyjc7TRKiu4bTUbGB1aDRiO9UJUFX+cAlMMs5xdmf8sQtuiDFjqsU5S2dPfyFOUjJIrUlHbxSg4qTUWrq+HikSa+Vca4p7uSNJnVq2aIGiMYAWtmqBVjxXfatutq9wnq0Y+mPpfozq7FJ4of7YgrV+ZsvYKrPsuFzTi+GKN/5rn6o2AFm9jUWf28O+uagOC+KUd6pug9FHXm9oAyxNaPUzLtLJHPs+G4tETyyU3HpiMI4O5XXjWj8tFN90YjOZ0vvls6bVWxm/HuL/U/eYuXMqt0PtSnmL01XVj7L6krJ6eYvCBwv51zNZqNOvdqi6vRrUFiZgOsVSByz8G8h5q8ixNhllRvR1PweKTjFddDgtUPLEwb70pOeli5v56pwLzHP7zSA2n1gHKj/jjB4KjKINLppoeJ2Bd2O+euoyOZB8YEGIrbZf/+MkrA3gut1EsjS1fliWDyhAh2L02gKk2Q2P4+jgLmKQQYM7QVksNhZoDW4sSIAf4INf7ssD08Elh6wehiiKW8dAgN4Vr1yK1+113a+S3fjlBSGYMM/8Zgw26RvlWThD0p2y/X0wZM+xt0ROoa+XC28jGQFmtgnShP44S8FvnVsvteXY2mFXMUoYnYSFXc5NXVLdQktg4taQEtB4ochs7bUFJHtiNF8G8cHU3BNbbe+kiwh8YvNXhjHrS6Cq0hG9LIu/hckUYIwti6cOACVqJOiuHD0i+t8ArzsssQMPlPTWorc/TyZWpfhG5IcrkASRiT6LofEUHo4KedpYWgseYVXoSnNojRRJi7/MgZL7TBRHHtENCSCnGRwoT29lHRnzWTqKvfcowFqKaRoLUlCLW9sE3JK8hlkpclcHVl8FyKvDvyPInMlKek+zQgoBQFJOWZobkyJBpgvhV5FSi6DE2Co6/Nz8b8fCt2RH93yXNCBtypE0TcKhF0wBZUlYEgcm7BZy2DAMlvlt+Ux2rjfbd05hppHPGNEvTPwSuSnKqfXHzB5PqaXFP29azlvCL9aRGTFlCashFyV76qxhqiGJTgQYNJbIrQp/FAtjYPJNA2teFCZDGndzdE4I7O1X4/mxRc1BrWiVX3DEVE4y6LD4p7gz0sR7rQ+5GhnnxwcHr3de7m1CsFknOkquC3T+vRrGGViwEqTscaZD8EqMuMDz++gNWimg2CXo62z2xlJ8JRcnyUzKYFk6VT2u0gfcOqztnGxSYmexqFiqhdnrDHtNGPVhFzZJRJyxUo0QLMSI+qRQ5MzcKrTyCzqJ3L/6P8A4yWrQOnwAAA= '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 1d73e97..5525d5f 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -22,7 +22,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1474 +$minimumTests = 1482 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 7651dc1..6919d08 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -50,7 +50,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 -$minimumTests = 1474 +$minimumTests = 1482 $allowedSkips = 0 $allowedNotRun = 0 diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 71872fb..51f9307 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -51,6 +51,7 @@ public static class GraphKitAuthStageCapture { private const uint GenericRead = 0x80000000; private const uint GenericWrite = 0x40000000; + private const uint DeleteAccess = 0x00010000; private const uint ShareRead = 0x00000001; private const uint ShareWrite = 0x00000002; private const uint ShareDelete = 0x00000004; @@ -61,6 +62,7 @@ public static class GraphKitAuthStageCapture private const uint FileFlagBackupSemantics = 0x02000000; private const uint FileFlagWriteThrough = 0x80000000; private const uint FileAttributeReparsePoint = 0x00000400; + private const int FileDispositionInfoClass = 4; public static GraphKitAuthPathEvidence InspectFile(string rootPath, string relativePath) => Inspect(rootPath, relativePath, expectDirectory: false, hashContent: true); @@ -243,6 +245,23 @@ public static GraphKitAuthCopyEvidence CopyFileCreateNew( 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); @@ -263,54 +282,141 @@ public static GraphKitAuthCopyEvidence CopyFileCreateNew( using FileStream destinationStream = OpenDestinationCreateNew( destinationPath, requireInitialOwnerOnly); SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; - 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."); - } - SetOwnerOnly(destinationPath, directory: false, writable: true); - byte[] buffer = new byte[131072]; - long offset = 0; - while (offset < sourceBefore.Length) + try { - int requested = (int)Math.Min(buffer.Length, sourceBefore.Length - offset); - int read = RandomAccess.Read(sourceHandle, buffer.AsSpan(0, requested), offset); - if (read == 0) + if (simulatePostCreateFailure) { - throw new EndOfStreamException($"Source '{sourceRelativePath}' ended during capture."); + RandomAccess.Write(destinationHandle, new byte[] { 0xA5 }, 0); + RandomAccess.FlushToDisk(destinationHandle); + throw new IOException("Injected post-create copy failure after a partial write."); } - if (offset > maximumLength - read) + GraphKitAuthPathEvidence destinationInitial = EvidenceFromHandle( + destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); + if (requireInitialOwnerOnly && !HasInitialOwnerOnlyAccess(destinationInitial)) { - throw new IOException($"Source '{sourceRelativePath}' exceeded its bounded capture length."); + throw new IOException($"New destination '{destinationRelativePath}' did not begin with owner-only access."); } - RandomAccess.Write(destinationHandle, buffer.AsSpan(0, read), offset); - offset += read; + SetOwnerOnly(destinationHandle, destinationPath, directory: false, writable: true); + 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 + }; } - RandomAccess.FlushToDisk(destinationHandle); + catch (Exception primaryFailure) + { + HandleCreateNewFailure( + destinationHandle, + destinationRelativePath, + operation: "Copying", + primaryFailure: primaryFailure); + throw; + } + } - NativeFacts sourceAfter = GetNativeFacts(sourceHandle, sourcePath); - if (!sourceBefore.SameObject(sourceAfter) || sourceBefore.Length != sourceAfter.Length) + private static void HandleCreateNewFailure( + SafeFileHandle destinationHandle, + string destinationRelativePath, + string operation, + Exception primaryFailure) + { + try { - throw new IOException($"Source '{sourceRelativePath}' changed while it was being captured."); + 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)); } - 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) + if (!OperatingSystem.IsWindows()) { - throw new IOException($"Captured destination '{destinationRelativePath}' does not match its source."); + 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); } + } - return new GraphKitAuthCopyEvidence + private static void MarkExactWindowsHandleForDeletion( + SafeFileHandle destinationHandle, + string destinationRelativePath) + { + if (!GetFileInformationByHandle(destinationHandle, out ByHandleFileInformation info)) { - Source = sourceEvidence, - DestinationInitial = destinationInitial, - Destination = destinationEvidence - }; + 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( @@ -318,6 +424,19 @@ public static GraphKitAuthWriteEvidence WriteFileCreateNew( 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); @@ -326,28 +445,46 @@ public static GraphKitAuthWriteEvidence WriteFileCreateNew( using FileStream destinationStream = OpenDestinationCreateNew( destinationPath, requireInitialOwnerOnly); SafeFileHandle destinationHandle = destinationStream.SafeFileHandle; - GraphKitAuthPathEvidence destinationInitial = EvidenceFromHandle( - destinationHandle, destinationPath, destinationRelativePath, expectDirectory: false); - if (requireInitialOwnerOnly && !HasInitialOwnerOnlyAccess(destinationInitial)) + try { - throw new IOException($"New destination '{destinationRelativePath}' did not begin with owner-only access."); + 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."); + } + SetOwnerOnly(destinationHandle, destinationPath, directory: false, writable: true); + 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 + }; } - SetOwnerOnly(destinationPath, directory: false, writable: true); - 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."); + catch (Exception primaryFailure) + { + HandleCreateNewFailure( + destinationHandle, + destinationRelativePath, + operation: "Writing", + primaryFailure: primaryFailure); + throw; } - return new GraphKitAuthWriteEvidence - { - DestinationInitial = destinationInitial, - Destination = destination - }; } public static void SetOwnerOnly(string absolutePath, bool directory, bool writable) @@ -369,6 +506,31 @@ public static void SetOwnerOnly(string absolutePath, bool directory, bool writab File.SetUnixFileMode(path, mode); } + private static void SetOwnerOnly( + SafeFileHandle handle, + string absolutePath, + bool directory, + bool writable) + { + if (OperatingSystem.IsWindows()) + { + // The create handle omits FILE_SHARE_DELETE, so the path cannot be + // renamed or replaced while its ACL is applied. + SetOwnerOnlyWindows(absolutePath, directory, writable); + return; + } + + uint mode = directory + ? (writable ? 0x1C0u : 0x140u) // 0700 / 0500 + : (writable ? 0x180u : 0x100u); // 0600 / 0400 + 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); @@ -667,61 +829,98 @@ private static FileStream OpenDestinationCreateNew( string destinationPath, bool requireInitialOwnerOnly) { - if (OperatingSystem.IsWindows() && requireInitialOwnerOnly) + if (OperatingSystem.IsWindows()) { - FileSecurity security = new(); - SecurityIdentifier owner = WindowsIdentity.GetCurrent().User - ?? throw new IOException("The current Windows identity has no SID."); - security.SetOwner(owner); - security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); - security.AddAccessRule(new FileSystemAccessRule( - owner, - FileSystemRights.FullControl, - InheritanceFlags.None, - PropagationFlags.None, - AccessControlType.Allow)); - byte[] descriptor = security.GetSecurityDescriptorBinaryForm(); - GCHandle pinnedDescriptor = GCHandle.Alloc(descriptor, GCHandleType.Pinned); - try + SafeFileHandle handle; + if (requireInitialOwnerOnly) { - SecurityAttributes attributes = new() + FileSecurity security = new(); + SecurityIdentifier owner = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + owner, + FileSystemRights.FullControl, + InheritanceFlags.None, + PropagationFlags.None, + AccessControlType.Allow)); + byte[] descriptor = security.GetSecurityDescriptorBinaryForm(); + GCHandle pinnedDescriptor = GCHandle.Alloc(descriptor, GCHandleType.Pinned); + try { - Length = Marshal.SizeOf(), - SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), - InheritHandle = 0 - }; - SafeFileHandle handle = CreateFileWithSecurityW( + SecurityAttributes attributes = new() + { + Length = Marshal.SizeOf(), + SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), + InheritHandle = 0 + }; + handle = CreateFileWithSecurityW( + destinationPath, + GenericRead | GenericWrite | DeleteAccess, + ShareRead, + ref attributes, + CreateNew, + FileAttributeNormal | FileFlagWriteThrough, + IntPtr.Zero); + } + finally + { + pinnedDescriptor.Free(); + } + } + else + { + handle = CreateFileW( destinationPath, - GenericRead | GenericWrite, + GenericRead | GenericWrite | DeleteAccess, ShareRead, - ref attributes, + IntPtr.Zero, CreateNew, FileAttributeNormal | FileFlagWriteThrough, IntPtr.Zero); - if (handle.IsInvalid) + } + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + if (error == 80 || error == 183) { - int error = Marshal.GetLastWin32Error(); - handle.Dispose(); - if (error == 80 || error == 183) - { - throw new IOException("Atomic owner-only file destination collision."); - } throw new IOException( - $"Could not atomically create owner-only destination file (Win32 {error})."); + 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 { - return new FileStream(handle, FileAccess.ReadWrite, bufferSize: 4096, isAsync: false); + MarkExactWindowsHandleForDeletion( + handle, + Path.GetFileName(destinationPath)); } - catch + 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; } - } - finally - { - pinnedDescriptor.Free(); + throw; } } @@ -732,10 +931,7 @@ private static FileStream OpenDestinationCreateNew( Share = FileShare.Read, Options = FileOptions.WriteThrough }; - if (!OperatingSystem.IsWindows()) - { - options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; - } + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; return new FileStream(destinationPath, options); } @@ -1104,6 +1300,12 @@ internal bool SameObject(NativeFacts other) => [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 { @@ -1142,6 +1344,13 @@ private static extern SafeFileHandle CreateFileWithSecurityW(string fileName, ui [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetFileInformationByHandle(SafeFileHandle file, out ByHandleFileInformation information); + [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); @@ -1152,6 +1361,9 @@ private static extern uint GetFinalPathNameByHandleW(SafeFileHandle file, String [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); diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 9e18da9..340a2c8 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -595,6 +595,13 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { 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) @@ -1180,6 +1187,345 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } } + 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')) @@ -1780,6 +2126,58 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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')) diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index 6ca361a..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 = 1474) + param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 1482) $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" @" diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 648e8d8..51964db 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -153,7 +153,7 @@ BeforeAll { [switch] $NullRequiredModules, [string] $BaseVersion = '0.4.0', [switch] $DirtySource, - [int] $Total = 1474 + [int] $Total = 1482 ) $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('graphkit-release-proof-' + [guid]::NewGuid().ToString('N')) @@ -386,7 +386,7 @@ $requiredAssembliesLine$requiredModulesLine sha256 = (Get-FileHash -LiteralPath $pesterObjectPath -Algorithm SHA256).Hash.ToLowerInvariant() } policy = [pscustomobject] [ordered] @{ - minimumTests = 1474 + minimumTests = 1482 allowedSkips = 0 allowedNotRun = 0 } @@ -1096,7 +1096,7 @@ Describe 'Test workflow release-proof generation' { $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 1474 + $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 From 08658768560d8f7b14957e786b5b34c0073ffd42 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 06:54:23 -0400 Subject: [PATCH 66/79] fix: preserve Windows GraphKit.Auth ACL transitions --- scripts/Invoke-GraphKitAuthParity.ps1 | 4 +- scripts/private/GraphKit.AuthStageCapture.cs | 86 ++++++++++++++------ tests/QA/GraphKitAuthPackage.tests.ps1 | 74 ++++++++++++++++- 3 files changed, 136 insertions(+), 28 deletions(-) diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index d6f1983..6474bc5 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - '5e0501580cacba66000029b52815f3c3bbd4aa4b324cd16a69c61ff827bc2dbe' + 'e38ab86ce06a847c83007effa6e07f58dc4a097b45e0b846c8c55a79b29b2f08' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 51f9307..2e24089 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -52,6 +52,8 @@ 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; @@ -296,7 +298,7 @@ public static GraphKitAuthCopyEvidence CopyFileCreateNew( { throw new IOException($"New destination '{destinationRelativePath}' did not begin with owner-only access."); } - SetOwnerOnly(destinationHandle, destinationPath, directory: false, writable: true); + SetOwnerOnlyWritableFile(destinationStream, destinationPath); byte[] buffer = new byte[131072]; long offset = 0; while (offset < sourceBefore.Length) @@ -459,7 +461,7 @@ public static GraphKitAuthWriteEvidence WriteFileCreateNew( { throw new IOException($"New destination '{destinationRelativePath}' did not begin with owner-only access."); } - SetOwnerOnly(destinationHandle, destinationPath, directory: false, writable: true); + SetOwnerOnlyWritableFile(destinationStream, destinationPath); RandomAccess.Write(destinationHandle, content, 0); RandomAccess.FlushToDisk(destinationHandle); GraphKitAuthPathEvidence destination = EvidenceFromHandle( @@ -506,23 +508,20 @@ public static void SetOwnerOnly(string absolutePath, bool directory, bool writab File.SetUnixFileMode(path, mode); } - private static void SetOwnerOnly( - SafeFileHandle handle, - string absolutePath, - bool directory, - bool writable) + private static void SetOwnerOnlyWritableFile( + FileStream stream, + string absolutePath) { if (OperatingSystem.IsWindows()) { - // The create handle omits FILE_SHARE_DELETE, so the path cannot be - // renamed or replaced while its ACL is applied. - SetOwnerOnlyWindows(absolutePath, directory, writable); + FileSecurity security = (FileSecurity)CreateOwnerOnlyWindowsSecurity( + directory: false, writable: true, setOwner: true); + FileSystemAclExtensions.SetAccessControl(stream, security); return; } - uint mode = directory - ? (writable ? 0x1C0u : 0x140u) // 0700 / 0500 - : (writable ? 0x180u : 0x100u); // 0600 / 0400 + SafeFileHandle handle = stream.SafeFileHandle; + const uint mode = 0x180u; // 0600 if (fchmod(handle.DangerousGetHandle().ToInt32(), mode) != 0) { throw new IOException( @@ -857,7 +856,7 @@ private static FileStream OpenDestinationCreateNew( }; handle = CreateFileWithSecurityW( destinationPath, - GenericRead | GenericWrite | DeleteAccess, + GenericRead | GenericWrite | DeleteAccess | WriteDacAccess | WriteOwnerAccess, ShareRead, ref attributes, CreateNew, @@ -873,7 +872,7 @@ private static FileStream OpenDestinationCreateNew( { handle = CreateFileW( destinationPath, - GenericRead | GenericWrite | DeleteAccess, + GenericRead | GenericWrite | DeleteAccess | WriteDacAccess | WriteOwnerAccess, ShareRead, IntPtr.Zero, CreateNew, @@ -1193,11 +1192,57 @@ private static WindowsPermissionFacts GetWindowsPermissionFacts(string path, boo } 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 currentIdentity = WindowsIdentity.GetCurrent().User + ?? throw new IOException("The current Windows identity has no SID."); + if (!currentOwner.Equals(currentIdentity)) + { + throw new IOException( + $"Owner-only access refused for '{path}' because its owner is not the current Windows identity."); + } + 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) { WindowsIdentity identity = WindowsIdentity.GetCurrent(); SecurityIdentifier owner = identity.User ?? throw new IOException("The current Windows identity has no SID."); FileSystemSecurity security = directory ? new DirectorySecurity() : new FileSecurity(); - security.SetOwner(owner); + if (setOwner) + { + security.SetOwner(owner); + } security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); FileSystemRights rights = writable ? FileSystemRights.FullControl @@ -1205,14 +1250,7 @@ private static void SetOwnerOnlyWindows(string path, bool directory, bool writab InheritanceFlags inheritance = directory && writable ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None; security.AddAccessRule(new FileSystemAccessRule(owner, rights, inheritance, PropagationFlags.None, AccessControlType.Allow)); - if (directory) - FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), (DirectorySecurity)security); - else - FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); - if (!directory) - { - File.SetAttributes(path, writable ? FileAttributes.Normal : FileAttributes.ReadOnly); - } + return security; } private sealed class WindowsPermissionFacts diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 340a2c8..f114d9d 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -2207,7 +2207,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { [IO.File]::ReadAllText($unrecorded) | Should -BeExactly 'partial-unregistered' } - It 'declares and consumes the exact Windows owner-only ACL evidence schema' { + 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 @@ -2222,6 +2222,45 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } $helper | Should -Match 'directory \? FileSystemRights\.ReadAndExecute : FileSystemRights\.Read' $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' + $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)if \(!currentOwner\.Equals\(currentIdentity\)\).*?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' { @@ -2288,7 +2327,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $copy.Destination.UnixMode | Should -Be 0x180 } - It 'creates a Windows child with only current-identity access before explicit reseal' -ForEach $windowsInitialAccessCases -AllowNullOrEmptyForEach { + It 'creates a Windows child with current-identity access and round trips repeated seal transitions' -ForEach $windowsInitialAccessCases -AllowNullOrEmptyForEach { Initialize-GraphKitAuthStageCapture $root = Join-Path $TestDrive ('windows-initial-access-' + [guid]::NewGuid().ToString('N')) $source = Join-Path $root 'source' @@ -2324,6 +2363,27 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $copy.DestinationInitial.AccessRulesProtected | Should -BeTrue $copy.DestinationInitial.HasInheritedAccessRules | 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 $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 $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) @@ -2360,6 +2420,11 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $ordinary = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( $source, 'candidate.dll', $destination, 'candidate.dll', $false) $ordinary.DestinationInitial.OwnerOnlyAccess | Should -BeFalse + $ordinary.Destination.OwnerSid | 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) @@ -2367,6 +2432,11 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Should -BeTrue $sealed.DestinationInitial.AccessRulesProtected | Should -BeTrue $sealed.DestinationInitial.HasInheritedAccessRules | Should -BeFalse + $sealed.Destination.OwnerSid | 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 From 1b4e9a7f5fcccf1cf87f4035754d5cb512427685 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 07:46:03 -0400 Subject: [PATCH 67/79] test: make R8 gates portable on Windows --- .gitattributes | 1 + tests/QA/ReleaseProof.tests.ps1 | 1 + tests/QA/TrainVersion.tests.ps1 | 15 +++++++------ tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 22 +++++++++---------- .../Import-GraphLegacyProfile.Tests.ps1 | 2 +- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.gitattributes b/.gitattributes index 7a09168..a69e968 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +* text=auto eol=lf tests/Fixtures/GraphKitAuthParityCases.json text eol=lf diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 51964db..d0be128 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -201,6 +201,7 @@ internal static class Fixture { internal const string Value = "public fixture"; -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() diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index c1d3828..7ea2a09 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -9,6 +9,7 @@ BeforeAll { 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 @@ -486,10 +487,10 @@ internal static class GitShimLauncher $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 = @' + $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' @@ -510,11 +511,11 @@ string proofIdentity = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE Copy-Item -LiteralPath $script:sourceCaptureHelper -Destination $helper if ($CaptureSentinel) { $source = (Get-Content -LiteralPath $helper -Raw).Replace("`r`n", "`n") - $needle = @' + $needle = (@' public static CapturedSourceFile Capture(string repositoryRoot, string relativePath) { -'@ - $replacement = @' +'@).Replace("`r`n", "`n") + $replacement = (@' public static CapturedSourceFile Capture(string repositoryRoot, string relativePath) { string? captureSentinel = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE_SENTINEL"); @@ -522,7 +523,7 @@ string proofIdentity = Environment.GetEnvironmentVariable("GRAPHKIT_TEST_CAPTURE { 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 } @@ -1071,7 +1072,7 @@ $source It 'accepts a platform-valid untracked path containing special and non-ASCII characters' { $root = New-R8TrainVersionFixture - $relative = if ($IsWindows) { "source/Private/tab`t雪.ps1" } else { "source/Private/tab`tline`n雪.ps1" } + $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 diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index 799c1af..4c390fe 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -2313,17 +2313,15 @@ switch ($Scenario) { $residentMvid = [GraphKit.Auth.GraphAuthHost].Assembly.ManifestModule.ModuleVersionId.ToString('D') $resolvedReplacement = (Resolve-Path -LiteralPath $ReplacementContractsPath).ProviderPath $resolvedContracts = (Resolve-Path -LiteralPath $ContractsPath).ProviderPath - if ($IsWindows) { - [IO.File]::Copy($resolvedReplacement, $resolvedContracts, $true) - } - else { - # Never truncate an assembly that CoreCLR may have memory-mapped: Linux can - # terminate with SIGBUS when a mapped page disappears. An atomic rename gives - # the path new bytes while the resident assembly retains its original inode. - $atomicReplacement = "$resolvedContracts.replacement.$([guid]::NewGuid().ToString('N'))" - [IO.File]::Copy($resolvedReplacement, $atomicReplacement) - [IO.File]::Move($atomicReplacement, $resolvedContracts, $true) - } + # 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() @@ -2561,7 +2559,7 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { $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 candidate' + $result.Output | Should -Match '(?s)Default ALC already contains.*refusing\s+candidate' } It 'binds a fresh synthetic candidate by exact location bytes and MVID' { diff --git a/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 b/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 index 8a4a67b..6f25e23 100644 --- a/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 +++ b/tests/Unit/Profiles/Import-GraphLegacyProfile.Tests.ps1 @@ -217,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 From 377c823ed4d8a2783c80b283a006378e78898281 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 08:21:41 -0400 Subject: [PATCH 68/79] fix: harden Windows auth stage portability --- .build/GraphKitAuth.tasks.ps1 | 4 +- scripts/Invoke-GraphKitAuthParity.ps1 | 4 +- scripts/private/GraphKit.AuthStageCapture.cs | 52 +++- tests/Adapter/Send-GraphHttpRequest.Tests.ps1 | 11 +- .../GraphKitAuthRunspace.Tests.ps1 | 11 +- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 110 ++++++- tests/QA/GraphKitAuthPackage.tests.ps1 | 292 +++++++++++++++--- 7 files changed, 420 insertions(+), 64 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index 8cc5d4f..a4c98b4 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -694,7 +694,7 @@ function Test-GraphKitAuthSealedStage { 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' } else { 'unix-0400' }) -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.' } @@ -827,7 +827,7 @@ function New-GraphKitAuthSealedStage { schemaVersion = 1 fullVersion = $FullVersion permissions = [ordered]@{ - file = if ($IsWindows) { 'windows-owner-read' } else { 'unix-0400' } + file = if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0400' } directory = if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0500' } } directories = [ordered]@{ diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 6474bc5..2bdafb0 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - 'e38ab86ce06a847c83007effa6e07f58dc4a097b45e0b846c8c55a79b29b2f08' + '7226425964255754dfb94307ce1422309ce35cf43812a1620eb466a49a850a0e' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,7 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 2e24089..9b29eb1 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -165,7 +165,7 @@ public static GraphKitAuthPathEvidence CreateDirectoryOwnerOnly( SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(), InheritHandle = 0 }; - if (!CreateDirectoryW(child, ref attributes)) + if (!CreateDirectoryW(ToExtendedWindowsPath(child), ref attributes)) { error = Marshal.GetLastWin32Error(); if (error == 80 || error == 183) @@ -570,7 +570,10 @@ public static void MoveDirectoryCreateNew( int error; if (OperatingSystem.IsWindows()) { - if (MoveFileExW(source, destination, 0)) + if (MoveFileExW( + ToExtendedWindowsPath(source), + ToExtendedWindowsPath(destination), + 0)) { return; } @@ -797,7 +800,7 @@ private static SafeFileHandle OpenReadNoFollow(string fullPath, bool directory) if (OperatingSystem.IsWindows()) { SafeFileHandle handle = CreateFileW( - fullPath, + ToExtendedWindowsPath(fullPath), GenericRead, ShareRead | ShareWrite | ShareDelete, IntPtr.Zero, @@ -855,7 +858,7 @@ private static FileStream OpenDestinationCreateNew( InheritHandle = 0 }; handle = CreateFileWithSecurityW( - destinationPath, + ToExtendedWindowsPath(destinationPath), GenericRead | GenericWrite | DeleteAccess | WriteDacAccess | WriteOwnerAccess, ShareRead, ref attributes, @@ -871,7 +874,7 @@ private static FileStream OpenDestinationCreateNew( else { handle = CreateFileW( - destinationPath, + ToExtendedWindowsPath(destinationPath), GenericRead | GenericWrite | DeleteAccess | WriteDacAccess | WriteOwnerAccess, ShareRead, IntPtr.Zero, @@ -1131,6 +1134,40 @@ private static string NormalizeWindowsPhysicalPath(string value) 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 WindowsPermissionFacts GetWindowsPermissionFacts(string path, bool directory) { FileSystemSecurity security = directory @@ -1145,8 +1182,7 @@ private static WindowsPermissionFacts GetWindowsPermissionFacts(string path, boo FileSystemRights.DeleteSubdirectoriesAndFiles | FileSystemRights.Delete | FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership; FileSystemRights expectedRights = - (directory ? FileSystemRights.ReadAndExecute : FileSystemRights.Read) | - FileSystemRights.Synchronize; + FileSystemRights.ReadAndExecute | FileSystemRights.Synchronize; bool ownerWritable = false; bool hasInheritedAccessRules = false; bool ownerOnlyAccess = owner.Equals(current) && rules.Count >= 1; @@ -1246,7 +1282,7 @@ private static FileSystemSecurity CreateOwnerOnlyWindowsSecurity( security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); FileSystemRights rights = writable ? FileSystemRights.FullControl - : (directory ? FileSystemRights.ReadAndExecute : FileSystemRights.Read); + : FileSystemRights.ReadAndExecute; InheritanceFlags inheritance = directory && writable ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None; security.AddAccessRule(new FileSystemAccessRule(owner, rights, inheritance, PropagationFlags.None, AccessControlType.Allow)); diff --git a/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 b/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 index ef0fd8b..1cfba6b 100644 --- a/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 +++ b/tests/Adapter/Send-GraphHttpRequest.Tests.ps1 @@ -88,7 +88,14 @@ public sealed class CompiledAdoptionTokenSource : IGraphTokenSource if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $fixtureAssembly -PathType Leaf)) { throw "Task 6 compiled sender fixture did not compile: $($compilerOutput | Out-String)" } - $null = [Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($fixtureAssembly) + $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' @@ -264,6 +271,8 @@ Describe 'Send-GraphHttpRequest (loopback through the real sender)' { } 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) diff --git a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 index b1e92c1..5331500 100644 --- a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 +++ b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 @@ -206,7 +206,14 @@ public sealed class Task7OfflineHandler : HttpMessageHandler if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $fixtureAssembly -PathType Leaf)) { throw "Task 7 controlled fixture build failed: $($buildOutput | Out-String)" } - $null = [Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($fixtureAssembly) + $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) { @@ -789,6 +796,8 @@ AfterAll { 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 diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 7196c97..9a69130 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -332,21 +332,42 @@ function Set-Task8FixtureOwnerWritable { param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][bool] $Directory) if ($IsWindows) { $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User - $acl = Get-Acl -LiteralPath $Path - $acl.SetOwner($identity) + $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.SetAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( $identity, [Security.AccessControl.FileSystemRights]::FullControl, $inheritance, [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)) - Set-Acl -LiteralPath $Path -AclObject $acl - if (-not $Directory) { (Get-Item -LiteralPath $Path).IsReadOnly = $false } + 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( @@ -1451,26 +1472,85 @@ finally { [IO.Path]::GetFileName($full) -notmatch '^graphkit-task8-') { throw 'Task 8 fixture cleanup refused a non-literal residual path.' } + $rootItem = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if (-not [string]::IsNullOrEmpty([string] $rootItem.LinkType) -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Remove-Item -LiteralPath $full -Force -ErrorAction Stop + return + } + $paths = @( + Get-ChildItem -LiteralPath $full -Recurse -Force -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Length } -Descending + ) + @($rootItem) if ($IsWindows) { $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User - $paths = @( - Get-ChildItem -LiteralPath $full -Recurse -Force -ErrorAction SilentlyContinue | - Sort-Object { $_.FullName.Length } -Descending - ) + @(Get-Item -LiteralPath $full -Force) foreach ($item in $paths) { - if (-not $item.PSIsContainer) { $item.IsReadOnly = $false } - $acl = Get-Acl -LiteralPath $item.FullName + if (-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) - $acl.SetAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $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)) - Set-Acl -LiteralPath $item.FullName -AclObject $acl + 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 { - & chmod -R u+rwX $full - if ($LASTEXITCODE -ne 0) { throw 'Task 8 fixture cleanup could not restore owner access.' } + foreach ($item in $paths) { + if (-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 } diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index f114d9d..3eedb90 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -153,30 +153,36 @@ BeforeAll { 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 Set-GraphKitAuthTestStageWritable { param([Parameter(Mandatory)] [string] $StagePath) - $payloadPath = Join-Path $StagePath 'payload' $versionPath = Split-Path $StagePath -Parent - if ($IsWindows) { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent().User - foreach ($directoryPath in @($versionPath, $StagePath, $payloadPath)) { - $acl = Get-Acl -LiteralPath $directoryPath - $acl.SetAccessRuleProtection($true, $false) - $rule = [Security.AccessControl.FileSystemAccessRule]::new( - $identity, [Security.AccessControl.FileSystemRights]::FullControl, - [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit, - [Security.AccessControl.PropagationFlags]::None, - [Security.AccessControl.AccessControlType]::Allow) - $acl.SetAccessRule($rule) - Set-Acl -LiteralPath $directoryPath -AclObject $acl + $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) + foreach ($item in $items) { + if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $item)) { continue } + $directory = [bool] $item.PSIsContainer + if ($IsWindows) { + Set-GraphKitAuthTestWindowsPathWritable ` + -Path $item.FullName -Directory $directory + } + else { + Set-GraphKitAuthTestUnixPathWritable -Path $item.FullName ` + -Directory $directory -Exact } - Get-ChildItem -LiteralPath $StagePath -File -Recurse -Force | ForEach-Object { $_.IsReadOnly = $false } - } - else { - & chmod 0700 $versionPath $StagePath $payloadPath - if ($LASTEXITCODE -ne 0) { throw 'Could not open the stage fixture for mutation.' } - Get-ChildItem -LiteralPath $StagePath -File -Recurse -Force | ForEach-Object { & chmod 0600 $_.FullName } - if ($LASTEXITCODE -ne 0) { throw 'Could not open the stage files for mutation.' } } } @@ -214,20 +220,97 @@ BeforeAll { 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) + $inheritance = if ($Directory) { + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit + } + else { + [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 } - if ($IsWindows) { - Get-ChildItem -LiteralPath $Path -File -Recurse -Force -ErrorAction SilentlyContinue | - ForEach-Object { $_.IsReadOnly = $false } - foreach ($directory in @(Get-ChildItem -LiteralPath $Path -Directory -Recurse -Force -ErrorAction SilentlyContinue | - Sort-Object { $_.FullName.Length } -Descending) + @(Get-Item -LiteralPath $Path -Force)) { - $script:GraphKitAuthStageCaptureType::SetOwnerOnly($directory.FullName, $true, $true) + $rootItem = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $rootItem)) { return } + $items = @( + Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Length } -Descending + ) + @($rootItem) + foreach ($item in $items) { + if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $item)) { continue } + $directory = [bool] $item.PSIsContainer + if ($IsWindows) { + Set-GraphKitAuthTestWindowsPathWritable ` + -Path $item.FullName -Directory $directory + } + else { + Set-GraphKitAuthTestUnixPathWritable -Path $item.FullName ` + -Directory $directory } - } - else { - & chmod -R u+rwX $Path - if ($LASTEXITCODE -ne 0) { throw "Could not make test tree '$Path' writable." } } } @@ -1856,7 +1939,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } } - It 'handles unavailable Linux renameat2 as actionable fail-closed without a fallback' { + 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' @@ -1872,6 +1955,116 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { 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 + 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' { @@ -2220,7 +2413,13 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $helper | Should -Match ([regex]::Escape($property + ' { get; init; }')) $task | Should -Match ([regex]::Escape('$Evidence.' + $property)) } - $helper | Should -Match 'directory \? FileSystemRights\.ReadAndExecute : FileSystemRights\.Read' + $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;')) @@ -2329,7 +2528,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { It 'creates a Windows child with current-identity access and round trips repeated seal transitions' -ForEach $windowsInitialAccessCases -AllowNullOrEmptyForEach { Initialize-GraphKitAuthStageCapture - $root = Join-Path $TestDrive ('windows-initial-access-' + [guid]::NewGuid().ToString('N')) + $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 @@ -2337,7 +2537,6 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $worldSid = [Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::WorldSid, $null) $acl = [Security.AccessControl.DirectorySecurity]::new() - $acl.SetOwner($currentSid) $acl.SetAccessRuleProtection($true, $false) $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( $currentSid, [Security.AccessControl.FileSystemRights]::FullControl, @@ -2349,12 +2548,15 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { [Security.AccessControl.InheritanceFlags]::ObjectInherit, [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)) - [Security.AccessControl.FileSystemAclExtensions]::SetAccessControl( + [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 $currentSid.Value @@ -2362,6 +2564,26 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { 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()) + { $script:GraphKitAuthStageCaptureType::WriteFileCreateNew( + $destination, 'ordinary.bin', $ordinaryBytes, $false) } | + Should -Throw '*Atomic file destination collision*' + $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) From 2f4c6e2a5f187bc52230e9fd2137826cac2770de Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 09:35:12 -0400 Subject: [PATCH 69/79] fix: bind staging evidence to native ownership --- .build/GraphKitAuth.tasks.ps1 | 6 +- scripts/Invoke-GraphKitAuthParity.ps1 | 216 +++++++++++++++++- scripts/private/GraphKit.AuthStageCapture.cs | 225 ++++++++++++++++--- tests/QA/GraphKitAuthPackage.tests.ps1 | 132 +++++++++-- tests/Unit/Auth/GraphKitAuth.Tests.ps1 | 4 +- 5 files changed, 528 insertions(+), 55 deletions(-) diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1 index a4c98b4..fb85683 100644 --- a/.build/GraphKitAuth.tasks.ps1 +++ b/.build/GraphKitAuth.tasks.ps1 @@ -614,7 +614,8 @@ function Test-GraphKitAuthSealedPermission { if ($Evidence.OwnerWritable) { return $false } if ($IsWindows) { $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - return [string]$Evidence.OwnerSid -ceq $currentSid -and + 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 @@ -622,7 +623,8 @@ function Test-GraphKitAuthSealedPermission { ($Directory -or [bool]$Evidence.FileReadOnly) } $expected = if ($Directory) { 0x140 } else { 0x100 } # 0500 / 0400 - return [int]$Evidence.UnixMode -eq $expected + return [int]$Evidence.UnixMode -eq $expected -and + [uint32]$Evidence.OwnerUid -eq [uint32]$Evidence.EffectiveUid } function Test-GraphKitAuthOwnerOnlyWritableDirectory { diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 2bdafb0..727938b 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -33,7 +33,7 @@ $script:GraphKitAuthParityAdapterChecks = @( $script:GraphKitAuthParityExpectedPublicAbiSha256 = '5b808693dfcd58c1b8b8a093caa789d8b5f9ce87f1bc57c6a1d8628077efc1f1' $script:GraphKitAuthParityExpectedNativeSourceSha256 = - '7226425964255754dfb94307ce1422309ce35cf43812a1620eb466a49a850a0e' + 'c4132fbc857e8c96e741c6f0eda371f62ec59bd61c669573faaab18d998a3808' $script:GraphKitAuthParityNativeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB @@ -706,7 +706,213 @@ function Test-GraphKitAuthParityRetention { function Initialize-GraphKitAuthParityNative { if ($null -ne $script:GraphKitAuthParityNativeType) { return } $helperGzipBase64 = @' -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 +/w8A/PyyYBQBAA== '@ $compressed = [Convert]::FromBase64String(($helperGzipBase64 -replace '\s','')) $compressedStream = [IO.MemoryStream]::new($compressed, $false) @@ -814,14 +1020,16 @@ function Test-GraphKitAuthParitySealedPermission { if ([bool]$Evidence.OwnerWritable) { return $false } if ($IsWindows) { $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - return [string]$Evidence.OwnerSid -ceq $currentSid -and + 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 }) + return [int]$Evidence.UnixMode -eq $(if ($Directory) { 0x140 } else { 0x100 }) -and + [uint32]$Evidence.OwnerUid -eq [uint32]$Evidence.EffectiveUid } function Assert-GraphKitAuthParityPortableNameSet { diff --git a/scripts/private/GraphKit.AuthStageCapture.cs b/scripts/private/GraphKit.AuthStageCapture.cs index 9b29eb1..f028d20 100644 --- a/scripts/private/GraphKit.AuthStageCapture.cs +++ b/scripts/private/GraphKit.AuthStageCapture.cs @@ -19,6 +19,8 @@ public sealed class GraphKitAuthPathEvidence 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; } @@ -26,6 +28,7 @@ public sealed class GraphKitAuthPathEvidence 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; } @@ -63,7 +66,14 @@ public static class GraphKitAuthStageCapture 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) @@ -108,8 +118,8 @@ public static bool HasInitialOwnerOnlyAccess(GraphKitAuthPathEvidence evidence) return OperatingSystem.IsWindows() ? evidence.OwnerOnlyAccess && !string.IsNullOrWhiteSpace(evidence.OwnerSid) && - string.Equals(evidence.OwnerSid, evidence.CurrentIdentitySid, StringComparison.Ordinal) - : evidence.UnixMode == 0x180; + string.Equals(evidence.OwnerSid, evidence.CurrentOwnerSid, StringComparison.Ordinal) + : evidence.UnixMode == 0x180 && evidence.OwnerUid == evidence.EffectiveUid; } public static bool HasInitialOwnerOnlyDirectoryAccess(GraphKitAuthPathEvidence evidence) @@ -123,8 +133,9 @@ public static bool HasInitialOwnerOnlyDirectoryAccess(GraphKitAuthPathEvidence e evidence.OwnerOnlyAccess && evidence.ExactWritableOwnerOnlyDirectoryAccess && !string.IsNullOrWhiteSpace(evidence.OwnerSid) && - string.Equals(evidence.OwnerSid, evidence.CurrentIdentitySid, StringComparison.Ordinal) - : evidence.IsDirectory && evidence.UnixMode == 0x1C0; + string.Equals(evidence.OwnerSid, evidence.CurrentOwnerSid, StringComparison.Ordinal) + : evidence.IsDirectory && evidence.UnixMode == 0x1C0 && + evidence.OwnerUid == evidence.EffectiveUid; } public static GraphKitAuthPathEvidence CreateDirectoryOwnerOnly( @@ -145,12 +156,13 @@ public static GraphKitAuthPathEvidence CreateDirectoryOwnerOnly( if (OperatingSystem.IsWindows()) { DirectorySecurity security = new(); - SecurityIdentifier owner = WindowsIdentity.GetCurrent().User + 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( - owner, + currentIdentity, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, @@ -699,6 +711,8 @@ private static GraphKitAuthPathEvidence EvidenceFromHandle( Length = after.Length, LinkCount = after.LinkCount, UnixMode = after.UnixMode, + OwnerUid = after.OwnerUid, + EffectiveUid = after.EffectiveUid, PermissionEvidence = after.PermissionEvidence, IsDirectory = after.IsDirectory, IsRegularFile = after.IsRegularFile, @@ -706,6 +720,7 @@ private static GraphKitAuthPathEvidence EvidenceFromHandle( OwnerWritable = after.OwnerWritable, OwnerSid = after.OwnerSid, CurrentIdentitySid = after.CurrentIdentitySid, + CurrentOwnerSid = after.CurrentOwnerSid, AccessRulesProtected = after.AccessRulesProtected, HasInheritedAccessRules = after.HasInheritedAccessRules, OwnerOnlyAccess = after.OwnerOnlyAccess, @@ -837,12 +852,13 @@ private static FileStream OpenDestinationCreateNew( if (requireInitialOwnerOnly) { FileSecurity security = new(); - SecurityIdentifier owner = WindowsIdentity.GetCurrent().User + 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( - owner, + currentIdentity, FileSystemRights.FullControl, InheritanceFlags.None, PropagationFlags.None, @@ -951,10 +967,12 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) 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(path, directory); - return new NativeFacts(identity, physical, windowsLength, info.NumberOfLinks, 0, directory, + 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.AccessRulesProtected, + permissions.OwnerSid, permissions.CurrentIdentitySid, permissions.CurrentOwnerSid, + permissions.AccessRulesProtected, permissions.HasInheritedAccessRules, permissions.OwnerOnlyAccess, permissions.ExactOwnerOnlyAccess, permissions.ExactWritableOwnerOnlyDirectoryAccess, @@ -971,6 +989,7 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) ulong inode; ulong links; uint mode; + uint ownerUid; long length; if (OperatingSystem.IsMacOS()) { @@ -978,6 +997,7 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) 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() && @@ -989,6 +1009,7 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) 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() && @@ -998,6 +1019,7 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) 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 @@ -1010,6 +1032,7 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) 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( @@ -1018,12 +1041,14 @@ private static NativeFacts GetNativeFacts(SafeFileHandle handle, string path) length, checked((long)links), unixMode, + ownerUid, + effectiveUid, isDirectory, isRegular, isLink, (unixMode & 0x80) != 0, Convert.ToString(unixMode, 8).PadLeft(4, '0'), - string.Empty, string.Empty, false, false, false, false, false, false); + string.Empty, string.Empty, string.Empty, false, false, false, false, false, false); } private static string GetUnixPhysicalPath(string path, string expectedIdentity, bool directory) @@ -1168,13 +1193,87 @@ private static bool IsWindowsDriveRooted(string value) => value.Length >= 3 && char.IsAsciiLetter(value[0]) && value[1] == ':' && value[2] == '\\'; - private static WindowsPermissionFacts GetWindowsPermissionFacts(string path, bool directory) + private static SecurityIdentifier GetCurrentTokenOwnerSid() { - FileSystemSecurity security = directory - ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access | AccessControlSections.Owner) - : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access | AccessControlSections.Owner); - SecurityIdentifier current = WindowsIdentity.GetCurrent().User + 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 | @@ -1185,27 +1284,27 @@ private static WindowsPermissionFacts GetWindowsPermissionFacts(string path, boo FileSystemRights.ReadAndExecute | FileSystemRights.Synchronize; bool ownerWritable = false; bool hasInheritedAccessRules = false; - bool ownerOnlyAccess = owner.Equals(current) && rules.Count >= 1; + bool ownerOnlyAccess = owner.Equals(currentTokenOwner) && rules.Count >= 1; bool exactOwnerOnlyAccess = security.AreAccessRulesProtected && - owner.Equals(current) && rules.Count == 1; + owner.Equals(currentTokenOwner) && rules.Count == 1; bool exactWritableOwnerOnlyDirectoryAccess = directory && - security.AreAccessRulesProtected && owner.Equals(current) && rules.Count == 1; + security.AreAccessRulesProtected && owner.Equals(currentTokenOwner) && rules.Count == 1; foreach (FileSystemAccessRule rule in rules) { hasInheritedAccessRules |= rule.IsInherited; - ownerOnlyAccess &= rule.IdentityReference.Equals(current) && + ownerOnlyAccess &= rule.IdentityReference.Equals(currentIdentity) && rule.AccessControlType == AccessControlType.Allow; if (rule.AccessControlType == AccessControlType.Allow && (rule.FileSystemRights & writeMask) != 0) { ownerWritable = true; } - exactOwnerOnlyAccess &= rule.IdentityReference.Equals(current) && + 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(current) && + exactWritableOwnerOnlyDirectoryAccess &= rule.IdentityReference.Equals(currentIdentity) && !rule.IsInherited && rule.AccessControlType == AccessControlType.Allow && rule.FileSystemRights == FileSystemRights.FullControl && @@ -1213,12 +1312,13 @@ private static WindowsPermissionFacts GetWindowsPermissionFacts(string path, boo rule.PropagationFlags == PropagationFlags.None; } bool fileReadOnly = directory || - (File.GetAttributes(path) & FileAttributes.ReadOnly) == FileAttributes.ReadOnly; + (fileAttributes & FileAttributeReadOnly) == FileAttributeReadOnly; return new WindowsPermissionFacts( security.GetSecurityDescriptorSddlForm(AccessControlSections.Access | AccessControlSections.Owner), ownerWritable, owner.Value, - current.Value, + currentIdentity.Value, + currentTokenOwner.Value, security.AreAccessRulesProtected, hasInheritedAccessRules, ownerOnlyAccess, @@ -1236,12 +1336,11 @@ private static void SetOwnerOnlyWindows(string path, bool directory, bool writab new FileInfo(path), AccessControlSections.Owner); SecurityIdentifier currentOwner = (SecurityIdentifier)currentSecurity.GetOwner( typeof(SecurityIdentifier)); - SecurityIdentifier currentIdentity = WindowsIdentity.GetCurrent().User - ?? throw new IOException("The current Windows identity has no SID."); - if (!currentOwner.Equals(currentIdentity)) + SecurityIdentifier currentTokenOwner = GetCurrentTokenOwnerSid(); + if (!currentOwner.Equals(currentTokenOwner)) { throw new IOException( - $"Owner-only access refused for '{path}' because its owner is not the current Windows identity."); + $"Owner-only access refused for '{path}' because its owner is not the current Windows token owner."); } FileSystemSecurity security = CreateOwnerOnlyWindowsSecurity( directory, writable, setOwner: false); @@ -1272,8 +1371,9 @@ private static FileSystemSecurity CreateOwnerOnlyWindowsSecurity( bool writable, bool setOwner) { - WindowsIdentity identity = WindowsIdentity.GetCurrent(); - SecurityIdentifier owner = identity.User ?? throw new IOException("The current Windows identity has no SID."); + 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) { @@ -1284,7 +1384,7 @@ private static FileSystemSecurity CreateOwnerOnlyWindowsSecurity( ? FileSystemRights.FullControl : FileSystemRights.ReadAndExecute; InheritanceFlags inheritance = directory && writable ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None; - security.AddAccessRule(new FileSystemAccessRule(owner, rights, inheritance, + security.AddAccessRule(new FileSystemAccessRule(currentIdentity, rights, inheritance, PropagationFlags.None, AccessControlType.Allow)); return security; } @@ -1292,7 +1392,8 @@ private static FileSystemSecurity CreateOwnerOnlyWindowsSecurity( private sealed class WindowsPermissionFacts { internal WindowsPermissionFacts(string sddl, bool ownerWritable, string ownerSid, - string currentIdentitySid, bool accessRulesProtected, bool hasInheritedAccessRules, + string currentIdentitySid, string currentOwnerSid, bool accessRulesProtected, + bool hasInheritedAccessRules, bool ownerOnlyAccess, bool exactOwnerOnlyAccess, bool exactWritableOwnerOnlyDirectoryAccess, bool fileReadOnly) { @@ -1300,6 +1401,7 @@ internal WindowsPermissionFacts(string sddl, bool ownerWritable, string ownerSid OwnerWritable = ownerWritable; OwnerSid = ownerSid; CurrentIdentitySid = currentIdentitySid; + CurrentOwnerSid = currentOwnerSid; AccessRulesProtected = accessRulesProtected; HasInheritedAccessRules = hasInheritedAccessRules; OwnerOnlyAccess = ownerOnlyAccess; @@ -1311,6 +1413,7 @@ internal WindowsPermissionFacts(string sddl, bool ownerWritable, string ownerSid 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; } @@ -1319,12 +1422,24 @@ internal WindowsPermissionFacts(string sddl, bool ownerWritable, string ownerSid 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, bool isDirectory, bool isRegularFile, bool isReparsePoint, + int unixMode, uint ownerUid, uint effectiveUid, bool isDirectory, + bool isRegularFile, bool isReparsePoint, bool ownerWritable, string permissionEvidence, string ownerSid, - string currentIdentitySid, bool accessRulesProtected, bool hasInheritedAccessRules, + string currentIdentitySid, string currentOwnerSid, bool accessRulesProtected, + bool hasInheritedAccessRules, bool ownerOnlyAccess, bool exactOwnerOnlyAccess, bool exactWritableOwnerOnlyDirectoryAccess, bool fileReadOnly) { @@ -1333,6 +1448,8 @@ internal NativeFacts(string identity, string physicalPath, long length, long lin Length = length; LinkCount = linkCount; UnixMode = unixMode; + OwnerUid = ownerUid; + EffectiveUid = effectiveUid; IsDirectory = isDirectory; IsRegularFile = isRegularFile; IsReparsePoint = isReparsePoint; @@ -1340,6 +1457,7 @@ internal NativeFacts(string identity, string physicalPath, long length, long lin PermissionEvidence = permissionEvidence; OwnerSid = ownerSid; CurrentIdentitySid = currentIdentitySid; + CurrentOwnerSid = currentOwnerSid; AccessRulesProtected = accessRulesProtected; HasInheritedAccessRules = hasInheritedAccessRules; OwnerOnlyAccess = ownerOnlyAccess; @@ -1352,6 +1470,8 @@ internal NativeFacts(string identity, string physicalPath, long length, long lin 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; } @@ -1359,6 +1479,7 @@ internal NativeFacts(string identity, string physicalPath, long length, long lin 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; } @@ -1388,6 +1509,12 @@ private struct SecurityAttributes public int InheritHandle; } + [StructLayout(LayoutKind.Sequential)] + private struct TokenOwnerInformation + { + public IntPtr Owner; + } + [StructLayout(LayoutKind.Sequential)] private struct ByHandleFileInformation { @@ -1418,6 +1545,31 @@ private static extern SafeFileHandle CreateFileWithSecurityW(string fileName, ui [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, @@ -1441,6 +1593,9 @@ private static extern uint GetFinalPathNameByHandleW(SafeFileHandle file, String [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); diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 3eedb90..be465a5 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -1154,7 +1154,9 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $observed.Count | Should -Be 1 if ($IsWindows) { - $observed[0].OwnerSid | Should -BeExactly $observed[0].CurrentIdentitySid + $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 @@ -2213,7 +2215,9 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { if ($IsWindows) { $fixture.ManifestInitialEvidence.OwnerOnlyAccess | Should -BeTrue $fixture.ManifestInitialEvidence.OwnerSid | - Should -BeExactly $fixture.ManifestInitialEvidence.CurrentIdentitySid + Should -BeExactly $fixture.ManifestInitialEvidence.CurrentOwnerSid + $fixture.ManifestInitialEvidence.CurrentIdentitySid | Should -BeExactly ( + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value) } else { $fixture.ManifestInitialEvidence.UnixMode | Should -Be 0x180 @@ -2225,15 +2229,23 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } } - It 'does not call wrong-owner Windows evidence owner-only at initial creation' { + 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-222' + $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' { @@ -2407,7 +2419,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Get-Command Set-GraphKitAuthWindowsAclMutation -CommandType Function -ErrorAction Stop | Should -Not -BeNullOrEmpty foreach ($property in @( - 'OwnerSid', 'CurrentIdentitySid', 'AccessRulesProtected', + 'OwnerSid', 'CurrentIdentitySid', 'CurrentOwnerSid', 'AccessRulesProtected', 'HasInheritedAccessRules', 'ExactOwnerOnlyAccess' )) { $helper | Should -Match ([regex]::Escape($property + ' { get; init; }')) @@ -2434,6 +2446,21 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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 ( @@ -2444,7 +2471,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $pathSetter = [regex]::Match($helper, '(?ms)^ private static void SetOwnerOnlyWindows\(.*?(?=^ private )').Value $pathSetter | Should -Match ( - '(?s)if \(!currentOwner\.Equals\(currentIdentity\)\).*?throw new IOException') + '(?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*=' @@ -2522,7 +2549,31 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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 } @@ -2559,9 +2610,9 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $destination, 'ordinary.bin', $ordinaryBytes, $false) $copy.DestinationInitial.OwnerOnlyAccess | Should -BeTrue - $copy.DestinationInitial.OwnerSid | Should -BeExactly $currentSid.Value - $copy.DestinationInitial.CurrentIdentitySid | - Should -BeExactly $currentSid.Value + $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) | @@ -2571,9 +2622,60 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $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[]] @($raceOriginal, $false)) + try { + [IO.File]::Move($raceOriginal, $raceParked) + [IO.File]::Move($raceReplacement, $raceOriginal) + [IO.File]::SetAttributes($raceOriginal, [IO.FileAttributes]::ReadOnly) + $handleFacts = $getNativeFacts.Invoke( + $null, [object[]] @($raceHandle, $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' @@ -2589,7 +2691,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $false) $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $false) $sealed = $script:GraphKitAuthStageCaptureType::InspectFile($destination, 'candidate.dll') - $sealed.OwnerSid | Should -BeExactly $currentSid.Value + $sealed.OwnerSid | Should -BeExactly $sealed.CurrentOwnerSid + $sealed.CurrentIdentitySid | Should -BeExactly $currentSid.Value $sealed.ExactOwnerOnlyAccess | Should -BeTrue $sealed.OwnerWritable | Should -BeFalse $sealed.FileReadOnly | Should -BeTrue @@ -2598,7 +2701,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $true) $script:GraphKitAuthStageCaptureType::SetOwnerOnly($captured, $false, $true) $unsealed = $script:GraphKitAuthStageCaptureType::InspectFile($destination, 'candidate.dll') - $unsealed.OwnerSid | Should -BeExactly $currentSid.Value + $unsealed.OwnerSid | Should -BeExactly $unsealed.CurrentOwnerSid + $unsealed.CurrentIdentitySid | Should -BeExactly $currentSid.Value $unsealed.AccessRulesProtected | Should -BeTrue $unsealed.HasInheritedAccessRules | Should -BeFalse $unsealed.OwnerOnlyAccess | Should -BeTrue @@ -2642,7 +2746,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $ordinary = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew( $source, 'candidate.dll', $destination, 'candidate.dll', $false) $ordinary.DestinationInitial.OwnerOnlyAccess | Should -BeFalse - $ordinary.Destination.OwnerSid | Should -BeExactly $currentSid.Value + $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 @@ -2654,7 +2759,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Should -BeTrue $sealed.DestinationInitial.AccessRulesProtected | Should -BeTrue $sealed.DestinationInitial.HasInheritedAccessRules | Should -BeFalse - $sealed.Destination.OwnerSid | Should -BeExactly $currentSid.Value + $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 diff --git a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 index 4c390fe..c8d88da 100644 --- a/tests/Unit/Auth/GraphKitAuth.Tests.ps1 +++ b/tests/Unit/Auth/GraphKitAuth.Tests.ps1 @@ -2559,7 +2559,9 @@ Describe 'GraphKit.Auth ABI v1 contract' -Tag 'Unit' { $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\s+candidate' + $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' { From 42073647b72b2d8ca4b2deb311ce5633e38fdcf2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 12:05:32 -0400 Subject: [PATCH 70/79] feat: isolate GraphKit Auth parity verification --- .../plans/2026-08-30-r8-graphkit-auth.md | 42 +- scripts/Invoke-GraphKitAuthParity.ps1 | 1513 +++++++++++++++-- .../Invoke-GraphKitAuthParityWorker.ps1 | 266 +++ tests/QA/GraphKitAuthLiveParity.tests.ps1 | 1125 +++++++++++- 4 files changed, 2743 insertions(+), 203 deletions(-) create mode 100644 scripts/private/Invoke-GraphKitAuthParityWorker.ps1 diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md index f82284a..8c5bb73 100644 --- a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md +++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md @@ -15,8 +15,9 @@ 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, frozen at clean commit -`beceb22`). The remaining checkboxes are approval-gated and out of scope for deterministic +(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). @@ -998,6 +999,7 @@ service-behavior claim. **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` @@ -1169,16 +1171,40 @@ tests/Unit/Transport/Invoke-GraphRetry.Tests.ps1 - [x] **Step 1: Write and test a digest-bound protected runner** -The runner requires the exact package path and SHA-256, installs into an isolated module path, and -accepts one auth mode per invocation. 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. +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 `scripts/Invoke-GraphKitAuthParity.ps1` and +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** diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index 727938b..d2213e5 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -35,6 +35,7 @@ $script:GraphKitAuthParityExpectedPublicAbiSha256 = $script:GraphKitAuthParityExpectedNativeSourceSha256 = 'c4132fbc857e8c96e741c6f0eda371f62ec59bd61c669573faaab18d998a3808' $script:GraphKitAuthParityNativeType = $null +$script:GraphKitAuthParityProcessTreeType = $null $script:GraphKitAuthParityMaxEntries = 4096 $script:GraphKitAuthParityMaxPackageBytes = 512MB $script:GraphKitAuthParityMaxEntryBytes = 64MB @@ -44,6 +45,10 @@ $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) @@ -1989,10 +1994,1297 @@ function Invoke-GraphKitAuthParityLiveCore { } } +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 + $teardownAllowanceMilliseconds = [Math]::Min( + 10000L, [Math]::Max(2000L, [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) { @@ -2004,27 +3296,13 @@ $task8Execution = if ($PSCmdlet.ParameterSetName -ceq 'DryRun') { 'DryRun' } els $task8Record = New-GraphKitAuthParityModeRecord -Execution $task8Execution ` -Mode $AuthMode -StartedUtc $task8StartedUtc $task8State = $null -$task8ImportedModule = $null -$task8Imported = $null -$task8Context = $null -$task8ContextCommand = $null -$task8ContextResult = $null -$task8ContextParameters = $null -$task8ReadResult = $null -$task8ReadCommand = $null -$task8ReadResultRecords = $null -$task8GetContextAction = $null -$task8ReadAction = $null -$task8LiveCoreResult = $null $task8StorePathBound = $false -$task8Diagnostics = $null -$task8ProviderWeakReference = $null +$task8WorkerStarted = $false +$task8WorkerTreeExitConfirmed = $false +$task8WorkerTeardownFailed = $false $task8PrimaryFailed = $false $task8FailureStage = 'Artifact' $task8FailureCode = 'ArtifactRejected' -$task8HadModulePath = Test-Path -LiteralPath Env:PSModulePath -$task8SavedModulePath = if ($task8HadModulePath) { [string]$env:PSModulePath } else { $null } -$task8ModulePathChanged = $false try { if ([string]::IsNullOrWhiteSpace($PackagePath) -or @@ -2166,106 +3444,80 @@ try { $task8FailureStage = 'Import' $task8FailureCode = 'ImportRejected' - $env:PSModulePath = if ($task8HadModulePath -and - -not [string]::IsNullOrEmpty($task8SavedModulePath)) { - $task8State.ModuleRoot + [IO.Path]::PathSeparator + $task8SavedModulePath - } - else { $task8State.ModuleRoot } - $task8ModulePathChanged = $true Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name BeforeFinalImportRecheck ` -Arguments @($task8State) Assert-GraphKitAuthParityState -State $task8State -Purpose Import - $task8Imported = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { - Import-Module -Name $task8State.ExtractedManifestPath -PassThru -Force -ErrorAction Stop - } - $task8ImportedModule = $task8Imported[0] - $task8LocationComparison = if ($IsWindows) { - [StringComparison]::OrdinalIgnoreCase + $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 } - else { [StringComparison]::Ordinal } - if ($task8ImportedModule.Name -cne 'GraphKit' -or - -not [string]::Equals( - [IO.Path]::GetFullPath($task8ImportedModule.ModuleBase), - [IO.Path]::GetFullPath($task8State.ModuleRoot), - $task8LocationComparison) -or - -not [string]::Equals( - [IO.Path]::GetFullPath($task8ImportedModule.Path), - [IO.Path]::GetFullPath($task8State.ExtractedModulePath), - $task8LocationComparison) -or - "$($task8ImportedModule.Version)-$($task8ImportedModule.PrivateData.PSData.Prerelease)" -cne - $task8Record.moduleVersion) { - throw [InvalidOperationException]::new('The exact extracted GraphKit module was not imported.') - } - $task8State.ImportedManifestPath = $task8State.ExtractedManifestPath - $task8State.ImportedModulePath = $task8ImportedModule.Path - $task8Record.checks.exactImport = $true - Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name AfterImport ` - -Arguments @($task8State) - + $task8WorkerPath = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ` + 'private/Invoke-GraphKitAuthParityWorker.ps1')) $task8FailureStage = 'Diagnostics' $task8FailureCode = 'DiagnosticsRejected' - $task8Diagnostics = Get-GraphKitAuthParityDiagnostics ` - -Module $task8ImportedModule -State $task8State - $task8ProviderWeakReference = $task8Diagnostics.ProviderWeakReference - foreach ($property in $task8Diagnostics.Checks.PSObject.Properties) { + $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 } - if (@($task8Record.adapter.PSObject.Properties.Value | Where-Object { - -not [bool]$_ - }).Count -ne 0) { - throw [InvalidOperationException]::new('The GraphKit.Auth adapter diagnostics were rejected.') - } - - if ($task8Execution -ceq 'Live') { - $task8StorePathBound = $PSBoundParameters.ContainsKey('StorePath') - Invoke-GraphKitAuthParityHook -Hooks $task8Hooks -Name PrepareLiveModule ` - -Arguments @( - $task8ImportedModule, $task8State, $task8Route, $ProfileId, - $(if ($task8StorePathBound) { $StorePath } else { $null }), - $task8StorePathBound) - Assert-GraphKitAuthParityState -State $task8State -Purpose Import - $task8ContextCommand = @(Get-Command -Name Get-GraphContext -Module GraphKit ` - -CommandType Function -ErrorAction Stop) - $task8ReadCommand = @(Get-Command -Name Get-GraphObject -Module GraphKit ` - -CommandType Function -ErrorAction Stop) - if ($task8ContextCommand.Count -ne 1 -or - -not [object]::ReferenceEquals($task8ContextCommand[0].Module, $task8ImportedModule) -or - $task8ReadCommand.Count -ne 1 -or - -not [object]::ReferenceEquals($task8ReadCommand[0].Module, $task8ImportedModule)) { - throw [InvalidOperationException]::new('The exact public live commands were not found.') - } - $task8GetContextAction = { - param($requestedProfileId, $requestedStorePath, $route) - $parameters = @{ ProfileId = $requestedProfileId; ErrorAction = 'Stop' } - if ($task8StorePathBound) { $parameters.StorePath = $requestedStorePath } - $records = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { - & $task8ContextCommand[0] @parameters - } - return $records[0] - }.GetNewClosure() - $task8ReadAction = { - param($context, $type, $operation, $passThruResult) - $records = Invoke-GraphKitAuthParityCaptured -ExpectedCount 1 -Action { - & $task8ReadCommand[0] -Context $context -Type $type ` - -Operation $operation -PassThruResult:$passThruResult -ErrorAction Stop - } - return $records[0] - }.GetNewClosure() - $task8LiveCoreResult = Invoke-GraphKitAuthParityLiveCore -Route $task8Route ` - -Diagnostics $task8Diagnostics -ProfileId $ProfileId -StorePath $StorePath ` - -StorePathBound:$task8StorePathBound -GetContextAction $task8GetContextAction ` - -ReadAction $task8ReadAction - $task8Record.checks.contextMatched = [bool]$task8LiveCoreResult.contextMatched - $task8Record.checks.sourceMatched = [bool]$task8LiveCoreResult.sourceMatched - $task8Record.checks.tenantProofVerified = [bool]$task8LiveCoreResult.tenantProofVerified - $task8Record.read.attempted = [bool]$task8LiveCoreResult.readAttempted - $task8Record.read.succeeded = [bool]$task8LiveCoreResult.readSucceeded - $task8Record.read.rowCount = [long]$task8LiveCoreResult.rowCount - if ($task8LiveCoreResult.state -cne 'Passed') { - $task8FailureStage = [string]$task8LiveCoreResult.failureStage - $task8FailureCode = [string]$task8LiveCoreResult.failureCode - throw [InvalidOperationException]::new('The protected parity live core was rejected.') - } + $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 { @@ -2274,45 +3526,10 @@ catch { -Stage $task8FailureStage -Code $task8FailureCode } finally { - $task8LiveCoreResult = $null - $task8GetContextAction = $null - $task8ReadAction = $null - $task8ReadResult = $null - $task8ReadResultRecords = $null - $task8ReadCommand = $null - $task8Context = $null - $task8ContextResult = $null - $task8ContextCommand = $null - $task8ContextParameters = $null - $task8Diagnostics = $null - $task8Imported = $null - $task8CleanupFailed = $false - if ($null -ne $task8ImportedModule) { - try { - $null = Invoke-GraphKitAuthParityCaptured -ExpectedCount 0 -Action { - Remove-Module -ModuleInfo $task8ImportedModule -Force -ErrorAction Stop - } - } - catch { $task8CleanupFailed = $true } - $task8ImportedModule = $null - } - if ($null -ne $task8ProviderWeakReference) { - for ($task8GcAttempt = 0; - $task8GcAttempt -lt 30 -and $task8ProviderWeakReference.IsAlive; - $task8GcAttempt++) { - [GC]::Collect() - [GC]::WaitForPendingFinalizers() - [GC]::Collect() - } - if ($task8ProviderWeakReference.IsAlive) { $task8CleanupFailed = $true } - $task8ProviderWeakReference = $null - } - if ($task8ModulePathChanged) { - if ($task8HadModulePath) { $env:PSModulePath = $task8SavedModulePath } - else { Remove-Item -LiteralPath Env:PSModulePath -ErrorAction SilentlyContinue } - $task8ModulePathChanged = $false - } - if ($null -ne $task8State) { + $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) 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/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 9a69130..037b5ba 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -127,6 +127,8 @@ $task8EvidenceMutationCases = @( 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 { @@ -293,12 +295,72 @@ public static class GraphKitTask8SparseFileFixtureV1 [string] $StorePath, [string] $HookKind = 'None', [string] $MutationValue = '', - [switch] $OrdinaryExecution + [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, @@ -311,11 +373,16 @@ param( [Parameter(Mandatory)][string] $HookKind, [string] $MutationValue, [Parameter(Mandatory)][string] $OrdinaryExecutionText, - [Parameter(Mandatory)][string] $TracePath + [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 @@ -328,6 +395,38 @@ function Write-Task8Trace { [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) { @@ -408,9 +507,56 @@ function Move-Task8FixtureDirectoryIdentityPreservingChildren { 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"; @@ -447,6 +593,24 @@ public class GraphKitTask8TokenSourceProxy : DispatchProxy } "@ +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) + [GraphKitTask8HardLinkFixture]::Create($LinkPath, $targetPath) + $evidenceType = $State.RootEvidence.GetType() + $nativeType = $evidenceType.Assembly.GetType( + $evidenceType.Namespace + '.GraphKitAuthStageCapture', $true, $false) + $linked = $nativeType::InspectFile($State.RootPath, $RelativePath) + if ([long]$linked.LinkCount -ne 2) { + throw 'The native fixture did not establish an exact two-link file.' + } +} + function New-Task8SourceProxy { param( [Parameter(Mandatory)][string] $Mode, @@ -507,11 +671,169 @@ $hooks.AfterExtraction = { $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 @@ -616,7 +938,7 @@ switch ($HookKind) { } }.GetNewClosure() } - 'OutsideSentinel' { + { $_ -cin @('OutsideSentinel','WorkerGrandchild','WorkerSessionEscape') } { $hooks.AfterRootCreated = { param($state) Write-Task8Trace -Event 'root-created' -Data @{ root = [string] $state.RootPath } @@ -626,6 +948,71 @@ switch ($HookKind) { 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 { $child.Kill($true) } 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) @@ -665,7 +1052,8 @@ switch ($HookKind) { 'FinalImportHardLinkMutation' { $outside = Join-Path (Split-Path $state.RootPath -Parent) ( 'graphkit-task8-link-target-' + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType HardLink -Path $outside -Target $path -ErrorAction Stop + New-Task8FixtureHardLink -State $state ` + -RelativePath 'module/GraphKit.psm1' -LinkPath $outside Write-Task8Trace -Event 'mutation-outside-created' -Data @{ path = $outside } } } @@ -699,7 +1087,8 @@ switch ($HookKind) { 'CleanupHardLinkMutation' { $outside = Join-Path (Split-Path $state.RootPath -Parent) ( 'graphkit-task8-link-target-' + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType HardLink -Path $outside -Target $path -ErrorAction Stop + New-Task8FixtureHardLink -State $state ` + -RelativePath 'module/GraphKit.psm1' -LinkPath $outside Write-Task8Trace -Event 'mutation-outside-created' -Data @{ path = $outside } } } @@ -1171,7 +1560,100 @@ try { $parameters.ProfileId = $ProfileId if (-not [string]::IsNullOrEmpty($StorePath)) { $parameters.StorePath = $StorePath } } - if ($HookKind -like 'Live*') { + 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) { @@ -1194,11 +1676,12 @@ try { 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 leave one contracts assembly for the test core.' + 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) @@ -1214,8 +1697,17 @@ try { else { [AppDomain]::CurrentDomain.SetData( 'GraphKit.Task8.ParityTestHooks/1', [pscustomobject] $hooks) - if ($UseOrdinaryExecution) { & $RunnerPath @parameters } - else { . $RunnerPath @parameters } + 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 { @@ -1253,13 +1745,16 @@ finally { [AppDomain]::CurrentDomain.SetData($script:task8PackageLiveHolderKey, $null) $script:task8PackageLiveHolderKey = $null } - $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 (-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 @@ -1288,7 +1783,9 @@ finally { '-HookKind',$HookKind, '-MutationValue',$MutationValue, '-OrdinaryExecutionText',([string][bool] $OrdinaryExecution).ToLowerInvariant(), - '-TracePath',$tracePath + '-RepeatCount',([string] $Repeat), + '-TracePath',$tracePath, + '-GrandchildPath',$grandchildPath )) { $null = $startInfo.ArgumentList.Add([string] $argument) } @@ -1314,24 +1811,39 @@ finally { } $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() - $outputLines = @($stdout -split "`r?`n" | Where-Object { - -not [string]::IsNullOrWhiteSpace($_) - }) - $parsed = $null - $jsonCount = 0 - if ($outputLines.Count -eq 1) { + $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 { - $parsed = ConvertFrom-Task8JsonText -Json $outputLines[0] - $jsonCount = 1 + $parsedRecords += ConvertFrom-Task8JsonText -Json $line + } + catch { + $parseFailed = $true + $parsedRecords = @() + break } - catch { $parsed = $null } } + $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 @@ -1753,6 +2265,7 @@ finally { 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' { @@ -1767,6 +2280,42 @@ Describe 'Task 8 protected GraphKit.Auth parity runner contract' { [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' @@ -1782,6 +2331,27 @@ Describe 'Task 8 protected GraphKit.Auth parity runner contract' { $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( @@ -1893,14 +2463,6 @@ Describe 'Task 8 protected GraphKit.Auth parity runner contract' { } It 'contains no provisioning, mutation, installation, Graph SDK, or Azure command in its AST' { - if (-not (Test-Path -LiteralPath $script:runnerPath -PathType Leaf)) { - throw 'Task 8 runner AST is not implemented.' - } - $tokens = $null - $errors = $null - $ast = [Management.Automation.Language.Parser]::ParseFile( - $script:runnerPath, [ref] $tokens, [ref] $errors) - @($errors).Count | Should -Be 0 $forbidden = @( 'New-Ivy24LabApp','New-ClientServicePrincipalCBA','Register-GraphTenant', 'Remove-GraphTenant','Set-Secret','Remove-Secret','Register-SecretVault', @@ -1919,10 +2481,21 @@ Describe 'Task 8 protected GraphKit.Auth parity runner contract' { 'New-AzResourceGroup','Remove-AzResourceGroup','New-AzUserAssignedIdentity', 'Remove-AzUserAssignedIdentity','New-AzContainerGroup','Remove-AzContainerGroup','az' ) - $commands = @($ast.FindAll({ - param($node) - $node -is [Management.Automation.Language.CommandAst] - }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $null -ne $_ }) + $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 } @@ -2734,25 +3307,59 @@ Describe 'Task 8 isolated import, routing, and cleanup' { } } - It 'restores PSModulePath, removes GraphKit, deletes only its exact root, and preserves a sibling' { + 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 + -HookKind OutsideSentinel -Repeat 2 $trace = Get-Task8TraceRecords $result.TracePath - $root = [string] ($trace | Where-Object event -eq 'root-created').data.root - $outside = [string] ($trace | Where-Object event -eq 'outside-created').data.path + $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.Data.state | Should -BeExactly 'Passed' - (Test-Path -LiteralPath $root) | Should -BeFalse - (Test-Path -LiteralPath $outside -PathType Leaf) | Should -BeTrue + $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 { - if (Test-Path -LiteralPath $outside -PathType Leaf) { - Remove-Item -LiteralPath $outside -Force + foreach ($record in $outside) { + $outsidePath = [string]$record.data.path + if (Test-Path -LiteralPath $outsidePath -PathType Leaf) { + Remove-Item -LiteralPath $outsidePath -Force + } } } @@ -2765,6 +3372,150 @@ Describe 'Task 8 isolated import, routing, and cleanup' { 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 + } + } + } } } @@ -3041,9 +3792,68 @@ Describe 'Task 8 evidence schema and stream guard' { $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 ` @@ -3053,6 +3863,227 @@ Describe 'Task 8 evidence schema and stream guard' { $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 5000 + [long]$noReadExit[0].data.elapsedMilliseconds | + Should -BeGreaterOrEqual ( + [long]$noReadExit[0].data.operationDeadlineMilliseconds) + [long]$noReadExit[0].data.elapsedMilliseconds | Should -BeLessOrEqual 7000 + $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' { From e554a6ead58251794a5a6fb9d45e88a3e0ee828f Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 12:34:00 -0400 Subject: [PATCH 71/79] test: await Task 8 fixture termination --- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 037b5ba..6cc6ecd 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -1000,7 +1000,11 @@ switch ($HookKind) { [string]$ready.heldPath -cne $heldPath -or $ready.escapedSession.GetType() -ne [bool] -or [bool]$ready.escapedSession -ne $expectedEscape) { - try { $child.Kill($true) } catch {} + 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 @{ From 4d345f324ff742f6d55d29af400d82e4b86b3afb Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 12:56:42 -0400 Subject: [PATCH 72/79] test: tolerate loaded source proof startup --- tests/QA/TrainVersion.tests.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/QA/TrainVersion.tests.ps1 b/tests/QA/TrainVersion.tests.ps1 index 7ea2a09..89295d6 100644 --- a/tests/QA/TrainVersion.tests.ps1 +++ b/tests/QA/TrainVersion.tests.ps1 @@ -1111,7 +1111,10 @@ $source & /usr/bin/mkfifo $fifo } - $result = Get-R8TrainVersionWithTimeout -RepositoryRoot $root -TimeoutMilliseconds 3000 + # 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 From b709f233bf8b22ff4f3951574bf4924a99c34213 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 13:23:28 -0400 Subject: [PATCH 73/79] fix: allow bounded worker teardown proof --- scripts/Invoke-GraphKitAuthParity.ps1 | 5 ++++- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/Invoke-GraphKitAuthParity.ps1 b/scripts/Invoke-GraphKitAuthParity.ps1 index d2213e5..9963a1b 100644 --- a/scripts/Invoke-GraphKitAuthParity.ps1 +++ b/scripts/Invoke-GraphKitAuthParity.ps1 @@ -2918,8 +2918,11 @@ function Invoke-GraphKitAuthParityWorkerProcess { $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(2000L, [long]($operationDeadlineMilliseconds / 4L))) + 10000L, [Math]::Max(5000L, [long]($operationDeadlineMilliseconds / 4L))) $hardDeadlineMilliseconds = $operationDeadlineMilliseconds + $teardownAllowanceMilliseconds $workerResult = $null diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 6cc6ecd..e0a73a1 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -3960,11 +3960,11 @@ Describe 'Task 8 evidence schema and stream guard' { $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 5000 + [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 7000 + [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])) From 36bc95d60318af8918c1413fa0a40f3e538b107f Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 13:40:20 -0400 Subject: [PATCH 74/79] test: separate runspace startup bounds --- .../GraphKitAuthRunspace.Tests.ps1 | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 index 5331500..4c3c297 100644 --- a/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 +++ b/tests/Concurrency/GraphKitAuthRunspace.Tests.ps1 @@ -1,5 +1,8 @@ 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 @@ -303,9 +306,7 @@ public sealed class Task7OfflineHandler : HttpMessageHandler $source = $holder.Source $holder.ObservedSources.Enqueue([object] $source) $null = $holder.Ready.Signal() - if (-not $holder.Go.Wait(5000)) { - throw 'Task 7 fixed-bearer child did not receive the parent release gate.' - } + $holder.Go.Wait() $result = $source.Acquire($false, [Threading.CancellationToken]::None) $holder.Results.Enqueue([object] $result) @@ -434,9 +435,7 @@ public sealed class Task7OfflineHandler : HttpMessageHandler $source = $holder.Sources[$ContextIndex] $holder.ObservedSources.Enqueue([object] $source) $null = $holder.Ready.Signal() - if (-not $holder.Go.Wait(5000)) { - throw 'Task 7 controlled child did not receive the parent release gate.' - } + $holder.Go.Wait() $transport = & $module { param($Context, $Source, $Client, [bool] $ForceRefresh, [int] $RequestIndex) $clientFactory = { @@ -589,9 +588,7 @@ public sealed class Task7OfflineHandler : HttpMessageHandler $source = $holder.Source $holder.ObservedSources.Enqueue([object] $source) $null = $holder.Ready.Signal() - if (-not $holder.Go.Wait(5000)) { - throw 'Task 7 legacy child did not receive the parent release gate.' - } + $holder.Go.Wait() $caught = $null try { $null = & $module { @@ -846,7 +843,7 @@ Describe 'GraphKit.Auth exact parent-source thread-runspace use' -Tag Concurrenc -ArgumentList $script:BuiltManifest, $holderKey } ) - $ready.Wait(5000) | Should -BeTrue + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue $go.Set() $outcomes = Complete-Task7ChildJobs -Jobs $jobs -ExpectedCount 2 @($outcomes | Where-Object { -not $_.Success }).Count | Should -Be 0 @@ -929,7 +926,7 @@ Describe 'GraphKit.Auth exact parent-source thread-runspace use' -Tag Concurrenc Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` -ArgumentList $script:BuiltManifest, $holderKey, 1, $false ) - $ready.Wait(5000) | Should -BeTrue + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue $go.Set() $entered.Wait(5000) | Should -BeTrue $release.Set() @@ -1054,7 +1051,7 @@ Describe 'GraphKit.Auth exact parent-source thread-runspace use' -Tag Concurrenc Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` -ArgumentList $script:BuiltManifest, $holderKey, 1, $false ) - $ready.Wait(5000) | Should -BeTrue + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue $go.Set() $entered.Wait(5000) | Should -BeTrue $followerObserved = [Threading.SpinWait]::SpinUntil( @@ -1156,7 +1153,7 @@ Describe 'GraphKit.Auth exact parent-source thread-runspace use' -Tag Concurrenc Start-ThreadJob -ScriptBlock $script:ControlledSenderChild ` -ArgumentList $script:BuiltManifest, $holderKey, 1, $true ) - $ready.Wait(5000) | Should -BeTrue + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue $go.Set() $entered.Wait(5000) | Should -BeTrue $ordinarySnapshot = Get-Task7OuterFlightSnapshot ` @@ -1315,7 +1312,7 @@ Describe 'GraphKit.Auth exact parent-source thread-runspace use' -Tag Concurrenc Start-ThreadJob -ScriptBlock $script:LegacyContainmentChild ` -ArgumentList $script:BuiltManifest, $holderKey ) - $ready.Wait(5000) | Should -BeTrue + $ready.Wait($script:Task7ThreadJobReadyTimeoutMilliseconds) | Should -BeTrue $go.Set() $outcomes = Complete-Task7ChildJobs -Jobs $jobs -ExpectedCount 1 $outcomes[0].Success | Should -BeTrue From ed7f9898c1a285efc9bd89280128c9fb8f720bf8 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 15:02:11 -0400 Subject: [PATCH 75/79] test: harden Windows hostile-link fixtures --- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 63 +++++- tests/QA/GraphKitAuthPackage.tests.ps1 | 255 ++++++++++++++++++++-- 2 files changed, 292 insertions(+), 26 deletions(-) diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index e0a73a1..4649689 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -601,13 +601,68 @@ function New-Task8FixtureHardLink { ) $targetPath = Join-Path $State.RootPath ( $RelativePath -replace '/', [IO.Path]::DirectorySeparatorChar) - [GraphKitTask8HardLinkFixture]::Create($LinkPath, $targetPath) $evidenceType = $State.RootEvidence.GetType() $nativeType = $evidenceType.Assembly.GetType( $evidenceType.Namespace + '.GraphKitAuthStageCapture', $true, $false) - $linked = $nativeType::InspectFile($State.RootPath, $RelativePath) - if ([long]$linked.LinkCount -ne 2) { - throw 'The native fixture did not establish an exact two-link file.' + $failures = [Collections.Generic.List[Exception]]::new() + $sourceDirectory = $null + $linkCreated = $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) { + try { + [GraphKitTask8HardLinkFixture]::Create($LinkPath, $targetPath) + $linkCreated = $true + } + 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) { + 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()) } } diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index be465a5..6a4590f 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -68,6 +68,57 @@ $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' @@ -159,6 +210,81 @@ BeforeAll { (($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 + if ($isLink) { + $parent = [IO.Path]::GetDirectoryName($path) + if ($IsWindows) { + Set-GraphKitAuthTestWindowsPathWritable -Path $parent -Directory $true + if (-not [string]::IsNullOrWhiteSpace($restorePath)) { + Set-GraphKitAuthTestWindowsPathWritable -Path $path -Directory $false + } + } + 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 @@ -172,8 +298,12 @@ BeforeAll { 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) { - if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $item)) { continue } $directory = [bool] $item.PSIsContainer if ($IsWindows) { Set-GraphKitAuthTestWindowsPathWritable ` @@ -233,13 +363,10 @@ BeforeAll { [Security.AccessControl.FileSecurity]::new() } $security.SetAccessRuleProtection($true, $false) - $inheritance = if ($Directory) { - [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor - [Security.AccessControl.InheritanceFlags]::ObjectInherit - } - else { - [Security.AccessControl.InheritanceFlags]::None - } + # 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, @@ -295,13 +422,19 @@ BeforeAll { 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)) { return } + 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 SilentlyContinue | + 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) { - if (-not (Test-GraphKitAuthTestAclMutationSafe -Item $item)) { continue } $directory = [bool] $item.PSIsContainer if ($IsWindows) { Set-GraphKitAuthTestWindowsPathWritable ` @@ -325,7 +458,13 @@ BeforeAll { } function Invoke-GraphKitAuthStageMutation { - param([string] $Kind, [string] $StagePath) + 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' @@ -344,12 +483,54 @@ BeforeAll { } 'hard-link' { $outsideLink = Join-Path $TestDrive ('GraphKit.Auth.hardlink-' + [guid]::NewGuid().ToString('N') + '.dll') - $null = New-Item -ItemType HardLink -Path $outsideLink -Target $targetPath -ErrorAction Stop + $linkArtifact = [pscustomobject]@{ + Path = $outsideLink; Directory = $false; Link = $true + RestorePath = $targetPath; 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') - [IO.File]::WriteAllText($outsidePath, 'outside'); [IO.File]::Delete($targetPath) - $null = New-Item -ItemType SymbolicLink -Path $targetPath -Target $outsidePath -ErrorAction Stop + $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; 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')) @@ -376,13 +557,25 @@ BeforeAll { } 'platform-directory-alias' { $outsidePayload = Join-Path $TestDrive ('payload-alias-target-' + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType Directory -Path $outsidePayload + $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; 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'." } } @@ -799,8 +992,10 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { @{ 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 + 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 } @@ -808,6 +1003,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $failure | Should -Not -Match 'version, envelope, or manifest is writable' } finally { + Remove-GraphKitAuthTestMutationArtifacts -Artifacts $cleanupArtifacts Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath } } @@ -829,17 +1025,28 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { 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') - $null = New-Item -ItemType HardLink -Path $outsideLink -Target $manifestPath -ErrorAction Stop + $linkArtifact = [pscustomobject]@{ + Path = $outsideLink; Directory = $false; Link = $true + RestorePath = $manifestPath; 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 Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath } } @@ -2003,7 +2210,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $targetPath, [IO.UnixFileMode]::UserRead) $targetUnixModeBefore = [IO.File]::GetUnixFileMode($targetPath) } - Set-GraphKitAuthTestTreeWritable -Path $linkSafetyRoot + { Set-GraphKitAuthTestTreeWritable -Path $linkSafetyRoot } | + Should -Throw '*refused a link or reparse entry*' if ($IsWindows) { (Get-Acl -LiteralPath $targetPath).Sddl | Should -BeExactly $targetAclBefore @@ -2192,7 +2400,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $null = New-Item -ItemType Junction -Path $linkPath -Target $external try { { New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput -FullVersion "0.4.0-r8.fixture.junction-$RootKind" ` - -PayloadSourceRoot (Join-Path $script:stagePath 'payload') } | Should -Throw '*without following*' + -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 @@ -2652,13 +2861,15 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $openReadNoFollow | Should -Not -BeNullOrEmpty $getNativeFacts | Should -Not -BeNullOrEmpty $raceHandle = $openReadNoFollow.Invoke( - $null, [object[]] @($raceOriginal, $false)) + $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[]] @($raceHandle, $raceOriginal)) + $null, [object[]] @( + [Microsoft.Win32.SafeHandles.SafeFileHandle] $raceHandle, + [string] $raceOriginal)) $factsType = $handleFacts.GetType() $instanceNonPublic = [Reflection.BindingFlags]'Instance, NonPublic' $factsType.GetProperty('Identity', $instanceNonPublic).GetValue($handleFacts) | From 3914579b96311d464063ca850667f42f100b4b05 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 16:00:14 -0400 Subject: [PATCH 76/79] test: finish Windows fixture cleanup --- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 20 +++- tests/QA/GraphKitAuthPackage.tests.ps1 | 136 ++++++++++++++++------ 2 files changed, 116 insertions(+), 40 deletions(-) diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 4649689..2e0341b 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -607,12 +607,20 @@ function New-Task8FixtureHardLink { $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) @@ -620,6 +628,10 @@ function New-Task8FixtureHardLink { } 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) } @@ -651,10 +663,10 @@ function New-Task8FixtureHardLink { } try { [IO.File]::Delete($LinkPath) } catch { $failures.Add($_.Exception) | Out-Null } - if ($IsWindows) { - try { $nativeType::SetOwnerOnly($targetPath, $false, $false) } - 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) } diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 6a4590f..62f46e4 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -955,7 +955,15 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { (Get-FileHash -LiteralPath (Join-Path $first.StagePath 'manifest.json') -Algorithm SHA256).Hash | Should -BeExactly $before } finally { - Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput | Out-Null + try { + Invoke-GraphKitAuthPrepareClean -OutputRoot $fixtureOutput | Out-Null + } + finally { + if ([IO.Directory]::Exists($fixtureOutput)) { + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + [IO.Directory]::Delete($fixtureOutput, $true) + } + } } } @@ -2397,8 +2405,11 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Join-Path $authRoot 'stage' } } - $null = New-Item -ItemType Junction -Path $linkPath -Target $external + $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*' @@ -2409,9 +2420,16 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { 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 + 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) + } } } } @@ -2988,20 +3006,39 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { It 'rejects a sealed stage after Windows ACL mutation' -ForEach $windowsAclMutationCases -AllowNullOrEmptyForEach { $fixture = New-GraphKitAuthStageFixture -Name ('windows-acl-' + $Kind.Replace(' ', '-')) - Set-GraphKitAuthWindowsAclMutation -StagePath $fixture.StagePath -Kind $Kind - { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | - Should -Throw + $fixtureOutput = Split-Path ( + Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) -Parent + try { + Set-GraphKitAuthWindowsAclMutation -StagePath $fixture.StagePath -Kind $Kind + { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | + Should -Throw + } + finally { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + [IO.Directory]::Delete($fixtureOutput, $true) + } } 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' - $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 + $fixtureOutput = Split-Path ( + Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) -Parent + 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 { + Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput + [IO.Directory]::Delete($fixtureOutput, $true) + } } It 'rejects a projected file after without deleting it' -ForEach @( @@ -3019,32 +3056,59 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { { 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')) - $null = New-Item -ItemType Directory -Path ( - Join-Path $physicalRepository 'source'), (Join-Path $physicalRepository 'destination') -Force - $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) ` - -Path $aliasAncestor -Target $physicalAncestor -ErrorAction Stop - $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')) - $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) ` - -Path $repositoryAlias -Target $physicalRepository -ErrorAction Stop - { Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $repositoryAlias ` - -RelativePath 'destination/candidate.dll' -Expected $aliasCopy.Destination } | - Should -Throw -Because 'the repository root itself must remain one no-follow directory' + 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 = ''; 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 = ''; 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) { From 6262e5e52df105df7bc85d93c6a77f02061938f2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 17:49:47 -0400 Subject: [PATCH 77/79] test: harden sealed fixture teardown --- tests/QA/GraphKitAuthLiveParity.tests.ps1 | 80 +++++++++++--- tests/QA/GraphKitAuthPackage.tests.ps1 | 129 +++++++++++++++++----- 2 files changed, 169 insertions(+), 40 deletions(-) diff --git a/tests/QA/GraphKitAuthLiveParity.tests.ps1 b/tests/QA/GraphKitAuthLiveParity.tests.ps1 index 2e0341b..3bc0827 100644 --- a/tests/QA/GraphKitAuthLiveParity.tests.ps1 +++ b/tests/QA/GraphKitAuthLiveParity.tests.ps1 @@ -2046,29 +2046,59 @@ finally { $Invocation.Data.failureCode | Should -BeExactly $Code } - function Remove-Task8ResidualFixturePath { + function Resolve-Task8ResidualFixturePath { param([AllowNull()][string] $Path) - if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) { return } + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } $full = [IO.Path]::GetFullPath($Path) - $temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) - if (-not $full.StartsWith($temp, [StringComparison]::Ordinal) -or + $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 (-not [string]::IsNullOrEmpty([string] $rootItem.LinkType) -or + 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 } - $paths = @( - Get-ChildItem -LiteralPath $full -Recurse -Force -ErrorAction SilentlyContinue | - Sort-Object { $_.FullName.Length } -Descending - ) + @($rootItem) + 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 [string]::IsNullOrEmpty([string] $item.LinkType) -or + if ((-not $OwnedHardLink -and + -not [string]::IsNullOrEmpty([string] $item.LinkType)) -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue } @@ -2116,7 +2146,8 @@ finally { } else { foreach ($item in $paths) { - if (-not [string]::IsNullOrEmpty([string] $item.LinkType) -or + if ((-not $OwnedHardLink -and + -not [string]::IsNullOrEmpty([string] $item.LinkType)) -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue } @@ -2138,6 +2169,25 @@ finally { 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', @@ -3187,8 +3237,8 @@ Describe 'Task 8 isolated import, routing, and cleanup' { (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue } finally { - Remove-Task8ResidualFixturePath -Path $root - Remove-Task8ResidualFixturePath -Path $outside + Remove-Task8OwnedHardLinkFixtureTree ` + -OutsidePath $outside -RootPath $root } if ($null -ne $outside) { (Test-Path -LiteralPath $outside) | Should -BeFalse @@ -3234,8 +3284,8 @@ Describe 'Task 8 isolated import, routing, and cleanup' { (Test-Path -LiteralPath $root -PathType Container) | Should -BeTrue } finally { - Remove-Task8ResidualFixturePath -Path $root - Remove-Task8ResidualFixturePath -Path $outside + Remove-Task8OwnedHardLinkFixtureTree ` + -OutsidePath $outside -RootPath $root } if ($null -ne $outside) { (Test-Path -LiteralPath $outside) | Should -BeFalse diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 62f46e4..1c6bd03 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -452,9 +452,99 @@ public static class GraphKitAuthPackageLinkFixture 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')) - New-GraphKitAuthSealedStage -OutputRoot $fixtureOutput ` - -FullVersion ("0.4.0-r8.fixture.$Name." + [guid]::NewGuid().ToString('N')) ` - -PayloadSourceRoot (Join-Path $script:stagePath 'payload') + 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 { @@ -975,13 +1065,13 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { [IO.File]::WriteAllText($manifestPath, '{"forged":true}') Set-GraphKitAuthTestStageSealed -StagePath $fixture.StagePath - { Invoke-GraphKitAuthPrepareClean -OutputRoot (Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) } | + { 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 { - Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } @@ -1012,7 +1102,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } finally { Remove-GraphKitAuthTestMutationArtifacts -Artifacts $cleanupArtifacts - Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } @@ -1026,8 +1116,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Should -Not -Throw } finally { - Invoke-GraphKitAuthPrepareClean -OutputRoot ( - Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) | Out-Null + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } @@ -1055,7 +1144,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } finally { Remove-GraphKitAuthTestMutationArtifacts -Artifacts $cleanupArtifacts - Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } @@ -2028,8 +2117,8 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { @{ RootKind = 'stage' } ) { $fixture = New-GraphKitAuthStageFixture -Name ('prepare-root-policy-' + $RootKind) - $authRoot = Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent - $outputRoot = Split-Path $authRoot -Parent + $outputRoot = $fixture.TestOutputRoot + $authRoot = Join-Path $outputRoot 'GraphKit.Auth' $roots = [ordered]@{ auth = $authRoot capture = Join-Path $authRoot 'capture' @@ -2072,8 +2161,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Should -BeExactly $versionSecurityBefore } finally { - Set-GraphKitAuthTestTreeWritable -Path $outputRoot - Remove-Item -LiteralPath $outputRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } @@ -2451,8 +2539,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { } } finally { - Invoke-GraphKitAuthPrepareClean -OutputRoot ( - Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) | Out-Null + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } @@ -3006,17 +3093,13 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { It 'rejects a sealed stage after Windows ACL mutation' -ForEach $windowsAclMutationCases -AllowNullOrEmptyForEach { $fixture = New-GraphKitAuthStageFixture -Name ('windows-acl-' + $Kind.Replace(' ', '-')) - $fixtureOutput = Split-Path ( - Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) -Parent try { Set-GraphKitAuthWindowsAclMutation -StagePath $fixture.StagePath -Kind $Kind { Test-GraphKitAuthSealedStage -StagePath $fixture.StagePath -FullVersion $fixture.FullVersion } | Should -Throw } finally { - Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath - Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput - [IO.Directory]::Delete($fixtureOutput, $true) + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } @@ -3024,8 +3107,6 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { if ($IsWindows) { @(@{}) } else { @() } ) -AllowNullOrEmptyForEach { $fixture = New-GraphKitAuthStageFixture -Name 'windows-wrong-owner-evidence' - $fixtureOutput = Split-Path ( - Split-Path (Split-Path (Split-Path $fixture.StagePath -Parent) -Parent) -Parent) -Parent try { $evidence = $script:GraphKitAuthStageCaptureType::InspectFile($fixture.StagePath, 'manifest.json') $mutated = $evidence | Select-Object * @@ -3035,9 +3116,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { Should -BeFalse } finally { - Set-GraphKitAuthTestStageWritable -StagePath $fixture.StagePath - Set-GraphKitAuthTestTreeWritable -Path $fixtureOutput - [IO.Directory]::Delete($fixtureOutput, $true) + Remove-GraphKitAuthTestStageFixture -Fixture $fixture } } From cdc0101f90a998b1f6e92b8db419e3a89a84c11c Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 18:42:30 -0400 Subject: [PATCH 78/79] test: preserve Windows fixture parent ACL --- tests/QA/GraphKitAuthPackage.tests.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 1c6bd03..4a3b7e2 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -254,10 +254,16 @@ public static class GraphKitAuthPackageLinkFixture if ($isLink) { $parent = [IO.Path]::GetDirectoryName($path) if ($IsWindows) { - Set-GraphKitAuthTestWindowsPathWritable -Path $parent -Directory $true 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 } + else { + Set-GraphKitAuthTestWindowsPathWritable -Path $parent -Directory $true + } } else { Set-GraphKitAuthTestUnixPathWritable -Path $parent -Directory $true From 5ce9bdca6f2775c0bbf1ec220b66f8985b747d99 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 5 Sep 2026 19:27:04 -0400 Subject: [PATCH 79/79] test: isolate Windows alias cleanup ACLs --- tests/QA/GraphKitAuthPackage.tests.ps1 | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/QA/GraphKitAuthPackage.tests.ps1 b/tests/QA/GraphKitAuthPackage.tests.ps1 index 4a3b7e2..dddf2f0 100644 --- a/tests/QA/GraphKitAuthPackage.tests.ps1 +++ b/tests/QA/GraphKitAuthPackage.tests.ps1 @@ -251,6 +251,7 @@ public static class GraphKitAuthPackageLinkFixture $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) { @@ -261,7 +262,10 @@ public static class GraphKitAuthPackageLinkFixture # rights from every sibling fixture below it. Set-GraphKitAuthTestWindowsPathWritable -Path $path -Directory $false } - else { + 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 } } @@ -581,7 +585,7 @@ public static class GraphKitAuthPackageLinkFixture $outsideLink = Join-Path $TestDrive ('GraphKit.Auth.hardlink-' + [guid]::NewGuid().ToString('N') + '.dll') $linkArtifact = [pscustomobject]@{ Path = $outsideLink; Directory = $false; Link = $true - RestorePath = $targetPath; Created = $false + RestorePath = $targetPath; WidenParent = $false; Created = $false } $CleanupArtifacts.Add($linkArtifact) | Out-Null New-GraphKitAuthTestHardLink -LinkPath $outsideLink -TargetPath $targetPath @@ -614,7 +618,7 @@ public static class GraphKitAuthPackageLinkFixture [IO.File]::Delete($targetPath) $linkArtifact = [pscustomobject]@{ Path = $targetPath; Directory = $false; Link = $true - RestorePath = $null; Created = $false + RestorePath = $null; WidenParent = $true; Created = $false } $CleanupArtifacts.Add($linkArtifact) | Out-Null New-GraphKitAuthTestFileSymbolicLink -LinkPath $targetPath -TargetPath $outsidePath @@ -667,7 +671,7 @@ public static class GraphKitAuthPackageLinkFixture $kind = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' } $linkArtifact = [pscustomobject]@{ Path = $payloadPath; Directory = $true; Link = $true - RestorePath = $null; Created = $false + RestorePath = $null; WidenParent = $true; Created = $false } $CleanupArtifacts.Add($linkArtifact) | Out-Null $null = New-Item -ItemType $kind -Path $payloadPath -Target $outsidePayload -ErrorAction Stop @@ -1135,7 +1139,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $outsideLink = Join-Path $TestDrive ('manifest-hard-link-' + [guid]::NewGuid().ToString('N') + '.json') $linkArtifact = [pscustomobject]@{ Path = $outsideLink; Directory = $false; Link = $true - RestorePath = $manifestPath; Created = $false + RestorePath = $manifestPath; WidenParent = $false; Created = $false } $cleanupArtifacts.Add($linkArtifact) | Out-Null New-GraphKitAuthTestHardLink -LinkPath $outsideLink -TargetPath $manifestPath @@ -3159,7 +3163,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { $aliasArtifact = [pscustomobject]@{ Path = $aliasAncestor; Directory = $true; Link = $true - RestorePath = ''; Created = $false + RestorePath = ''; WidenParent = $false; Created = $false } $aliasCleanup.Add($aliasArtifact) | Out-Null $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) ` @@ -3181,7 +3185,7 @@ Describe 'GraphKit.Auth sealed staging implementation' -Tag 'QA' { 'projection-repository-alias-' + [guid]::NewGuid().ToString('N')) $repositoryAliasArtifact = [pscustomobject]@{ Path = $repositoryAlias; Directory = $true; Link = $true - RestorePath = ''; Created = $false + RestorePath = ''; WidenParent = $false; Created = $false } $aliasCleanup.Add($repositoryAliasArtifact) | Out-Null $null = New-Item -ItemType $(if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }) `