src/Public/Install-FotonTool.ps1

function Install-FotonTool {
    <#
    .SYNOPSIS
        Install a pinned external DFIR tool into the Foton tool root (Hayabusa first).
 
    .DESCRIPTION
        Workstation setup only. Does not run during compose/analyze and does not
        mutate PATH. Downloads (or unpacks -ArchivePath) a catalog-pinned release,
        verifies SHA-256, installs under %LOCALAPPDATA%\Foton\tools by default, and
        writes tools.json so Invoke-FotonExternalParse can find the binary.
 
        First cut supports Hayabusa only. Compose stays offline; if a tool is
        missing it tells you to run this command.
 
    .PARAMETER Name
        Catalog tool name (e.g. Hayabusa). Required unless -List.
 
    .PARAMETER List
        List every tool Foton knows about on this workstation: pinned catalog tools
        (Source Installed, or Catalog with the download URL when not yet installed) and
        existing installs registered with -Path (Source Registered). Location is the
        executable compose will run. Tools found only on PATH are not listed.
 
    .PARAMETER ToolRoot
        Install root. Default: %LOCALAPPDATA%\Foton\tools.
 
    .PARAMETER ArchivePath
        Offline install: path to the already-downloaded release zip. Still verifies
        ExpectedSha256 from the catalog.
 
    .PARAMETER Path
        Register a tool that is already on disk instead of downloading one. Accepts a
        folder or an executable. -Name EZTools registers PECmd, LECmd, JLECmd,
        EvtxECmd, AmcacheParser, and RECmd from one EZ Tools folder, including the
        per-tool subfolders. Writes tools.json only; nothing is downloaded and PATH
        is not changed. The recorded SHA-256 is observed, not verified.
 
    .PARAMETER CatalogPath
        Optional catalog JSON override (tests).
 
    .PARAMETER Force
        Skip the confirmation prompt (automation / CI).
 
    .EXAMPLE
        Install-FotonTool -List
 
        Shows pinned and registered tools, their versions, and where compose finds them.
 
    .EXAMPLE
        Install-FotonTool -Name Hayabusa
 
        Prints source URL and hash, asks for confirmation, downloads and installs.
 
    .EXAMPLE
        Install-FotonTool -Name Hayabusa -ArchivePath D:\Offline\hayabusa-4.0.0-win-x64.zip -Force
 
        Air-gapped install from a local archive.
 
    .EXAMPLE
        Install-FotonTool -Name EZTools -Path C:\Tools\EZTools
 
        Registers an existing Eric Zimmerman tools folder so compose finds every tool,
        including EvtxeCmd\EvtxECmd.exe and RECmd\RECmd.exe in their subfolders.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [string]$Name,
        [switch]$List,
        [string]$ToolRoot,
        [string]$ArchivePath,
        [string]$Path,
        [string]$CatalogPath,
        [switch]$Force
    )

    if ($Path) {
        if (-not $Name) { throw 'Use -Name with -Path (for example -Name EZTools -Path C:\Tools\EZTools).' }
        # -Force skips the confirmation prompt only; -WhatIf still writes nothing.
        if ($Force) { $ConfirmPreference = 'None' }
        $manifest = Get-FotonToolManifestPath -ToolRoot $ToolRoot
        if (-not $PSCmdlet.ShouldProcess($manifest, ("Register existing {0} install from {1}" -f $Name, $Path))) {
            return
        }
        $rows = @(Register-FotonLocalTool -Name $Name -Path $Path -ToolRoot $ToolRoot)
        Write-Host ''
        Write-Host ' Foton tool registration (existing install; nothing downloaded)' -ForegroundColor Cyan
        $rows | Select-Object Name, Status, Version, ExecutablePath | Format-Table -AutoSize | Out-Host
        Write-Host ' SHA-256 values in tools.json are observed on disk, not verified against an upstream hash.' -ForegroundColor Yellow
        Write-Host ' PATH was not changed. Compose resolves these tools via tools.json.' -ForegroundColor Green
        return $rows
    }

    $catalog = @(Get-FotonToolCatalog -CatalogPath $CatalogPath)
    if ($List -or -not $Name) {
        # One row per tool: pinned catalog entries (Installed when tools.json has them,
        # else Catalog with the download URL) and registered local installs (Registered,
        # observed hash only). Location is what compose will run.
        $installed = @(Get-FotonInstalledToolList -ToolRoot $ToolRoot)
        $rows = New-Object System.Collections.ArrayList
        foreach ($entry in $catalog) {
            $have = $installed | Where-Object { [string]$_.Name -eq [string]$entry.Name } | Select-Object -First 1
            $null = $rows.Add([PSCustomObject]@{
                    Name           = [string]$entry.Name
                    Version        = $(if ($have) { [string]$have.Version } else { [string]$entry.Version })
                    Source         = $(if ($have) { 'Installed' } else { 'Catalog' })
                    Location       = $(if ($have) { [string]$have.ExecutablePath } else { [string]$entry.DownloadUrl })
                    Verified       = [bool]$have
                    License        = [string]$entry.License
                    ExpectedSha256 = [string]$entry.ExpectedSha256
                    Notes          = [string]$entry.Notes
                })
        }
        $catalogNames = @($catalog | ForEach-Object { [string]$_.Name })
        foreach ($have in @($installed | Where-Object { $catalogNames -notcontains [string]$_.Name } | Sort-Object Name)) {
            $null = $rows.Add([PSCustomObject]@{
                    Name           = [string]$have.Name
                    Version        = [string]$have.Version
                    Source         = 'Registered'
                    Location       = [string]$have.ExecutablePath
                    Verified       = $false
                    License        = ''
                    ExpectedSha256 = ''
                    Notes          = ('Registered existing install; observed SHA-256 {0}, not verified against upstream.' -f [string]$have.ObservedSha256)
                })
        }
        # Default view keeps the console to one compact table; Format-List * shows the rest.
        $view = New-Object System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet', [string[]]@('Name', 'Version', 'Source', 'Location'))
        $members = [System.Management.Automation.PSMemberInfo[]]@($view)
        foreach ($r in $rows) {
            $r | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $members -Force
        }
        if (-not $List -and -not $Name) {
            Write-Host " Usage: Install-FotonTool -Name Hayabusa (or -List)" -ForegroundColor Cyan
        }
        return @($rows)
    }

    $entry = Get-FotonToolCatalogEntry -Name $Name -CatalogPath $CatalogPath
    $root = Get-FotonToolRoot -ToolRoot $ToolRoot
    $dest = Join-Path $root ([string]$entry.Name.ToLowerInvariant())
    $destVer = Join-Path $dest ([string]$entry.Version)

    Write-Host ''
    Write-Host ' Foton tool install (workstation setup - not part of a case package)' -ForegroundColor Cyan
    Write-Host (" Tool: {0} {1}" -f $entry.Name, $entry.Version)
    Write-Host (" License: {0} {1}" -f $entry.License, $entry.ProjectUrl)
    Write-Host (" Source: {0}" -f $(if ($ArchivePath) { $ArchivePath } else { $entry.DownloadUrl }))
    Write-Host (" SHA-256: {0}" -f $entry.ExpectedSha256)
    Write-Host (" Dest: {0}" -f $destVer)
    Write-Host (" Note: {0}" -f $entry.Notes)
    Write-Host ''

    $confirmText = ("Install {0} {1} into {2}?" -f $entry.Name, $entry.Version, $destVer)
    if (-not $Force) {
        if (-not $PSCmdlet.ShouldContinue($confirmText, 'Install-FotonTool')) {
            Write-Host ' Cancelled.' -ForegroundColor Yellow
            return
        }
    }
    # -Force skips prompts for automation/CI (NonInteractive has no ShouldProcess UI).

    New-Item -Path $root -ItemType Directory -Force | Out-Null
    $logPath = Join-Path $root 'install.log'
    $log = {
        param([string]$Msg)
        $line = '{0} UTC {1}' -f [datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss'), $Msg
        Add-Content -LiteralPath $logPath -Value $line -Encoding UTF8
        Write-Host (" {0}" -f $Msg)
    }

    $zipPath = $null
    $cleanupZip = $false
    try {
        if ($ArchivePath) {
            if (-not (Test-Path -LiteralPath $ArchivePath)) {
                throw ("Archive not found: {0}" -f $ArchivePath)
            }
            $zipPath = (Get-Item -LiteralPath $ArchivePath).FullName
            & $log ("Using local archive {0}" -f $zipPath)
        }
        else {
            $zipPath = Join-Path $env:TEMP ('foton-tool-' + [guid]::NewGuid().ToString('N') + '.zip')
            $cleanupZip = $true
            & $log ("Downloading {0}" -f $entry.DownloadUrl)
            # Prefer curl.exe when present (same path operators already use); fall back.
            $dlOk = $false
            if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
                & curl.exe -fsSL -o $zipPath $entry.DownloadUrl
                if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $zipPath)) { $dlOk = $true }
            }
            if (-not $dlOk) {
                $wc = New-Object System.Net.WebClient
                try { $wc.DownloadFile([string]$entry.DownloadUrl, $zipPath) }
                finally { $wc.Dispose() }
            }
            if (-not (Test-Path -LiteralPath $zipPath)) {
                throw 'Download produced no file. Use -ArchivePath for an offline install.'
            }
        }

        $hash = (Get-FotonSha256Hex -LiteralPath $zipPath).ToUpperInvariant()
        $expect = ([string]$entry.ExpectedSha256).ToUpperInvariant()
        & $log ("Archive SHA-256 {0}" -f $hash)
        if ($hash -ne $expect) {
            throw ("SHA-256 mismatch for {0}. Expected {1}, got {2}. Refusing to install." -f $entry.AssetName, $expect, $hash)
        }
        & $log 'Hash matches catalog pin.'

        if (Test-Path -LiteralPath $destVer) {
            Remove-Item -LiteralPath $destVer -Recurse -Force
        }
        New-Item -Path $destVer -ItemType Directory -Force | Out-Null
        Expand-Archive -LiteralPath $zipPath -DestinationPath $destVer -Force
        & $log ("Expanded to {0}" -f $destVer)

        $exe = $null
        foreach ($n in @($entry.ExecutableNames)) {
            $hit = @(Get-ChildItem -LiteralPath $destVer -Filter $n -File -Recurse -ErrorAction SilentlyContinue |
                    Select-Object -First 1)
            if ($hit.Count -gt 0) { $exe = $hit[0].FullName; break }
        }
        if (-not $exe) {
            throw ("Archive extracted but no executable named {0} was found under {1}." -f (
                    (@($entry.ExecutableNames) -join '/'), $destVer))
        }

        # Optional convenience link at dest\hayabusa.exe when nested in a version folder.
        $linkDir = $dest
        New-Item -Path $linkDir -ItemType Directory -Force | Out-Null
        $stable = Join-Path $linkDir ([IO.Path]::GetFileName($exe))
        Copy-Item -LiteralPath $exe -Destination $stable -Force
        $exeDir = Split-Path -Parent $exe
        foreach ($leaf in @('config', 'rules')) {
            $srcSide = Join-Path $exeDir $leaf
            $dstSide = Join-Path $linkDir $leaf
            if ((Test-Path -LiteralPath $srcSide) -and -not (Test-Path -LiteralPath $dstSide)) {
                Copy-Item -LiteralPath $srcSide -Destination $dstSide -Recurse -Force
            }
        }
        # Prefer the stable copy for the manifest so rules/config sit beside it.
        if (Test-Path -LiteralPath $stable) { $exe = $stable }

        $rulesVer = ("bundled-{0}" -f $entry.Version)
        $rulesPath = ''
        if ($entry.RulesRelativePath) {
            $rp = Join-Path (Split-Path -Parent $exe) ([string]$entry.RulesRelativePath)
            if (Test-Path -LiteralPath $rp) {
                $rulesPath = (Get-Item -LiteralPath $rp).FullName
                $gitHead = Join-Path $rp '.git\HEAD'
                if (Test-Path -LiteralPath $gitHead) {
                    try {
                        $h = (Get-Content -LiteralPath $gitHead -TotalCount 1).Trim()
                        if ($h -match 'ref:\s*refs/heads/(.+)$') {
                            $ref = Join-Path $rp ('.git\refs\heads\' + $Matches[1])
                            if (Test-Path -LiteralPath $ref) {
                                $rulesVer = ((Get-Content -LiteralPath $ref -TotalCount 1).Trim().Substring(0, [Math]::Min(12, 40)))
                            }
                        }
                        elseif ($h.Length -ge 12) { $rulesVer = $h.Substring(0, 12) }
                    }
                    catch { }
                }
            }
        }

        $record = [PSCustomObject]@{
            Name            = [string]$entry.Name
            Version         = [string]$entry.Version
            SourceUrl       = $(if ($ArchivePath) { $zipPath } else { [string]$entry.DownloadUrl })
            ArchiveSha256   = $hash
            InstallDateUtc  = [datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')
            InstallPath     = $destVer
            ExecutablePath  = $exe
            RulesPath       = $rulesPath
            RulesVersion    = $rulesVer
            License         = [string]$entry.License
        }
        $manPath = Save-FotonToolManifestEntry -ToolRoot $root -Entry $record
        & $log ("Wrote manifest {0}" -f $manPath)
        & $log ("Installed {0} {1} -> {2}" -f $entry.Name, $entry.Version, $exe)
        Write-Host ''
        Write-Host ' PATH was not changed. Compose resolves this tool via tools.json.' -ForegroundColor Green
        Write-Host ' Next: Foton.cmd compose <case-folder>' -ForegroundColor Green
        return $record
    }
    finally {
        if ($cleanupZip -and $zipPath -and (Test-Path -LiteralPath $zipPath)) {
            Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue
        }
    }
}