Private/Credential.ps1

# The user's saved credentials, by their target names only.
#
# Whether a credential is saved for an account is read off the names Windows' Credential
# Manager lists for the user who runs it, through cmdkey /list: it prints the target names
# and never a secret. Nothing here calls CredEnumerate or CredRead, which hand over the
# secret with the name. See ADR-0016.
#
# cmdkey's output is localised ("Ziel:", "Typ:" on a German Windows, "Cible :" on a French
# one), so it is read by position: the first line of each indented block is the target.
# Observed on a German Windows 11 26200, 2026-09-25: blocks of three or four lines,
# separated by empty lines or by lines of four spaces, the persistence line sometimes
# missing. First read for OutlookAuth (#24); the Nextcloud Kind reads it too (#185).

function ConvertFrom-CmdkeyList {
    <#
    .SYNOPSIS
        The target names out of cmdkey /list output, in the order listed. Pure.
    .DESCRIPTION
        Read by position, never by label, because the labels are localised: an entry is a
        block of indented lines, and its first line is the target, "<label>: <target>"
        (German "Ziel:", French "Cible :"). The label holds no colon, the target may, so
        the line is split at the first one. The "LegacyGeneric:target=" style prefix
        cmdkey puts in front says how the entry was stored and is dropped, leaving the
        name Credential Manager shows. The unindented heading and any line without a
        colon (an empty store's message) are not entries.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line = @())

    $inBlock = $false
    foreach ($l in @($Line)) {
        if (-not "$l".Trim()) { $inBlock = $false; continue }
        if ($inBlock) { continue }
        $inBlock = $true
        if ("$l" -notmatch '^\s+[^:]+:\s*(\S.*)$') { continue }
        $target = $Matches[1].Trim() -replace '^[A-Za-z]+:target=', ''
        if ($target) { $target }
    }
}

function Read-CredentialTargetName {
    <#
    .SYNOPSIS
        The target names of the credentials saved for whoever runs this, as cmdkey /list
        prints them: Targets, and Error where the store could not be listed. Asks only;
        reads no secret. Never throws.
    .DESCRIPTION
        Targets is $null where the store was not read, and an empty list where it was and
        holds nothing: a store with nothing in it is a different fact from one whose
        listing failed. Error is said in words, as the Report prints it: that cmdkey ended
        with an error instead of a list, or was not found. Never its bare exit code: cmdkey
        has no list of what its codes mean.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $targets = $null
    $problem = $null
    try {
        $listed = @(& cmdkey.exe /list 2>$null)
        if ($LASTEXITCODE -ne 0) { $problem = Get-Text 'Value.Credential.CmdkeyFailed' }
        else { $targets = @(ConvertFrom-CmdkeyList -Line $listed) }
    }
    catch [System.Management.Automation.CommandNotFoundException] { $problem = Get-Text 'Value.Credential.CmdkeyMissing' }
    catch { $problem = $_.Exception.Message }
    [pscustomobject]@{ Targets = $targets; Error = $problem }
}