Public/GitHubAccount.ps1

# Private helpers backing the GitHub-account-aware fetch in Sync-GitRemote.
# None of these are exported; they are dot-sourced with the rest of Public/.
#
# The mechanism keeps the globally-active `gh` account unchanged: a per-account
# token is acquired with `gh auth token` and injected into the git child
# process environment only. This is safe when several fetches run concurrently
# in the same process (a shared $env:GH_TOKEN would race). It supports any host
# `gh` manages, i.e. github.com and GitHub Enterprise (GHE) hosts alike.

# Values derived from a repository (remote-URL host/owner segments) and from
# `gh auth status` are passed to the `gh` executable. Validate them first so a
# hostile segment can't smuggle cmd.exe metacharacters into a launched process
# (CVE-2024-1874 / "BatBadBut" class), and so a bad value degrades to a graceful
# no-op rather than an error.
function Test-GitHubHostName {
    param([string]$HostName)
    # DNS host label set only; no scheme, port, path, or shell metacharacters.
    return [bool]($HostName -and $HostName -match '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$')
}

function Test-GitHubAccountName {
    param([string]$Account)
    # GitHub login rules: alphanumerics and single hyphens, no leading hyphen.
    return [bool]($Account -and $Account -match '^[A-Za-z0-9](?:-?[A-Za-z0-9])*$')
}

function Test-GhAvailable {
    return [bool](Get-Command gh -CommandType Application -ErrorAction SilentlyContinue)
}

function Get-GitHubSignedInAccount {
    <#
    .SYNOPSIS
    Enumerate the accounts signed in to `gh`, grouped by host.
    .DESCRIPTION
    Parses `gh auth status` and returns one object per signed-in account with
    its host, account name, and whether it is the active account for that host.
    Covers github.com and any GitHub Enterprise host `gh` is configured for.
    Returns nothing when `gh` is unavailable or no accounts are signed in.
    #>

    [OutputType('GitHubAccount')]
    [CmdletBinding()]
    param()

    if (-not (Test-GhAvailable)) { return }

    # `gh auth status` has no JSON/machine-readable form, so this parses its
    # English output ("Logged in to <host> account <user>" / "Active account:
    # true"). It is therefore coupled to that wording; if a future `gh` changes
    # it, the Active flag only affects candidate ordering, so a parse miss
    # degrades gracefully to "try all accounts" rather than failing.
    $output = & gh auth status 2>&1
    if (-not $output) { return }

    $records = [System.Collections.Generic.List[psobject]]::new()
    $current = $null
    foreach ($line in $output) {
        $text = "$line"
        # "✓ Logged in to <host> account <user> (<source>)"
        if ($text -match 'Logged in to\s+(?<host>\S+)\s+account\s+(?<account>\S+)') {
            $current = [PSCustomObject]@{
                PSTypeName = 'GitHubAccount'
                Host       = $Matches.host.ToLowerInvariant()
                Account    = $Matches.account
                Active     = $false
            }
            $records.Add($current)
        }
        elseif ($current -and $text -match 'Active account:\s*(?<active>true|false)') {
            $current.Active = ($Matches.active -eq 'true')
        }
    }

    $records
}

function Get-GitHubRemoteInfo {
    <#
    .SYNOPSIS
    Extract the host and owner from a git remote URL.
    .DESCRIPTION
    Understands HTTPS/SSH URL form (scheme://[user@]host[:port]/owner/repo) and
    the scp-like form (user@host:owner/repo). Returns an object with a
    lower-cased Host and the Owner, or nothing when the URL is unrecognized.
    #>

    [OutputType('GitHubRemoteInfo')]
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Url)

    $gitHost = $null
    $owner = $null
    if ($Url -match '^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]+@)?(?<host>[^/:]+)(?::\d+)?/(?<owner>[^/]+)/') {
        $gitHost = $Matches.host
        $owner = $Matches.owner
    }
    elseif ($Url -match '^(?:[^@]+@)?(?<host>[^:/]+):(?<owner>[^/]+)/') {
        $gitHost = $Matches.host
        $owner = $Matches.owner
    }

    if (-not $gitHost -or -not $owner) { return }

    [PSCustomObject]@{
        PSTypeName = 'GitHubRemoteInfo'
        Host       = $gitHost.ToLowerInvariant()
        Owner      = $owner
    }
}

