diff --git a/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs index 83b6210..f60af2c 100644 --- a/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Connection/ConnectPveServerCmdlet.cs @@ -137,7 +137,7 @@ namespace PSProxmoxVE.Cmdlets.Connection return; } - ModuleState.ActiveSession = session; + ModuleState.SetActiveSession(this, session); if (SkipCertificateCheck.IsPresent) WriteWarning("TLS certificate validation is disabled for this session. Connections are susceptible to man-in-the-middle attacks. Use only in trusted networks or test environments."); diff --git a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs index 1b9185b..40cf9fe 100644 --- a/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Connection/DisconnectPveServerCmdlet.cs @@ -19,7 +19,8 @@ namespace PSProxmoxVE.Cmdlets.Connection protected override void ProcessPveRecord() { bool explicitSessionSupplied = MyInvocation.BoundParameters.ContainsKey(nameof(Session)); - var sessionToDisconnect = explicitSessionSupplied ? Session : ModuleState.ActiveSession; + var moduleSession = ModuleState.GetActiveSession(this); + var sessionToDisconnect = explicitSessionSupplied ? Session : moduleSession; if (sessionToDisconnect is null) { @@ -27,7 +28,7 @@ namespace PSProxmoxVE.Cmdlets.Connection return; } - if (!ReferenceEquals(sessionToDisconnect, ModuleState.ActiveSession)) + if (!ReferenceEquals(sessionToDisconnect, moduleSession)) { var lifecycle = sessionToDisconnect.AuthMode == PveAuthMode.ApiToken ? "API tokens do not expire; revoke it with Remove-PveApiToken if it is no longer needed." @@ -39,7 +40,7 @@ namespace PSProxmoxVE.Cmdlets.Connection if (!ShouldProcess($"{sessionToDisconnect.Hostname}:{sessionToDisconnect.Port}", "Disconnect")) return; - ModuleState.ActiveSession = null; + ModuleState.SetActiveSession(this, null); WriteVerbose($"Disconnected from {sessionToDisconnect.Hostname}:{sessionToDisconnect.Port}."); } } diff --git a/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs index 7f50a18..cd3baf1 100644 --- a/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Connection/TestPveConnectionCmdlet.cs @@ -24,7 +24,7 @@ namespace PSProxmoxVE.Cmdlets.Connection protected override void ProcessRecord() { - var session = ModuleState.ActiveSession; + var session = ModuleState.GetActiveSession(this); var isConnected = session is not null && !session.IsExpired; if (Detailed.IsPresent) diff --git a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs index ec14290..4df1870 100644 --- a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs +++ b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs @@ -80,13 +80,13 @@ namespace PSProxmoxVE.Cmdlets /// /// Returns the session to use for this cmdlet. - /// Resolution order: -Session parameter → ModuleState.ActiveSession. + /// Resolution order: -Session parameter → the runspace's module session. /// Throws if no session is available, /// or if the session ticket has expired. /// protected PveSession GetSession() { - var session = Session ?? ModuleState.ActiveSession; + var session = Session ?? ModuleState.GetActiveSession(this); if (session is null) throw new PveNotConnectedException(); diff --git a/src/PSProxmoxVE/ModuleState.cs b/src/PSProxmoxVE/ModuleState.cs index 6c5700d..9885fe2 100644 --- a/src/PSProxmoxVE/ModuleState.cs +++ b/src/PSProxmoxVE/ModuleState.cs @@ -1,10 +1,46 @@ +using System.Management.Automation; using PSProxmoxVE.Core.Authentication; namespace PSProxmoxVE { - /// Module-scoped state container for the active PVE session + /// + /// Per-runspace storage for the session established by Connect-PveServer. + /// The value lives in the module's own session state, which PowerShell creates + /// once per runspace, so runspaces cannot see or overwrite each other's session. + /// internal static class ModuleState { - internal static PveSession? ActiveSession { get; set; } + internal const string ActiveSessionVariable = "PSProxmoxVE.ActiveSession"; + + // Qualified so a nested scope in the module's session state (InModuleScope, a + // module-scoped scriptblock) neither writes a copy that dies with the scope nor reads + // through to a same-named global. + private const string ModuleActiveSessionVariable = "script:" + ActiveSessionVariable; + + // Importing the assembly directly rather than through the manifest yields a module + // with no session state; the global scope is then the only per-runspace slot left. + private const string GlobalActiveSessionVariable = "global:" + ActiveSessionVariable; + + internal static PveSession? GetActiveSession(PSCmdlet cmdlet) + { + var moduleState = ModuleSessionState(cmdlet); + return moduleState is null + ? cmdlet.SessionState.PSVariable.GetValue(GlobalActiveSessionVariable) as PveSession + : moduleState.PSVariable.GetValue(ModuleActiveSessionVariable) as PveSession; + } + + internal static void SetActiveSession(PSCmdlet cmdlet, PveSession? session) + { + var moduleState = ModuleSessionState(cmdlet); + if (moduleState is null) + cmdlet.SessionState.PSVariable.Set(GlobalActiveSessionVariable, session); + else + moduleState.PSVariable.Set(ModuleActiveSessionVariable, session); + } + + // PSCmdlet.SessionState resolves in the caller's scope, so it would store the session + // wherever the cmdlet happened to be invoked from. + private static SessionState? ModuleSessionState(PSCmdlet cmdlet) => + cmdlet.MyInvocation?.MyCommand?.Module?.SessionState; } } diff --git a/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 b/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 index e628f11..68a4063 100644 --- a/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Connection/Disconnect-PveServer.Tests.ps1 @@ -44,19 +44,18 @@ Describe 'Disconnect-PveServer' { Context 'Active session lifecycle' { BeforeAll { - $script:PveSessionType = [PSProxmoxVE.Core.Authentication.PveSession] - $script:ModuleStateType = [System.AppDomain]::CurrentDomain.GetAssemblies() | - Where-Object { $_.GetName().Name -eq 'PSProxmoxVE' } | - ForEach-Object { $_.GetType('PSProxmoxVE.ModuleState') } | - Select-Object -First 1 - $script:ModuleStateType | Should -Not -BeNullOrEmpty + $script:Module = Get-Module PSProxmoxVE + $script:Module | Should -Not -BeNullOrEmpty - $script:ActiveSessionProperty = $script:ModuleStateType.GetProperty( - 'ActiveSession', - [System.Reflection.BindingFlags]'NonPublic, Static') - $script:ActiveSessionProperty | Should -Not -BeNullOrEmpty + $script:SetActiveSession = { + param($Value) + $script:Module.SessionState.PSVariable.Set('PSProxmoxVE.ActiveSession', $Value) + } + $script:GetActiveSession = { + $script:Module.SessionState.PSVariable.GetValue('PSProxmoxVE.ActiveSession') + } - $script:SessionCtor = $script:PveSessionType.GetConstructor( + $script:SessionCtor = [PSProxmoxVE.Core.Authentication.PveSession].GetConstructor( [System.Reflection.BindingFlags]'NonPublic, Instance', $null, [type[]]@([string], [int], [bool], [string]), @@ -65,7 +64,7 @@ Describe 'Disconnect-PveServer' { } AfterEach { - $script:ActiveSessionProperty.SetValue($null, $null) + & $script:SetActiveSession $null } It 'Should report "no session" when nothing is active' { @@ -75,11 +74,11 @@ Describe 'Disconnect-PveServer' { It 'Should clear the active session and warn on a second disconnect' { $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) - $script:ActiveSessionProperty.SetValue($null, $activeSession) + & $script:SetActiveSession $activeSession Disconnect-PveServer -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue $w | Should -BeNullOrEmpty - $script:ActiveSessionProperty.GetValue($null) | Should -BeNullOrEmpty + & $script:GetActiveSession | Should -BeNullOrEmpty Disconnect-PveServer -Confirm:$false -WarningVariable w2 -WarningAction SilentlyContinue $w2[0] | Should -Match 'No active Proxmox VE session' @@ -87,33 +86,41 @@ Describe 'Disconnect-PveServer' { It 'Should warn when disconnecting an explicit non-active session, and leave the active session untouched' { $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) - $script:ActiveSessionProperty.SetValue($null, $activeSession) + & $script:SetActiveSession $activeSession $mismatchedSession = $script:SessionCtor.Invoke(@('other.example', 8006, $false, 'othertoken')) Disconnect-PveServer -Session $mismatchedSession -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue $w[0] | Should -Match 'not the module-level session' $w[0] | Should -Match 'Remove-PveApiToken' - [object]::ReferenceEquals($script:ActiveSessionProperty.GetValue($null), $activeSession) | Should -BeTrue + [object]::ReferenceEquals((& $script:GetActiveSession), $activeSession) | Should -BeTrue } It 'Should disconnect when -Session matches the active session' { $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) - $script:ActiveSessionProperty.SetValue($null, $activeSession) + & $script:SetActiveSession $activeSession Disconnect-PveServer -Session $activeSession -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue $w | Should -BeNullOrEmpty - $script:ActiveSessionProperty.GetValue($null) | Should -BeNullOrEmpty + & $script:GetActiveSession | Should -BeNullOrEmpty } It 'Should not clear the active session under -WhatIf' { $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) - $script:ActiveSessionProperty.SetValue($null, $activeSession) + & $script:SetActiveSession $activeSession Disconnect-PveServer -WhatIf -ErrorAction Stop - [object]::ReferenceEquals($script:ActiveSessionProperty.GetValue($null), $activeSession) | Should -BeTrue + [object]::ReferenceEquals((& $script:GetActiveSession), $activeSession) | Should -BeTrue + } + + It 'Should see the session Test-PveConnection sees' { + $activeSession = $script:SessionCtor.Invoke(@('active.example', 8006, $false, 'activetoken')) + & $script:SetActiveSession $activeSession + + Test-PveConnection | Should -BeTrue + [object]::ReferenceEquals((Test-PveConnection -Detailed), $activeSession) | Should -BeTrue } } } diff --git a/tests/PSProxmoxVE.Tests/Connection/SessionIsolation.Tests.ps1 b/tests/PSProxmoxVE.Tests/Connection/SessionIsolation.Tests.ps1 new file mode 100644 index 0000000..4a430e9 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Connection/SessionIsolation.Tests.ps1 @@ -0,0 +1,111 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests proving the active PVE session is per-runspace state, not a + process-wide static shared by every runspace in the host. + Fully offline — no live Proxmox VE target is required. +#> + +BeforeAll { + . $PSScriptRoot/../_TestHelper.ps1 + + # A dll import produces a module with no session state, so the runspaces import the + # manifest that sits beside it. + $script:ModulePath = Join-Path (Split-Path (Get-Module PSProxmoxVE).Path) 'PSProxmoxVE.psd1' + + function New-PveTestRunspace { + param([string]$Path) + + $ps = [powershell]::Create() + $null = $ps.AddScript({ + param($p) + Import-Module $p -Force -ErrorAction Stop + }).AddArgument($Path) + $null = $ps.Invoke() + if ($ps.Streams.Error.Count -gt 0) { + $err = $ps.Streams.Error[0] + $ps.Dispose() + throw "Runspace module import failed: $err" + } + $ps.Commands.Clear() + $ps.Streams.Error.Clear() + $ps + } + + function Invoke-InRunspace { + param([System.Management.Automation.PowerShell]$Runspace, [scriptblock]$Script, $Argument) + + $Runspace.Commands.Clear() + $Runspace.Streams.Error.Clear() + $null = $Runspace.AddScript($Script) + if ($PSBoundParameters.ContainsKey('Argument')) { $null = $Runspace.AddArgument($Argument) } + $out = $Runspace.Invoke() + if ($Runspace.Streams.Error.Count -gt 0) { + throw "Runspace script failed: $($Runspace.Streams.Error[0])" + } + , $out + } +} + +Describe 'Active session isolation between runspaces' { + BeforeAll { + $script:SetSession = { + param($hostname) + $ctor = [PSProxmoxVE.Core.Authentication.PveSession].GetConstructor( + [System.Reflection.BindingFlags]'NonPublic, Instance', + $null, + [type[]]@([string], [int], [bool], [string]), + $null) + $session = $ctor.Invoke(@($hostname, 8006, $false, 'token')) + (Get-Module PSProxmoxVE).SessionState.PSVariable.Set('PSProxmoxVE.ActiveSession', $session) + } + + $script:ClearSession = { + (Get-Module PSProxmoxVE).SessionState.PSVariable.Set('PSProxmoxVE.ActiveSession', $null) + } + + $script:GetHostname = { + $detailed = Test-PveConnection -Detailed + if ($null -eq $detailed) { '' } else { $detailed.Hostname } + } + + $script:RunspaceA = New-PveTestRunspace -Path $script:ModulePath + $script:RunspaceB = New-PveTestRunspace -Path $script:ModulePath + } + + AfterAll { + if ($script:RunspaceA) { $script:RunspaceA.Dispose() } + if ($script:RunspaceB) { $script:RunspaceB.Dispose() } + } + + It 'Should not expose one runspace''s session to another' { + Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:ClearSession + Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:SetSession -Argument 'runspace-a.example' + + $connectedInB = Invoke-InRunspace -Runspace $script:RunspaceB -Script { Test-PveConnection } + $connectedInB[0] | Should -BeFalse + } + + It 'Should keep each runspace on its own server after both connect' { + Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:SetSession -Argument 'runspace-a.example' + Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:SetSession -Argument 'runspace-b.example' + + $inA = Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:GetHostname + $inB = Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:GetHostname + + $inA[0] | Should -Be 'runspace-a.example' + $inB[0] | Should -Be 'runspace-b.example' + } + + It 'Should not let one runspace''s disconnect clear another''s session' { + Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:SetSession -Argument 'runspace-a.example' + Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:SetSession -Argument 'runspace-b.example' + + Invoke-InRunspace -Runspace $script:RunspaceB -Script { Disconnect-PveServer -Confirm:$false } + + $inA = Invoke-InRunspace -Runspace $script:RunspaceA -Script $script:GetHostname + $inB = Invoke-InRunspace -Runspace $script:RunspaceB -Script $script:GetHostname + $inA[0] | Should -Be 'runspace-a.example' + $inB[0] | Should -Be '' + } +}