src/PowerShell/Hooks.ps1

# cdp PowerShell domain: Hooks.ps1
# Loaded by src/cdp.psm1; do not dot-source peer files.

function Get-CdpStringFingerprint {
    param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)

    $algorithm = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value)
        $hash = $algorithm.ComputeHash($bytes)
        ([System.BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
    } finally {
        $algorithm.Dispose()
    }
}

function Get-CdpHookTrustPath {
    if (-not [string]::IsNullOrWhiteSpace($env:CDP_HOOK_TRUST_PATH)) {
        return [Environment]::ExpandEnvironmentVariables($env:CDP_HOOK_TRUST_PATH)
    }
    Join-Path (Get-CdpUserHome) '.cdp\hook-trust.json'
}

function Get-CdpNormalizedConfigPath {
    param([Parameter(Mandatory = $true)][string]$ConfigPath)

    $fullPath = [System.IO.Path]::GetFullPath($ConfigPath)
    if ([System.IO.Path]::DirectorySeparatorChar -eq '\') {
        return $fullPath.ToLowerInvariant()
    }
    $fullPath
}

function Get-CdpHookDescriptor {
    param([Parameter(Mandatory = $true)][object]$Project)

    if (-not $Project.PSObject.Properties['onEnter'] -or $null -eq $Project.onEnter) { return $null }
    $onEnter = $Project.onEnter
    $command = if ($onEnter -is [string]) {
        [string]$onEnter
    } elseif ($onEnter.PSObject.Properties['powershell']) {
        [string]$onEnter.powershell
    } else {
        ''
    }
    if ([string]::IsNullOrWhiteSpace($command)) { return $null }
    [PSCustomObject]@{ Kind = 'powershell'; Command = $command }
}

function Get-CdpHookIdentity {
    param(
        [Parameter(Mandatory = $true)][string]$ConfigPath,
        [Parameter(Mandatory = $true)][object]$Project
    )

    $descriptor = Get-CdpHookDescriptor -Project $Project
    if (-not $descriptor) { return $null }
    $nameHash = Get-CdpStringFingerprint -Value ([string]$Project.name)
    $rootHash = Get-CdpStringFingerprint -Value ([string]$Project.rootPath)
    $commandHash = Get-CdpStringFingerprint -Value $descriptor.Command
    $configContentHash = Get-CdpFileFingerprint -LiteralPath $ConfigPath
    [PSCustomObject]@{
        ConfigFingerprint = Get-CdpStringFingerprint -Value (Get-CdpNormalizedConfigPath $ConfigPath)
        ProjectFingerprint = Get-CdpStringFingerprint -Value "name=$nameHash;root=$rootHash"
        HookFingerprint = Get-CdpStringFingerprint -Value "config=$configContentHash;kind=$($descriptor.Kind);command=$commandHash"
        Kind = $descriptor.Kind
    }
}

function Protect-CdpHookTrustStore {
    param([Parameter(Mandatory = $true)][string]$LiteralPath)

    if ([System.IO.Path]::DirectorySeparatorChar -ne '\') {
        & chmod 600 $LiteralPath
        if ($LASTEXITCODE -ne 0) { throw "Unable to secure the cdp hook trust store." }
        return
    }
    $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
    $acl = New-Object System.Security.AccessControl.FileSecurity
    $acl.SetOwner($identity)
    $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow')
    $acl.AddAccessRule($rule)
    Set-Acl -LiteralPath $LiteralPath -AclObject $acl
}

function Read-CdpHookTrustStore {
    $path = Get-CdpHookTrustPath
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
        return [PSCustomObject]@{
            Path = $path
            Fingerprint = 'missing'
            Value = [PSCustomObject]@{ version = 1; entries = @() }
        }
    }
    Protect-CdpHookTrustStore -LiteralPath $path
    $document = Read-CdpJsonDocument -LiteralPath $path
    $value = $document.Value
    if ($null -eq $value -or $value.version -ne 1 -or -not $value.PSObject.Properties['entries']) {
        throw "Invalid cdp hook trust store."
    }
    $value.entries = @($value.entries)
    [PSCustomObject]@{ Path = $path; Fingerprint = $document.Fingerprint; Value = $value }
}

