src/_Invoke-CciBlobRequest.ps1

function _Invoke-CciBlobRequest {
<#
.SYNOPSIS
    Minimal Azure Blob REST client (list / download / upload) using an Entra
    bearer token.
.DESCRIPTION
    Replaces Az.Storage for cciget's purposes - see _Get-CciEntraToken for why
    the Az modules are avoided on freshly built machines.
.PARAMETER Context
    Store context from _Connect-CciBlobStore (Account + Token).
.PARAMETER Operation
    List, Download, or Upload.
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Context,
        [Parameter(Mandatory)][ValidateSet('List','Download','Upload')][string]$Operation,
        [Parameter(Mandatory)][string]$Container,
        [string]$Prefix,
        [string]$Blob,
        [string]$Path
    )

    $base = "https://$($Context.Account).blob.core.windows.net"
    $headers = @{
        Authorization  = "Bearer $($Context.Token)"
        'x-ms-version' = '2021-08-06'
        'x-ms-date'    = [DateTime]::UtcNow.ToString('R')
    }

    # Every request goes through this so no failure can be swallowed. A bare
    # `-ErrorAction Stop` was not enough: during the first pilot, 403s from
    # partially propagated RBAC were written to the error stream while the
    # install loop carried on and reported success over a half-downloaded
    # module. Failures now throw, and only genuinely transient statuses retry.
    $transient = @(408, 429, 500, 502, 503, 504)
    $request = {
        param([hashtable]$Splat, [string]$What)
        for ($attempt = 1; ; $attempt++) {
            try {
                return Invoke-WebRequest @Splat -UseBasicParsing -ErrorAction Stop
            }
            catch {
                $status = 0
                $r = $_.Exception.Response
                if ($r -and $r.StatusCode) { $status = [int]$r.StatusCode }
                if ($transient -contains $status -and $attempt -lt 4) {
                    Start-Sleep -Seconds ([Math]::Pow(2, $attempt))
                    continue
                }
                $detail = if ($status) { "HTTP $status" } else { $_.Exception.Message }
                throw "cciget: blob request failed ($detail): $What"
            }
        }
    }

    switch ($Operation) {
        'List' {
            $names = [System.Collections.Generic.List[string]]::new()
            $marker = $null
            do {
                $uri = "$base/$Container`?restype=container&comp=list&maxresults=5000"
                if ($Prefix) { $uri += "&prefix=$([uri]::EscapeDataString($Prefix))" }
                if ($marker) { $uri += "&marker=$([uri]::EscapeDataString($marker))" }
                $resp = & $request @{ Uri = $uri; Headers = $headers } "list $Container/$Prefix"
                # Decode the body from raw bytes as UTF-8 rather than trusting
                # $resp.Content. The service returns the listing as UTF-8 with a
                # BOM but no charset in the Content-Type, and Windows PowerShell
                # 5.1 then falls back to ISO-8859-1: the BOM arrives as the three
                # characters "i>>?" instead of U+FEFF and the cast to [xml] fails
                # with "Cannot convert value ... to type System.Xml.XmlDocument".
                # PowerShell 7 decodes as UTF-8, so this only breaks on the
                # machine builds this path exists for.
                $bytes = $resp.RawContentStream.ToArray()
                $text = [System.Text.Encoding]::UTF8.GetString($bytes)
                if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
                [xml]$xml = $text
                foreach ($b in $xml.EnumerationResults.Blobs.Blob) { $names.Add($b.Name) }
                $marker = $xml.EnumerationResults.NextMarker
            } while ($marker)
            return $names
        }
        'Download' {
            $uri = "$base/$Container/$(($Blob -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/')"
            $dir = Split-Path $Path -Parent
            if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
            $null = & $request @{ Uri = $uri; Headers = $headers; OutFile = $Path } "download $Blob"
            if (-not (Test-Path $Path)) { throw "cciget: download reported success but produced no file: $Blob" }
            return $Path
        }
        'Upload' {
            $uri = "$base/$Container/$(($Blob -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/')"
            $bytes = [System.IO.File]::ReadAllBytes($Path)
            $putHeaders = $headers.Clone()
            $putHeaders['x-ms-blob-type'] = 'BlockBlob'
            $null = & $request @{
                Uri = $uri; Method = 'Put'; Headers = $putHeaders; Body = $bytes
                ContentType = 'application/octet-stream'
            } "upload $Blob"
            return $Blob
        }
    }
}