microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1a84554b682a27ab81f3bacac1f03a17a6c7656a

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/lib/Get-VerifiedDownload.ps1

394lines · modepreview

# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
#Requires -Version 7.0

<#
.SYNOPSIS
    Downloads and verifies artifacts using SHA256 checksums.

.DESCRIPTION
    Securely downloads files from URLs and verifies their integrity using
    SHA256 checksums before saving or extracting. Contains pure functions
    for testability and an I/O wrapper for orchestration.

.PARAMETER Url
    URL to download from.

.PARAMETER ExpectedSHA256
    Expected SHA256 checksum of the file.

.PARAMETER OutputPath   
    Path where the downloaded file will be saved.

.PARAMETER Extract
    Extract the archive after verification.

.PARAMETER ExtractPath
    Destination directory for extraction.

.EXAMPLE
    .\Get-VerifiedDownload.ps1 -Url "https://example.com/tool.tar.gz" -ExpectedSHA256 "abc123..." -OutputPath "./tool.tar.gz"

.EXAMPLE
    .\Get-VerifiedDownload.ps1 -Url "https://example.com/tool.tar.gz" -ExpectedSHA256 "abc123..." -OutputPath "./tool.tar.gz" -Extract -ExtractPath "./tools"

.EXAMPLE
    . .\Get-VerifiedDownload.ps1
    Invoke-VerifiedDownload -Url "https://example.com/file.zip" -DestinationDirectory "C:\downloads" -ExpectedHash "abc123..."
#>

#region Script Parameters

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [string]$Url,

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

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

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

    [Parameter(Mandatory = $false)]
    [string]$ExtractPath
)

#endregion

$ErrorActionPreference = 'Stop'

Import-Module (Join-Path $PSScriptRoot "Modules/CIHelpers.psm1") -Force

#region Pure Functions

function Get-FileHashValue {
    <#
    .SYNOPSIS
        Computes the hash of a file using the specified algorithm.
    .OUTPUTS
        System.String
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [ValidateSet('SHA256', 'SHA384', 'SHA512')]
        [string]$Algorithm
    )

    $hashResult = Get-FileHash -Path $Path -Algorithm $Algorithm
    return $hashResult.Hash
}

function Test-HashMatch {
    <#
    .SYNOPSIS
        Compares two hash strings for equality (case-insensitive).
    .OUTPUTS
        System.Boolean
    #>
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string]$ComputedHash,

        [Parameter(Mandatory)]
        [string]$ExpectedHash
    )

    return $ComputedHash.ToUpperInvariant() -eq $ExpectedHash.ToUpperInvariant()
}

function Get-DownloadTargetPath {
    <#
    .SYNOPSIS
        Resolves the target file path for a download operation.
    .OUTPUTS
        System.String
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Url,

        [Parameter(Mandatory)]
        [string]$DestinationDirectory,

        [Parameter()]
        [string]$FileName
    )

    if ([string]::IsNullOrWhiteSpace($FileName)) {
        $uri = [System.Uri]::new($Url)
        $FileName = [System.IO.Path]::GetFileName($uri.LocalPath)
    }

    return [System.IO.Path]::Combine($DestinationDirectory, $FileName)
}

function Test-ExistingFileValid {
    <#
    .SYNOPSIS
        Checks if an existing file matches the expected hash.
    .OUTPUTS
        System.Boolean
    #>
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [string]$ExpectedHash,

        [Parameter(Mandatory)]
        [ValidateSet('SHA256', 'SHA384', 'SHA512')]
        [string]$Algorithm
    )

    if (-not (Test-Path -Path $Path -PathType Leaf)) {
        return $false
    }

    $computedHash = Get-FileHashValue -Path $Path -Algorithm $Algorithm
    return Test-HashMatch -ComputedHash $computedHash -ExpectedHash $ExpectedHash
}

function New-DownloadResult {
    <#
    .SYNOPSIS
        Creates a standardized download result object.
    .OUTPUTS
        System.Collections.Hashtable
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [bool]$WasDownloaded,

        [Parameter(Mandatory)]
        [bool]$HashVerified
    )

    return @{
        Path         = $Path
        WasDownloaded = $WasDownloaded
        HashVerified = $HashVerified
    }
}

function Get-ArchiveType {
    <#
    .SYNOPSIS
        Determines the archive type from a URL or file path.
    .OUTPUTS
        System.String
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    switch -Regex ($Path) {
        '\.zip$' { return 'zip' }
        '\.(tar\.gz|tgz)$' { return 'tar.gz' }
        '\.tar$' { return 'tar' }
        default { return 'unknown' }
    }
}

function Test-TarAvailable {
    <#
    .SYNOPSIS
        Tests if the tar command is available.
    .OUTPUTS
        System.Boolean
    #>
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    $tarCmd = Get-Command -Name 'tar' -ErrorAction SilentlyContinue
    return $null -ne $tarCmd
}

#endregion

#region I/O Wrapper Function