function Save-CdpHookTrustStore {
    param([Parameter(Mandatory = $true)][object]$Document)

    [void](Write-CdpJsonFile `
        -LiteralPath $Document.Path `
        -Value $Document.Value `
        -ExpectedFingerprint $Document.Fingerprint)
    Protect-CdpHookTrustStore -LiteralPath $Document.Path
}

function Test-CdpHookTrusted {
    param(
        [Parameter(Mandatory = $true)][string]$ConfigPath,
        [Parameter(Mandatory = $true)][object]$Project
    )

    $identity = Get-CdpHookIdentity -ConfigPath $ConfigPath -Project $Project
    if (-not $identity) { return $false }
    $store = Read-CdpHookTrustStore
    @($store.Value.entries | Where-Object {
        $_.configFingerprint -eq $identity.ConfigFingerprint -and
        $_.projectFingerprint -eq $identity.ProjectFingerprint -and
        $_.hookFingerprint -eq $identity.HookFingerprint
    }).Count -gt 0
}

function Get-CdpHookProjects {
    param([Parameter(Mandatory = $true)][string]$ConfigPath)

    $document = Read-CdpJsonDocument -LiteralPath $ConfigPath
    @(@($document.Value) | Where-Object {
        $_.enabled -eq $true -and (Get-CdpHookDescriptor -Project $_)
    })
}

function Show-CdpHookTrustList {
    param([Parameter(Mandatory = $true)][string]$ConfigPath)

    $store = Read-CdpHookTrustStore
    $projects = @(Get-CdpHookProjects -ConfigPath $ConfigPath)
    if ($projects.Count -eq 0) { Write-Host 'No command hooks found.' -ForegroundColor Yellow; return }
    Write-Host 'cdp hook trust' -ForegroundColor Cyan
    foreach ($project in $projects) {
        $identity = Get-CdpHookIdentity -ConfigPath $ConfigPath -Project $project
        $projectEntries = @($store.Value.entries | Where-Object {
            $_.configFingerprint -eq $identity.ConfigFingerprint -and
            $_.projectFingerprint -eq $identity.ProjectFingerprint
        })
        $state = if (@($projectEntries | Where-Object { $_.hookFingerprint -eq $identity.HookFingerprint }).Count) {
            'trusted'
        } elseif ($projectEntries.Count -gt 0) {
            'stale'
        } else {
            'untrusted'
        }
        Write-Host " $($project.name) [$($identity.Kind)] $state"
    }
}

function Add-CdpHookTrust {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param([string]$ConfigPath, [string]$Name)

    $projects = @(Get-CdpHookProjects -ConfigPath $ConfigPath)
    $matches = @(Get-CdpProjectMatches -Projects $projects -Query $Name)
    if ($matches.Count -ne 1) { throw "Hook trust requires one project match; found $($matches.Count)." }
    $identity = Get-CdpHookIdentity -ConfigPath $ConfigPath -Project $matches[0]
    if (-not $PSCmdlet.ShouldProcess([string]$matches[0].name, 'Trust current hook fingerprint')) {
        return
    }
    $store = Read-CdpHookTrustStore
    $entries = @($store.Value.entries | Where-Object {
        -not ($_.configFingerprint -eq $identity.ConfigFingerprint -and
            $_.projectFingerprint -eq $identity.ProjectFingerprint)
    })
    $entries += [PSCustomObject]@{
        configFingerprint = $identity.ConfigFingerprint
        projectFingerprint = $identity.ProjectFingerprint
        hookFingerprint = $identity.HookFingerprint
        trustedAt = [DateTime]::UtcNow.ToString('o')
    }
    $store.Value.entries = @($entries)
    Save-CdpHookTrustStore -Document $store
    Write-Host "Trusted hook for project: $($matches[0].name)" -ForegroundColor Green
}

function Remove-CdpHookTrust {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param([string]$ConfigPath, [string]$Name)

    $store = Read-CdpHookTrustStore
    $configFingerprint = Get-CdpStringFingerprint -Value (Get-CdpNormalizedConfigPath $ConfigPath)
    $projectFingerprint = $null
    if ($Name -ne '--all') {
        $matches = @(Get-CdpProjectMatches -Projects @(Get-CdpHookProjects $ConfigPath) -Query $Name)
        if ($matches.Count -ne 1) { throw "Hook revoke requires one project match; found $($matches.Count)." }
        $projectFingerprint = (Get-CdpHookIdentity -ConfigPath $ConfigPath -Project $matches[0]).ProjectFingerprint
    }
    $target = if ($Name -eq '--all') { 'all hooks for active config' } else { $Name }
    if (-not $PSCmdlet.ShouldProcess($target, 'Revoke hook trust')) {
        return
    }
    $store.Value.entries = @($store.Value.entries | Where-Object {
        $_.configFingerprint -ne $configFingerprint -or
        ($projectFingerprint -and $_.projectFingerprint -ne $projectFingerprint)
    })
    Save-CdpHookTrustStore -Document $store
    Write-Host 'Hook trust revoked.' -ForegroundColor Green
}

function Invoke-CdpHookCommand {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param([string]$Action, [string]$Name, [string]$ConfigPath)

    try {
        if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Get-DefaultConfigPath }
        if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { throw "Configuration file not found." }
        switch ($Action) {
            'list' { Show-CdpHookTrustList -ConfigPath $ConfigPath }
            'trust' {
                $parameters = @{ ConfigPath = $ConfigPath; Name = $Name }
                if ($WhatIfPreference) { $parameters.WhatIf = $true }
                if ($PSBoundParameters.ContainsKey('Confirm')) { $parameters.Confirm = $PSBoundParameters['Confirm'] }
                Add-CdpHookTrust @parameters
            }
            'revoke' {
                $parameters = @{ ConfigPath = $ConfigPath; Name = $Name }
                if ($WhatIfPreference) { $parameters.WhatIf = $true }
                if ($PSBoundParameters.ContainsKey('Confirm')) { $parameters.Confirm = $PSBoundParameters['Confirm'] }
                Remove-CdpHookTrust @parameters
            }
            default { throw "Unknown hook action." }
        }
    } catch {
        Write-Host "Error: hook trust operation failed ($($_.Exception.GetType().Name))." -ForegroundColor Red
    }
}

function Set-CdpOnEnterEnvironment {
    param([Parameter(Mandatory = $true)][object]$OnEnter)

    if ($OnEnter -is [string] -or -not $OnEnter.PSObject.Properties['env']) { return }
    $OnEnter.env.PSObject.Properties | ForEach-Object {
        if ($_.Name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') {
            Write-Host " onEnter warning: invalid environment variable name skipped." -ForegroundColor Yellow
        } else {
            [System.Environment]::SetEnvironmentVariable($_.Name, [string]$_.Value, 'Process')
        }
    }
}

function Get-CdpOnEnterCommand {
    param([Parameter(Mandatory = $true)][object]$OnEnter)

    if ($OnEnter -is [string]) { return [string]$OnEnter }
    if ($OnEnter.PSObject.Properties['powershell']) { return [string]$OnEnter.powershell }
    ''
}

function Invoke-CdpOnEnter {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Project,

        [Parameter(Mandatory = $false)]
        [string]$ConfigPath,

        [Parameter(Mandatory = $false)]
        [switch]$AllowHook,

        [Parameter(Mandatory = $false)]
        [switch]$NoHook
    )

    if (-not $Project.PSObject.Properties['onEnter'] -or $null -eq $Project.onEnter) {
        return
    }

    if ($NoHook) {
        Write-Host " onEnter skipped by --no-hook." -ForegroundColor Yellow
        return
    }

    $onEnter = $Project.onEnter

    try {
        Set-CdpOnEnterEnvironment -OnEnter $onEnter

        $hookCommand = Get-CdpOnEnterCommand -OnEnter $onEnter

        if ([string]::IsNullOrWhiteSpace($hookCommand)) {
            return
        }

        $trusted = -not [string]::IsNullOrWhiteSpace($ConfigPath) -and
            (Test-CdpHookTrusted -ConfigPath $ConfigPath -Project $Project)
        if (-not $AllowHook -and -not $trusted) {
            Write-Host " onEnter command skipped: trust this project hook or use -AllowHook once." -ForegroundColor Yellow
            return
        }

        Invoke-Expression $hookCommand
    } catch {
        Write-Host " onEnter warning: command failed ($($_.Exception.GetType().Name))." -ForegroundColor Yellow
    }
}