src/PSMutation.Sandbox.ps1
|
<# .SYNOPSIS Sandbox isolation for the PowerShell mutation runner. .DESCRIPTION Mutants must NEVER be written into tracked source -- not even transiently, so a hard kill (Ctrl-C / TaskStop) can't leave a mutated file staged in git. So instead of mutating the real file in place, the runner copies the source subtrees into a throwaway temp sandbox and mutates only the copy. The tests run from the sandbox too, so their $PSScriptRoot-relative dot-sources resolve to the sandboxed modules. On any exit -- clean or killed -- only a temp dir is dirty, and it's disposable. Each function stays tiny (well under the complexity ceiling) and side-effects are confined here. #> # Default subtrees copied into the sandbox. Neutral module convention; a consuming # repo overrides this via the config's `sandboxSubtrees` to match its own layout. $script:PSMutationSandboxSubtrees = @('src', 'tests') function New-PSMutationSandbox { # Copy the source subtrees into a fresh temp dir; return its root path. [OutputType([string])] [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] param( [Parameter(Mandatory)] [string]$RepoRoot, [string[]]$Subtrees = $script:PSMutationSandboxSubtrees, [string]$Name = "psmut-sandbox-$PID" ) $root = Join-Path ([System.IO.Path]::GetTempPath()) $Name if ($PSCmdlet.ShouldProcess($root, 'Create mutation sandbox')) { if (Test-Path $root) { Remove-Item $root -Recurse -Force } New-Item -ItemType Directory -Path $root -Force | Out-Null $Subtrees | Where-Object { Test-Path (Join-Path $RepoRoot $_) } | ForEach-Object { Copy-Item (Join-Path $RepoRoot $_) (Join-Path $root $_) -Recurse -Force } } return $root } function ConvertTo-PSMutationSandboxPath { # Map a repo path to its position inside the sandbox (structure is preserved). Pure. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$RepoRoot, [Parameter(Mandatory)] [string]$SandboxRoot ) $rel = [System.IO.Path]::GetRelativePath($RepoRoot, [System.IO.Path]::GetFullPath($Path)) return [System.IO.Path]::GetFullPath((Join-Path $SandboxRoot $rel)) } function ConvertFrom-PSMutationSandboxPath { # Inverse of ConvertTo -- sandbox path back to a repo-relative display path. Pure. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$SandboxRoot ) return [System.IO.Path]::GetRelativePath($SandboxRoot, [System.IO.Path]::GetFullPath($Path)) -replace '\\', '/' } function Remove-PSMutationSandbox { # Delete a sandbox. Best-effort -- a leftover temp dir is harmless, never tracked. [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] param([Parameter(Mandatory)] [string]$SandboxRoot) if ($PSCmdlet.ShouldProcess($SandboxRoot, 'Remove mutation sandbox')) { if (Test-Path $SandboxRoot) { Remove-Item $SandboxRoot -Recurse -Force -ErrorAction SilentlyContinue } } } function Get-PSMutationSandboxOwnerId { # The process id encoded in a sandbox directory name, or $null when the name is # not one the runner produces. New-PSMutationSandbox names them # "psmut-sandbox-$PID"; anything else in temp is somebody else's and is left alone. [OutputType([int])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Name) if ($Name -match '^psmut-sandbox-(\d+)$') { return [int]$Matches[1] } return # emits nothing, so the caller's variable is $null } function Test-PSMutationSandboxAbandoned { # True when a sandbox can be deleted: its owning process is gone. A sandbox whose # owner is still ALIVE belongs to a concurrent run and must be left alone -- the # sweep used to take those too, which is how two runs destroyed each other. [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] $Directory, [int]$CurrentProcessId = $PID ) $owner = Get-PSMutationSandboxOwnerId -Name $Directory.Name if ($null -eq $owner) { return $false } # Our own id: any directory already holding it is a leftover from a previous # process that happened to get this id, and we are about to recreate it anyway. if ($owner -eq $CurrentProcessId) { return $true } $proc = Get-Process -Id $owner -ErrorAction SilentlyContinue if (-not $proc) { return $true } # The id is live but may have been RECYCLED onto an unrelated process. A process # that started after the sandbox did cannot be the one that created it, so the # sandbox is abandoned despite the id being in use. return (Get-PSMutationProcessStart -Process $proc) -gt $Directory.CreationTime } function Get-PSMutationProcessStart { # StartTime is not readable for every process -- protected/system ones fail. That # failure is NON-TERMINATING in PowerShell: the property yields $null instead of # raising, so a try/catch around it never runs (coverage proved the catch was # unreachable, which is why there isn't one). Testing the value is the whole # contract. An unreadable start time becomes a time far in the past, which reads # as "cannot prove this sandbox is stale" and so keeps it, rather than risking a # live run's files. [OutputType([datetime])] [CmdletBinding()] param([Parameter(Mandatory)] $Process) $start = $Process.StartTime if ($null -eq $start) { return [datetime]::MinValue } return $start } function Clear-PSMutationStaleSandbox { # Sweep sandboxes left by a previously killed run (belt-and-braces at startup), # WITHOUT touching one that a concurrent run is using. [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] param() if ($PSCmdlet.ShouldProcess('temp', 'Clear stale mutation sandboxes')) { Get-ChildItem ([System.IO.Path]::GetTempPath()) -Directory -Filter 'psmut-sandbox-*' -ErrorAction SilentlyContinue | Where-Object { Test-PSMutationSandboxAbandoned -Directory $_ } | ForEach-Object { Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } } } |