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.
#>


function New-PSMutationSandboxName {
    # A sandbox directory name no other local user can predict.
    #
    # The name used to be exactly "psmut-sandbox-$PID", in a world-writable temp directory. That is
    # guessable -- process ids are small, visible, and a watcher can simply wait -- so another local
    # user could create that path FIRST, as a symlink to a directory they own. The run then copied
    # the source through it. Reproduced end to end on Linux with the sticky bit set and
    # fs.protected_symlinks=1, which does not help here: that guard only covers the FINAL path
    # component, and the planted symlink is an intermediate one.
    #
    # The process id stays in the name because the stale sweep identifies an owner by it. What makes
    # the name unguessable is the suffix, and it comes from a cryptographic RNG rather than
    # Get-Random or a GUID: those are about uniqueness, and the property needed here is that an
    # attacker cannot predict the next value. RandomNumberGenerator.Create() predates .NET Core 1.0,
    # so it costs nothing against this module's PowerShell 7.2 floor.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Pure factory: returns a string, changes no system state. New-PSMutationSandbox below is the one that creates something, and it does support ShouldProcess.')]
    [OutputType([string])]
    [CmdletBinding()]
    param()
    $bytes = [byte[]]::new(16)
    $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
    return "psmut-sandbox-$PID-" + [System.BitConverter]::ToString($bytes).Replace('-', '').ToLowerInvariant()
}

function Assert-PSMutationSandboxPath {
    # Refuse a sandbox path that already exists, rather than clearing it.
    #
    # The old code removed whatever was there first. That is what made the attack above work: on a
    # sticky temp directory the removal of another user's entry FAILS, the failure is
    # non-terminating, and execution carried on to write through the entry that was left. Refusing
    # is both safer and simpler -- with an unguessable name a collision is not a case worth
    # recovering from, so an existing path means something is wrong and the run should say so
    # instead of clearing it.
    #
    # Checked again AFTER creation by the caller, because a check followed by a create is a race.
    # Nothing about this refusal depends on winning it: the name is unguessable, so an attacker
    # cannot aim at it, and the post-creation check catches the case where the path we ended up
    # with is not the directory we asked for.
    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) { return }
    throw ("Refusing to use the mutation sandbox '$Path': something is already there. The name " +
        'carries 128 bits of randomness, so this is not an ordinary collision -- it is either a ' +
        'leftover nothing cleaned up, or another local user placing something where this run was ' +
        'about to write. Delete it if you recognise it; investigate if you do not.')
}

function Assert-PSMutationSandboxReal {
    # Refuse a sandbox that is not the plain directory we just created.
    #
    # A reparse point here -- a symlink or a junction -- means writes land somewhere else, which is
    # the whole of the vulnerability this pair of guards closes. Checked after creation rather than
    # only before it, so a path substituted between the two is still caught.
    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string]$Path)
    $item = Get-Item -LiteralPath $Path -Force
    if (-not $item.PSIsContainer -or $item.LinkType) {
        throw ("Refusing to use the mutation sandbox '$Path': it is a " +
            "$($item.LinkType ? "$($item.LinkType) to '$($item.Target -join ', ')'" : 'file'), " +
            'not a directory this run created. Writes would land outside the sandbox, which is the ' +
            'one thing the sandbox exists to prevent.')
    }
}

function New-PSMutationSandbox {
    # Copy the source subtrees into a fresh temp dir; return its root path.
    #
    # -Subtrees is mandatory rather than defaulted. What to copy is a config decision,
    # and this file is mechanism: giving it an opinion about the repo's layout would put
    # the default here, where the resolver that owns it would have to reach across files
    # to read it.
    #
    # -Name defaults to an UNGUESSABLE name; see New-PSMutationSandboxName for why, and
    # Assert-PSMutationSandboxPath for why an existing path is refused rather than cleared.
    [OutputType([string])]
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    param(
        [Parameter(Mandatory)] [string]$RepoRoot,
        [Parameter(Mandatory)] [string[]]$Subtrees,
        [string]$Name = (New-PSMutationSandboxName)
    )
    $root = Join-Path ([System.IO.Path]::GetTempPath()) $Name
    if ($PSCmdlet.ShouldProcess($root, 'Create mutation sandbox')) {
        Assert-PSMutationSandboxPath -Path $root
        New-Item -ItemType Directory -Path $root | Out-Null
        Assert-PSMutationSandboxReal -Path $root
        $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))
    $mapped = [System.IO.Path]::GetFullPath((Join-Path $SandboxRoot $rel))

    # Refuse a path that lands OUTSIDE the sandbox, because the caller writes to whatever
    # comes back. A leading `..` survives the round trip above, so a config naming a file
    # above the source root gets mutated where it lives -- and the promise that a hard kill
    # cannot leave a mutant in tracked source then rests entirely on the `finally` that
    # restores it, rather than on the mutant never touching a real file at all.
    #
    # Asked as a relative path rather than by string-matching for '..', because a path may
    # contain '..' and still resolve inside: `src/../src/a.ps1` is `src/a.ps1`, and refusing
    # that would reject a config that was never ambiguous.
    $back = [System.IO.Path]::GetRelativePath($SandboxRoot, $mapped)
    if ([System.IO.Path]::IsPathRooted($back) -or $back -eq '..' -or
        $back.StartsWith('..' + [System.IO.Path]::DirectorySeparatorChar)) {
        throw ("Config path '$Path' resolves outside the source root, to '$mapped'. Every path " +
            "in a config is copied into a temp sandbox and mutated there; one that escapes would " +
            "be mutated in place, in the directory the sandbox exists to replace. Use a path " +
            "inside '$RepoRoot', or point -SourceRoot at the directory that contains them all.")
    }
    return $mapped
}

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>-<random>"; anything else in temp is somebody else's and is left alone.
    #
    # The random suffix is OPTIONAL in this pattern, and that is not laxity: sandboxes created
    # before the name was randomised are called "psmut-sandbox-<pid>" and still need reclaiming.
    # Refusing to recognise them would orphan every one of them permanently the moment this
    # version ships. The suffix is what makes a name unguessable when it is WRITTEN; reading one
    # is a different question.
    [OutputType([int])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string]$Name)
    if ($Name -match '^psmut-sandbox-(\d+)(-[0-9a-f]+)?$') { return [int]$Matches[1] }
    # The other artefact a run used to leave named after its process: Pester's coverage XML, which
    # older versions wrote to temp as "psmut-coverage-<pid>.xml". Nothing ever deleted it -- the
    # sweep looked only at DIRECTORIES named psmut-sandbox-*, so it could not match by construction,
    # and they accumulated for the life of the machine. The coverage file now lives inside the
    # sandbox and goes with it, so this arm is TRANSITIONAL: it exists to reclaim what earlier
    # versions left behind, and can be deleted once no machine plausibly still has any.
    if ($Name -match '^psmut-coverage-(\d+)\.xml$') { 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, or two
    # runs on one machine delete each other's working files mid-flight.
    [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')) {
        # Directories AND files. The coverage XML older versions left in temp is a file, and a
        # sweep restricted to directories is what let 67 of them pile up on the machine this was
        # found on. Both shapes are named after the process that made them, so one ownership test
        # answers for both -- Test-PSMutationSandboxAbandoned reads Name and CreationTime, which a
        # FileInfo carries exactly as a DirectoryInfo does.
        @(Get-ChildItem ([System.IO.Path]::GetTempPath()) -Directory -Filter 'psmut-sandbox-*' -ErrorAction SilentlyContinue) +
        @(Get-ChildItem ([System.IO.Path]::GetTempPath()) -File -Filter 'psmut-coverage-*.xml' -ErrorAction SilentlyContinue) |
            # A reparse point is skipped rather than swept. Temp is world-writable, so anyone can
            # leave a symlink named like a sandbox; recursively removing one asks the sweep to
            # follow a path an attacker chose. Nothing this module creates is ever a link, so
            # skipping costs nothing and removes the sweep as a deletion primitive.
            Where-Object { -not $_.LinkType } |
            Where-Object { Test-PSMutationSandboxAbandoned -Directory $_ } |
            ForEach-Object { Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
    }
}