function Get-GitHubAccountToken {
    <#
    .SYNOPSIS
    Acquire a host+account token without switching the active account.
    .DESCRIPTION
    Wraps `gh auth token --hostname <host> --user <account>`. Returns the token
    string on success, or nothing on any failure (so callers can fall back).
    The globally-active `gh` account is never changed.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$HostName,
        [Parameter(Mandatory)][string]$Account
    )

    if (-not (Test-GitHubHostName $HostName) -or -not (Test-GitHubAccountName $Account)) {
        Write-Verbose "Refusing to request token for unsafe host/account '$HostName'/'$Account'."
        return
    }

    $token = & gh auth token --hostname $HostName --user $Account 2>$null
    if ($LASTEXITCODE -ne 0) { return }

    $token = "$token".Trim()
    if ($token) { $token }
}

function Get-GitHubAccountMapValue {
    <#
    .SYNOPSIS
    Look up an account for a host+owner in a user-supplied map (case-insensitive).
    .DESCRIPTION
    Accepts keys as "host/owner" or bare "owner" (owner-only entries apply on any
    host). Returns the mapped account name, or nothing when no entry matches.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [hashtable]$Map,
        [Parameter(Mandatory)][string]$HostName,
        [Parameter(Mandatory)][string]$Owner
    )

    if (-not $Map -or $Map.Count -eq 0) { return }

    $candidates = @("$HostName/$Owner", $Owner)
    foreach ($key in $Map.Keys) {
        foreach ($candidate in $candidates) {
            if ([string]::Equals("$key", $candidate, [System.StringComparison]::OrdinalIgnoreCase)) {
                $value = "$($Map[$key])".Trim()
                if ($value) { return $value }
            }
        }
    }
}

function Invoke-GitWithEnvironment {
    <#
    .SYNOPSIS
    Run git with extra environment variables set on the child process only.
    .DESCRIPTION
    Uses a redirected .NET process so GH_TOKEN/GH_HOST are visible to the git
    child (and the `gh` credential helper it invokes) without touching the
    caller's $env: — safe under same-process parallel callers. Captures both
    stdout and stderr (git fetch writes ref updates to stderr) and returns an
    object with ExitCode and the merged Output lines.
    #>

    [OutputType('GitInvocationResult')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string[]]$Arguments,
        [hashtable]$Environment
    )

    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    $psi.FileName = 'git'
    foreach ($arg in $Arguments) { $psi.ArgumentList.Add($arg) }
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true
    $psi.UseShellExecute = $false
    if ($Environment) {
        foreach ($key in $Environment.Keys) {
            $psi.EnvironmentVariables[$key] = [string]$Environment[$key]
        }
    }

    $proc = [System.Diagnostics.Process]::new()
    $proc.StartInfo = $psi
    $null = $proc.Start()
    # Read stdout async while draining stderr synchronously to avoid a
    # full-pipe deadlock when either stream fills its buffer.
    $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
    $stderrText = $proc.StandardError.ReadToEnd()
    $proc.WaitForExit()
    $stdoutText = $stdoutTask.GetAwaiter().GetResult()
    $exitCode = $proc.ExitCode
    $proc.Dispose()

    $lines = [System.Collections.Generic.List[string]]::new()
    foreach ($block in @($stdoutText, $stderrText)) {
        if ($block) {
            foreach ($l in ($block -split "\r?\n")) { $lines.Add($l) }
        }
    }

    [PSCustomObject]@{
        PSTypeName = 'GitInvocationResult'
        ExitCode   = $exitCode
        Output     = $lines.ToArray()
    }
}

function Test-GitHubAuthFailure {
    <#
    .SYNOPSIS
    Heuristically decide whether git fetch output indicates an auth/access
    failure worth retrying with a different account (vs a real error).
    #>

    [OutputType([bool])]
    [CmdletBinding()]
    param([string[]]$Output)

    $joined = ($Output -join "`n")
    return [bool]($joined -match 'Authentication failed|could not read Username|terminal prompts disabled|403 Forbidden|Repository not found|Permission (?:to .*)?denied|access denied|401 Unauthorized')
}