function Invoke-VerifiedDownload {
    <#
    .SYNOPSIS
        Downloads and verifies a file with hash validation.
    .DESCRIPTION
        I/O wrapper that orchestrates download operations using pure functions
        for logic and handles all file system and network operations.
    .OUTPUTS
        System.Collections.Hashtable
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$Url,

        [Parameter(Mandatory)]
        [string]$DestinationDirectory,

        [Parameter(Mandatory)]
        [string]$ExpectedHash,

        [Parameter()]
        [ValidateSet('SHA256', 'SHA384', 'SHA512')]
        [string]$Algorithm = 'SHA256',

        [Parameter()]
        [string]$FileName,

        [Parameter()]
        [switch]$Extract,

        [Parameter()]
        [string]$ExtractPath
    )

    $targetPath = Get-DownloadTargetPath -Url $Url -DestinationDirectory $DestinationDirectory -FileName $FileName

    # Check if valid file already exists
    if (Test-Path $targetPath) {
        if (Test-ExistingFileValid -Path $targetPath -ExpectedHash $ExpectedHash -Algorithm $Algorithm) {
            Write-Verbose "File already exists and hash matches: $targetPath"
            return New-DownloadResult -Path $targetPath -WasDownloaded $false -HashVerified $true
        }
    }

    # Ensure destination directory exists
    if (-not (Test-Path $DestinationDirectory)) {
        New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
    }

    # Download to temp file first
    $tempFile = [System.IO.Path]::GetTempFileName()
    try {
        Write-Host "Downloading: $Url"
        Invoke-WebRequest -Uri $Url -OutFile $tempFile -UseBasicParsing

        $computedHash = Get-FileHashValue -Path $tempFile -Algorithm $Algorithm
        $verified = Test-HashMatch -ComputedHash $computedHash -ExpectedHash $ExpectedHash

        if (-not $verified) {
            throw "Checksum verification failed!`nExpected: $ExpectedHash`nActual:   $computedHash"
        }

        # Handle extraction or move
        if ($Extract) {
            $extractDir = if ($ExtractPath) { $ExtractPath } else { $DestinationDirectory }
            if (-not (Test-Path $extractDir)) {
                New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
            }

            $archiveType = Get-ArchiveType -Path $Url
            switch ($archiveType) {
                'zip' {
                    Write-Verbose "Extracting ZIP archive to $extractDir"
                    Expand-Archive -Path $tempFile -DestinationPath $extractDir -Force
                }
                'tar.gz' {
                    if (-not (Test-TarAvailable)) {
                        throw "tar command not available for .tar.gz extraction"
                    }
                    Write-Verbose "Extracting tar.gz archive to $extractDir"
                    tar -xzf $tempFile -C $extractDir
                    if ($LASTEXITCODE -ne 0) {
                        throw "tar extraction failed with exit code $LASTEXITCODE"
                    }
                }
                'tar' {
                    if (-not (Test-TarAvailable)) {
                        throw "tar command not available for .tar extraction"
                    }
                    Write-Verbose "Extracting tar archive to $extractDir"
                    tar -xf $tempFile -C $extractDir
                    if ($LASTEXITCODE -ne 0) {
                        throw "tar extraction failed with exit code $LASTEXITCODE"
                    }
                }
                default {
                    throw "Unsupported archive format for '$Url'. Supported: .zip, .tar.gz, .tgz, .tar"
                }
            }
        }
        else {
            Move-Item -Path $tempFile -Destination $targetPath -Force
        }

        Write-Host "Download verified and complete" -ForegroundColor Green
        return New-DownloadResult -Path $targetPath -WasDownloaded $true -HashVerified $true
    }
    finally {
        if (Test-Path $tempFile) {
            Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue
        }
    }
}

#endregion

#region Main Execution
if ($MyInvocation.InvocationName -ne '.') {
    try {
        # Require parameters for direct invocation
        if (-not $Url -or -not $ExpectedSHA256 -or -not $OutputPath) {
            Write-Error "When invoking directly, -Url, -ExpectedSHA256, and -OutputPath are required."
            exit 1
        }

        # Resolve destination directory and file name from OutputPath
        $destinationDir = Split-Path -Parent $OutputPath
        if (-not $destinationDir) {
            $destinationDir = $PWD.Path
        }
        $fileName = Split-Path -Leaf $OutputPath

        # Determine extract path
        $extractDir = $null
        if ($Extract) {
            $extractDir = if ($ExtractPath) { $ExtractPath } else { $destinationDir }
        }

        # Call the I/O wrapper function with script parameters
        $result = Invoke-VerifiedDownload `
            -Url $Url `
            -DestinationDirectory $destinationDir `
            -ExpectedHash $ExpectedSHA256 `
            -FileName $fileName `
            -Extract:$Extract `
            -ExtractPath $extractDir

        # Output the result for callers
        $result
        exit 0
    }
    catch {
        Write-Error -ErrorAction Continue "Get-VerifiedDownload failed: $($_.Exception.Message)"
        Write-CIAnnotation -Message $_.Exception.Message -Level Error
        exit 1
    }
}
#endregion Main Execution