Viscalyx.GitHub.psm1

#Region '.\prefix.ps1' -1

$script:dscResourceCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/DscResource.Common'
Import-Module -Name $script:dscResourceCommonModulePath

$script:viscalyxCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/Viscalyx.Common'
Import-Module -Name $script:viscalyxCommonModulePath

$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US'
#EndRegion '.\prefix.ps1' 8
#Region '.\Private\Convert-SecureStringAsPlainText.ps1' -1

<#
    .SYNOPSIS
        Converts a secure string to a plain text string.
 
    .DESCRIPTION
        This function converts a secure string to a plain text string by using
        the SecureStringToBSTR method and then converts the resulting BSTR
        to a string using PtrToStringBSTR. The function ensures that the
        memory allocated for the BSTR is properly freed to prevent memory leaks.
 
    .PARAMETER SecureString
        The secure string that should be converted to a plain text string.
 
    .EXAMPLE
        $securePassword = ConvertTo-SecureString -String 'P@ssw0rd' -AsPlainText -Force
        $plainTextPassword = Convert-SecureStringAsPlainText -SecureString $securePassword
 
        This example converts a secure string to a plain text string.
 
    .EXAMPLE
        $credential = Get-Credential
        $plainTextPassword = Convert-SecureStringAsPlainText -SecureString $credential.Password
 
        This example gets credentials from the user and converts the password
        secure string to a plain text string.
 
    .NOTES
        This function should be used with caution as it exposes secure strings
        as plain text which can be a security risk. Only use this function when
        absolutely necessary and in a secure context.
#>

function Convert-SecureStringAsPlainText
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Security.SecureString]
        $SecureString
    )

    Write-Verbose -Message $script:localizedData.Convert_SecureStringAsPlainText_Converting

    # cSpell: ignore BSTR
    $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)

    try
    {
        $plainText = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
    }
    finally
    {
        if ($bstr -ne [IntPtr]::Zero)
        {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        }
    }

    return $plainText
}
#EndRegion '.\Private\Convert-SecureStringAsPlainText.ps1' 62
#Region '.\Private\Invoke-UrlDownload.ps1' -1

<#
    .SYNOPSIS
        Downloads a file from a URL with progress indication.
 
    .DESCRIPTION
        The Invoke-UrlDownload function downloads a file from a specified URL to a
        local destination path. It uses PowerShell's Invoke-WebRequest to perform
        the download and relies on Invoke-WebRequest's built-in progress reporting
        rather than implementing its own Write-Progress callbacks. The function
        supports specifying a User-Agent header and includes robust error handling
        for HTTP errors (for example 401 and 404), network issues, and file system
        permission errors. The function will create the output directory if needed
        and honors the -Force switch to overwrite existing files.
 
    .PARAMETER Uri
        Specifies the URL from which to download the file.
 
    .PARAMETER OutputPath
        Specifies the local file path where the downloaded file will be saved.
 
    .PARAMETER UserAgent
        Specifies the User-Agent header to be used in the HTTP request.
        Defaults to 'Viscalyx.GitHub'.
 
    .PARAMETER Force
        Forces the download even if the file already exists at the specified output path.
        Without this parameter, the function will skip the download if the file exists.
 
    .EXAMPLE
        Invoke-UrlDownload -Uri 'https://example.com/file.zip' -OutputPath 'C:\Downloads\file.zip'
 
        Downloads a file from example.com and saves it to the specified path.
 
    .EXAMPLE
        Invoke-UrlDownload -Uri 'https://example.com/file.zip' -OutputPath 'C:\Downloads\file.zip' -Force
 
        Downloads a file from example.com and overwrites any existing file at the specified path.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        System.Boolean
 
        Returns $true when the download succeeds or when the download is skipped because
        the file already exists (when -Force is not specified). Returns $false when a
        non-terminating error occurs. A terminating error will throw and produce no
        return value.
 
    .NOTES
        This function is designed to be used internally by other commands within the module.
#>

function Invoke-UrlDownload
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Uri]
        $Uri,

        [Parameter(Mandatory = $true)]
        [System.String]
        $OutputPath,

        [Parameter()]
        [System.String]
        $UserAgent = 'Viscalyx.GitHub',

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $Force
    )

    # Validate and create output directory if needed
    $outputDirectory = Split-Path -Path $OutputPath -Parent

    if (-not [string]::IsNullOrWhiteSpace($outputDirectory))
    {
        if (-not (Test-Path -Path $outputDirectory))
        {
            try
            {
                Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_CreatingDirectory -f $outputDirectory)

                $null = New-Item -Path $outputDirectory -ItemType Directory -Force -ErrorAction Stop
            }
            catch
            {
                $errorMessage = $script:localizedData.Invoke_UrlDownload_DirectoryCreationError -f $outputDirectory, $_.Exception.Message

                $PSCmdlet.ThrowTerminatingError(
                    [System.Management.Automation.ErrorRecord]::new(
                        ($errorMessage),
                        'IUD0001',
                        [System.Management.Automation.ErrorCategory]::InvalidOperation,
                        $outputDirectory
                    )
                )
            }
        }
    }

    # Check if file already exists
    if (Test-Path -Path $OutputPath)
    {
        if ($Force)
        {
            Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_DownloadingFile -f $Uri, $OutputPath)
        }
        else
        {
            Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_SkippingDownload -f $OutputPath)
            return $true
        }
    }
    else
    {
        Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_DownloadingFile -f $Uri, $OutputPath)
    }

    try
    {
        $previousErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'

        # Create WebRequest parameters
        $webRequestParams = @{
            Uri         = $Uri
            OutFile     = $OutputPath
            UserAgent   = $UserAgent
            ErrorAction = 'Stop'
        }

        <#
            Add UseBasicParsing parameter only for Windows PowerShell 5.1.
            In PowerShell Core/7+, basic parsing is the default and the parameter
            is deprecated/removed
        #>

        if ($PSVersionTable.PSEdition -eq 'Desktop')
        {
            $webRequestParams.UseBasicParsing = $true
        }

        # Download the file using Invoke-WebRequest
        # This will handle the download and progress reporting automatically
        Invoke-WebRequest @webRequestParams

        $ErrorActionPreference = $previousErrorActionPreference

        Write-Verbose -Message ($script:localizedData.Invoke_UrlDownload_DownloadCompleted -f $OutputPath)

        return $true
    }
    catch
    {
        $ErrorActionPreference = $previousErrorActionPreference

        # Save the error record before entering any switch or other context-changing statements
        $errorRecord = $_

        # Determine the type of error and provide specific error message
        $errorMessage = $errorRecord.Exception.Message

        if ($errorRecord.Exception -is [System.Net.WebException])
        {
            $webException = $errorRecord.Exception -as [System.Net.WebException]

            # Check if there's an HTTP response
            if ($webException.Response)
            {
                $response = $webException.Response

                # Check if we can access StatusCode
                if ($response.StatusCode)
                {
                    $statusCode = $response.StatusCode.value__

                    switch ($statusCode)
                    {
                        401
                        {
                            $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_UnauthorizedError -f $Uri, $errorMessage) -ErrorRecord $errorRecord

                            Write-Error -Message ($script:localizedData.Invoke_UrlDownload_UnauthorizedError -f $Uri, $errorMessage) -Category SecurityError -ErrorId 'Invoke_UrlDownload_Unauthorized' -TargetObject $Uri -Exception $exception
                        }

                        404
                        {
                            $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NotFoundError -f $Uri, $errorMessage) -ErrorRecord $errorRecord

                            Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NotFoundError -f $Uri, $errorMessage) -Category ResourceUnavailable -ErrorId 'Invoke_UrlDownload_NotFound' -TargetObject $Uri -Exception $exception
                        }

                        default
                        {
                            $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -ErrorRecord $errorRecord

                            Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_NetworkError' -TargetObject $Uri -Exception $exception
                        }
                    }
                }
                else
                {
                    $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -ErrorRecord $errorRecord

                    Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_NetworkError' -TargetObject $Uri -Exception $exception
                }
            }
            else
            {
                $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -ErrorRecord $errorRecord

                Write-Error -Message ($script:localizedData.Invoke_UrlDownload_NetworkError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_NetworkError' -TargetObject $Uri -Exception $exception
            }
        }
        elseif ($errorRecord.Exception -is [System.UnauthorizedAccessException] -or
                ($errorRecord.Exception -is [System.IO.IOException] -and $errorMessage -match 'denied|access'))
        {
            $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_PermissionError -f $OutputPath, $errorMessage) -ErrorRecord $errorRecord

            Write-Error -Message ($script:localizedData.Invoke_UrlDownload_PermissionError -f $OutputPath, $errorMessage) -Category PermissionDenied -ErrorId 'Invoke_UrlDownload_PermissionError' -TargetObject $OutputPath -Exception $exception
        }
        else
        {
            $exception = New-Exception -Message ($script:localizedData.Invoke_UrlDownload_UnknownError -f $Uri, $errorMessage) -ErrorRecord $errorRecord

            Write-Error -Message ($script:localizedData.Invoke_UrlDownload_UnknownError -f $Uri, $errorMessage) -Category NotSpecified -ErrorId 'Invoke_UrlDownload_UnknownError' -TargetObject $Uri -Exception $exception
        }

        return $false
    }
}
#EndRegion '.\Private\Invoke-UrlDownload.ps1' 235
#Region '.\Public\Get-GitHubRelease.ps1' -1

<#
    .SYNOPSIS
        Gets releases from a GitHub repository.
 
    .DESCRIPTION
        Gets releases from a GitHub repository. By default, it returns all non-prerelease
        and non-draft releases. Use the Latest parameter to return only the most
        recent release. Use the IncludePrerelease parameter to include prerelease
        versions, and the IncludeDraft parameter to include draft releases in the
        results.
 
    .PARAMETER OwnerName
        The name of the repository owner.
 
    .PARAMETER RepositoryName
        The name of the repository.
 
    .PARAMETER Latest
        If specified, only returns the most recent release that matches other filter
        criteria.
 
    .PARAMETER IncludePrerelease
        If specified, prerelease versions will be included in the results.
 
    .PARAMETER IncludeDraft
        If specified, draft releases will be included in the results.
 
    .PARAMETER Token
        The GitHub personal access token to use for authentication. If not specified,
        the function will use anonymous access which has rate limits.
 
    .EXAMPLE
        Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell'
 
        Gets all non-prerelease, non-draft releases from the PowerShell/PowerShell repository.
 
    .EXAMPLE
        Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -Latest
 
        Gets the latest non-prerelease, non-draft release from the PowerShell/PowerShell repository.
 
    .EXAMPLE
        Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -IncludePrerelease
 
        Gets all releases including prereleases (but excluding drafts) from the PowerShell/PowerShell repository.
 
    .EXAMPLE
        Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -IncludePrerelease -IncludeDraft
 
        Gets all releases including prereleases and drafts from the PowerShell/PowerShell repository.
 
    .NOTES
        For more information about GitHub releases, see the GitHub REST API documentation:
        https://docs.github.com/en/rest/releases/releases
#>

function Get-GitHubRelease
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $OwnerName,

        [Parameter(Mandatory = $true)]
        [System.String]
        $RepositoryName,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $Latest,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $IncludePrerelease,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $IncludeDraft,

        [Parameter()]
        [System.Security.SecureString]
        $Token
    )

    Write-Verbose -Message ($script:localizedData.Get_GitHubRelease_ProcessingRepository -f $OwnerName, $RepositoryName)

    $apiBaseUrl = 'https://api.github.com/repos/{0}/{1}/releases' -f $OwnerName, $RepositoryName

    $headers = @{
        Accept = 'application/vnd.github.v3+json'
    }

    if ($Token)
    {
        # Convert SecureString to plain text for the authorization header
        $plainTextToken = Convert-SecureStringAsPlainText -SecureString $Token

        $headers.Authorization = "Bearer $plainTextToken"
    }

    try
    {
        $releases = Invoke-RestMethod -Uri $apiBaseUrl -Headers $headers -Method 'Get' -ErrorAction 'Stop'
    }
    catch
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Get_GitHubRelease_Error_ApiRequest -f $_.Exception.Message
            Category     = 'ObjectNotFound'
            ErrorId      = 'GGHR0001' # cSpell: disable-line
            TargetObject = '{0}/{1}' -f $OwnerName, $RepositoryName
        }

        Write-Error @writeErrorParameters

        return $null
    }

    if (-not $releases -or $releases.Count -eq 0)
    {
        Write-Verbose -Message ($script:localizedData.Get_GitHubRelease_NoReleasesFound -f $OwnerName, $RepositoryName)

        return $null
    }

    if (-not $IncludePrerelease)
    {
        Write-Verbose -Message $script:localizedData.Get_GitHubRelease_FilteringPrerelease

        $releases = $releases |
            Where-Object -FilterScript { -not $_.prerelease }
    }

    if (-not $IncludeDraft)
    {
        Write-Verbose -Message $script:localizedData.Get_GitHubRelease_FilteringDraft

        $releases = $releases |
            Where-Object -FilterScript { -not $_.draft }
    }

    if ($Latest)
    {
        <#
            Sort by created_at descending to get the most recent release. The
            created_at attribute is the date of the commit used for the release,
            and not the date when the release was drafted or published.
        #>

        $latestRelease = $releases |
            Sort-Object -Property 'created_at' -Descending |
            Select-Object -First 1

        return $latestRelease
    }

    return $releases
}
#EndRegion '.\Public\Get-GitHubRelease.ps1' 159
#Region '.\Public\Get-GitHubReleaseAsset.ps1' -1

<#
    .SYNOPSIS
        Gets metadata for a specific asset from a GitHub repository release.
 
    .DESCRIPTION
        This command retrieves metadata information about assets from releases of
        a GitHub repository.
 
        You can use the Latest parameter to specifically target the latest release,
        and the IncludePrerelease parameter to include prerelease versions when
        determining the latest release. The IncludeDraft parameter allows including
        draft releases in the results.
 
        The command returns metadata including the asset name, size, download URL,
        release version, and other relevant information for matching assets.
 
        This command can either fetch releases directly from GitHub or accept
        pre-fetched release objects through the InputObject parameter.
 
    .PARAMETER InputObject
        One or more release objects from GitHub. These objects should be the output
        from Get-GitHubRelease. This parameter enables piping releases directly into
        this command.
 
    .PARAMETER OwnerName
        The name of the repository owner.
 
    .PARAMETER RepositoryName
        The name of the repository.
 
    .PARAMETER Latest
        If specified, only returns assets from the latest release based on semantic
        versioning.
 
    .PARAMETER IncludePrerelease
        If specified, prerelease versions will be included in the release results.
 
    .PARAMETER IncludeDraft
        If specified, draft releases will be included in the release results.
 
    .PARAMETER AssetName
        The name of the asset to retrieve metadata for.
        You can use wildcards to match the asset name.
        If not specified, all assets from the matching release will be returned.
 
    .PARAMETER Token
        The GitHub personal access token to use for authentication.
        If not specified, the command will use anonymous access which has rate limits.
 
    .EXAMPLE
        Get-GitHubReleaseAsset -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -AssetName 'PowerShell-*-win-x64.msi'
 
        This example retrieves metadata for the Windows x64 MSI installer asset from the latest
        full release of the PowerShell/PowerShell repository.
 
    .EXAMPLE
        Get-GitHubReleaseAsset -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -AssetName 'PowerShell-*-win-x64.msi' -IncludePrerelease
 
        This example retrieves metadata for the Windows x64 MSI installer asset from the latest
        release (including prereleases) of the PowerShell/PowerShell repository.
 
    .EXAMPLE
        Get-GitHubReleaseAsset -OwnerName 'Microsoft' -RepositoryName 'WSL' -AssetName '*.x64' -Token 'ghp_1234567890abcdef'
 
        This example retrieves metadata for the AppX bundle asset from the latest release of the
        Microsoft/WSL repository using a GitHub personal access token for authentication.
 
    .EXAMPLE
        Get-GitHubReleaseAsset -OwnerName 'PowerShell' -RepositoryName 'PowerShell' -IncludePrerelease -IncludeDraft
 
        This example retrieves metadata for all assets from all releases, including prereleases and drafts,
        of the PowerShell/PowerShell repository.
 
    .EXAMPLE
        Get-GitHubRelease -OwnerName 'PowerShell' -RepositoryName 'PowerShell' | Get-GitHubReleaseAsset -AssetName 'PowerShell-*-win-x64.msi'
 
        This example pipes releases from Get-GitHubRelease and retrieves metadata for the Windows x64 MSI installer assets.
 
    .NOTES
        This command requires internet connectivity to access the GitHub API when using the ByRepository parameter set.
        GitHub API rate limits may apply for unauthenticated requests.
#>

function Get-GitHubReleaseAsset
{
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the examples are syntactically correct. The rule does not seem to understand that there is pipeline input.')]
    [CmdletBinding(DefaultParameterSetName = 'ByRepository')]
    [OutputType([PSCustomObject])]
    param
    (
        [Parameter(Mandatory = $true, ParameterSetName = 'ByInputObject', ValueFromPipeline = $true)]
        [PSCustomObject[]]
        $InputObject,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByRepository')]
        [System.String]
        $OwnerName,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByRepository')]
        [System.String]
        $RepositoryName,

        [Parameter(ParameterSetName = 'ByRepository')]
        [System.Management.Automation.SwitchParameter]
        $Latest,

        [Parameter(ParameterSetName = 'ByRepository')]
        [System.Management.Automation.SwitchParameter]
        $IncludePrerelease,

        [Parameter(ParameterSetName = 'ByRepository')]
        [System.Management.Automation.SwitchParameter]
        $IncludeDraft,

        [Parameter(ParameterSetName = 'ByRepository')]
        [System.Security.SecureString]
        $Token,

        [Parameter()]
        [System.String]
        $AssetName
    )

    begin
    {
        $releases = @()
    }

    process
    {
        if ($PSCmdlet.ParameterSetName -eq 'ByRepository')
        {
            $getGitHubReleaseParameters = @{} + $PSBoundParameters
            $getGitHubReleaseParameters.Remove('AssetName')

            $releaseFromRepo = Get-GitHubRelease @getGitHubReleaseParameters

            if (-not $releaseFromRepo)
            {
                return
            }

            $releases += $releaseFromRepo
        }
        else
        {
            $releases += $InputObject
        }
    }

    end
    {
        Write-Verbose -Message ($script:localizedData.Get_GitHubReleaseAsset_FoundRelease -f ($releases.name -join ', '))

        foreach ($release in $releases)
        {
            if ($AssetName)
            {
                # Find the requested asset using wildcard matching
                $matchingAssets = $release.assets |
                    Where-Object -FilterScript {
                        $_.name -like $AssetName
                    }

                if (-not $matchingAssets -or $matchingAssets.Count -eq 0)
                {
                    $writeErrorParameters = @{
                        Message      = $script:localizedData.Get_GitHubReleaseAsset_MissingAssetName
                        Category     = 'ObjectNotFound'
                        ErrorId      = 'GGHRAM0001' # cSpell: disable-line
                        TargetObject = $AssetName
                    }

                    Write-Error @writeErrorParameters

                    continue
                }
            }
            else
            {
                $matchingAssets = $release.assets
            }

            Write-Verbose -Message (
                $script:localizedData.Get_GitHubReleaseAsset_FoundAsset -f ($matchingAssets.name -join ', ')
            )

            $matchingAssets
        }
    }
}
#EndRegion '.\Public\Get-GitHubReleaseAsset.ps1' 191
#Region '.\Public\Save-GitHubReleaseAsset.ps1' -1

<#
    .SYNOPSIS
        Downloads GitHub release assets to a specified path.
 
    .DESCRIPTION
        The Save-GitHubReleaseAsset command downloads GitHub release assets
        to a specified local path. It can process multiple assets passed through
        the pipeline from Get-GitHubReleaseAsset and supports filtering by asset name.
        The command displays a progress bar during download to provide visual feedback
        to the user about the download status.
 
        The command can be used in two ways:
        1. By piping GitHub release asset objects from Get-GitHubReleaseAsset
        2. By directly specifying the download URI for an asset
 
    .PARAMETER InputObject
        Specifies the GitHub release asset objects to download. These objects
        are typically passed through the pipeline from Get-GitHubReleaseAsset.
 
    .PARAMETER Path
        Specifies the local path where the assets will be downloaded. If the
        path doesn't exist, it will be created.
 
    .PARAMETER AssetName
        Specifies a filter to download only assets that match the given name pattern.
        Wildcard characters are supported.
 
    .PARAMETER Uri
        Specifies the direct URI to a GitHub release asset to download. This parameter
        cannot be used together with InputObject.
 
    .PARAMETER MaxRetries
        Specifies the maximum number of retry attempts for failed downloads due to
        transient network issues. The default value is 3. Each retry uses exponential
        backoff (2^attempt seconds) before attempting the download again.
 
    .PARAMETER FileHash
        Specifies the expected SHA256 file hash for verification. Can be either:
        - A hashtable containing expected SHA256 file hashes keyed by asset names (for multiple files)
        - A string containing the expected SHA256 hash (for single file downloads)
        After each successful download, the actual file hash is computed and compared to
        the expected hash. If they differ, the downloaded file is deleted and an error
        is written. This parameter enables file integrity validation during the download
        process.
 
    .PARAMETER Overwrite
        Forces the download even if the file already exists at the specified output path.
        Without this parameter, the command will skip downloading files that already exist.
 
    .PARAMETER Force
        Suppresses all confirmation prompts and performs the operation without asking for
        user confirmation. This is useful for automation scenarios where user interaction
        is not possible or desired.
 
    .EXAMPLE
        $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads'
 
        Downloads all assets from PowerShell v7.3.0 release to the C:\Downloads directory.
 
    .EXAMPLE
        $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads' -AssetName '*win-x64*'
 
        Downloads only the Windows x64 assets from PowerShell v7.3.0 release to the C:\Downloads directory.
 
    .EXAMPLE
        Save-GitHubReleaseAsset -Uri 'https://github.com/PowerShell/PowerShell/releases/download/v7.3.0/PowerShell-7.3.0-win-x64.msi' -Path 'C:\Downloads'
 
        Downloads a specific PowerShell 7.3.0 MSI directly using its URI to the C:\Downloads directory.
 
    .EXAMPLE
        Save-GitHubReleaseAsset -Uri 'https://github.com/PowerShell/PowerShell/releases/download/v7.3.0/PowerShell-7.3.0-win-x64.msi' -Path 'C:\Downloads' -AssetName 'custom-name.msi'
 
        Downloads a specific PowerShell MSI and saves it as 'custom-name.msi' in the C:\Downloads directory.
 
    .EXAMPLE
        Save-GitHubReleaseAsset -Uri 'https://github.com/PowerShell/PowerShell/releases/download/v7.3.0/PowerShell-7.3.0-win-x64.msi' -Path 'C:\Downloads' -FileHash 'A1B2C3D4E5F6...'
 
        Downloads a PowerShell MSI and verifies its SHA256 hash matches the expected value. If the hash doesn't match, the downloaded file is deleted and an error is written.
 
    .EXAMPLE
        $fileHashes = @{ 'PowerShell-7.3.0-win-x64.msi' = 'A1B2C3D4E5F6...'; 'PowerShell-7.3.0-win-x86.msi' = 'F6E5D4C3B2A1...' }
        $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads' -AssetName '*win*.msi' -FileHash $fileHashes
 
        Downloads multiple PowerShell MSI files and verifies each file's SHA256 hash against the corresponding value in the hashtable. Files with mismatched hashes are deleted and errors are written.
 
    .EXAMPLE
        $inputObject = Get-GitHubReleaseAsset -Owner 'PowerShell' -Repository 'PowerShell' -Tag 'v7.3.0' ; Save-GitHubReleaseAsset -InputObject $inputObject -Path 'C:\Downloads' -Overwrite
 
        Downloads all assets from PowerShell v7.3.0 release to the C:\Downloads directory, overwriting any existing files.
 
    .INPUTS
        System.Management.Automation.PSObject
 
        Accepts GitHub release asset objects with 'name' and 'browser_download_url' properties.
 
    .OUTPUTS
        None
 
        This command does not generate output.
#>

function Save-GitHubReleaseAsset
{
    [CmdletBinding(DefaultParameterSetName = 'ByInputObject', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ByInputObject')]
        [AllowEmptyCollection()]
        [AllowNull()]
        [PSObject[]]
        $InputObject,

        [Parameter(Mandatory = $true)]
        [ValidateScript({
                if ((Test-Path -Path $_) -and -not (Test-Path -Path $_ -PathType Container))
                {
                    throw ($script:localizedData.Save_GitHubReleaseAsset_PathIsFile -f $_)
                }
                return $true
            })]
        [System.String]
        $Path,

        [Parameter(ParameterSetName = 'ByInputObject')]
        [Parameter(ParameterSetName = 'ByUri')]
        [System.String]
        $AssetName,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByUri')]
        [System.Uri]
        $Uri,

        [Parameter()]
        [ValidateRange(0, 10)]
        [System.Int32]
        $MaxRetries = 3,

        [Parameter()]
        [ValidateScript({
                $_ -is [System.Collections.Hashtable] -or $_ -is [System.String]
            })]
        [System.Object]
        $FileHash,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $Overwrite,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $Force
    )

    begin
    {
        if ($Force.IsPresent -and -not $Confirm)
        {
            $ConfirmPreference = 'None'
        }

        # Create a collection to store assets for processing
        $assetsToDownload = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'

        # Flag to track if we should skip processing
        $skipProcessing = $false

        # Ensure the output directory exists
        if (-not (Test-Path -Path $Path))
        {
            $shouldProcessDescriptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDescription -f $Path
            $shouldProcessConfirmationMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessConfirmation
            $shouldProcessCaptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessCaption

            if ($PSCmdlet.ShouldProcess($shouldProcessDescriptionMessage, $shouldProcessConfirmationMessage, $shouldProcessCaptionMessage))
            {
                Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_CreatingDirectory -f $Path)
                $null = New-Item -Path $Path -ItemType Directory -Force
            }
            else
            {
                # User declined to create the directory, set flag to skip processing
                $skipProcessing = $true
                return
            }
        }

        # If Uri parameter is used, create a custom object to represent the asset
        if ($PSCmdlet.ParameterSetName -eq 'ByUri')
        {
            $uriFileName = [System.IO.Path]::GetFileName($Uri.AbsolutePath)

            # If AssetName is specified, use that as the filename instead
            $fileName = if ($PSBoundParameters.ContainsKey('AssetName'))
            {
                $AssetName
            }
            else
            {
                $uriFileName
            }

            $assetObject = [PSCustomObject]@{
                name                 = $fileName
                browser_download_url = $Uri.AbsoluteUri
            }

            $assetsToDownload.Add($assetObject)

            Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_UsingDirectUri -f $Uri, $fileName)
        }
    }

    process
    {
        # Skip processing if user declined directory creation
        if ($skipProcessing)
        {
            return
        }

        # Only process pipeline input for ByInputObject parameter set
        if ($PSCmdlet.ParameterSetName -eq 'ByInputObject')
        {
            foreach ($asset in $InputObject)
            {
                # Skip assets that don't match the asset name filter if one is provided
                if ($PSBoundParameters.ContainsKey('AssetName'))
                {
                    if (-not ($asset.name -like $AssetName))
                    {
                        Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_AssetFiltered -f $asset.name, $AssetName)
                        continue
                    }
                }

                # Add asset to the collection
                $assetsToDownload.Add($asset)
            }
        }
    }

    end
    {
        # Skip processing if user declined directory creation
        if ($skipProcessing)
        {
            return
        }

        if ($assetsToDownload.Count -eq 0)
        {
            Write-Error -Message $script:localizedData.Save_GitHubReleaseAsset_NoAssetsToDownload -Category ObjectNotFound -ErrorId 'SGRA0003' -TargetObject $AssetName

            return
        }

        Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_DownloadingAssets -f $assetsToDownload.Count)

        # Download assets one by one with progress indicator
        for ($i = 0; $i -lt $assetsToDownload.Count; $i++)
        {
            $asset = $assetsToDownload[$i]
            $destination = Join-Path -Path $Path -ChildPath $asset.name
            $activityMessage = $script:localizedData.Save_GitHubReleaseAsset_Progress_DownloadingAssets_Status -f $asset.name, ($i + 1), $assetsToDownload.Count
            $percentComplete = [System.Math]::Round((($i + 1) / $assetsToDownload.Count) * 100)

            # Show progress
            Write-Progress -Activity $script:localizedData.Save_GitHubReleaseAsset_Progress_DownloadingAssets_Activity -Status $activityMessage -PercentComplete $percentComplete

            Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_DownloadingAsset -f $asset.name, $destination)

            # Check if user approves downloading this asset
            $shouldProcessDescriptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDownloadDescription -f $asset.name, $destination
            $shouldProcessConfirmationMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDownloadConfirmation
            $shouldProcessCaptionMessage = $script:localizedData.Save_GitHubReleaseAsset_ShouldProcessDownloadCaption

            if (-not $PSCmdlet.ShouldProcess($shouldProcessDescriptionMessage, $shouldProcessConfirmationMessage, $shouldProcessCaptionMessage))
            {
                Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_SkippedByUser -f $asset.name)
                continue
            }

            # Use the private function to download the file with retry logic
            $attempt = 0
            $downloadSuccessful = $false
            $lastException = $null

            while ($attempt -le $MaxRetries -and -not $downloadSuccessful)
            {
                $attempt++

                try
                {
                    $downloadResult = Invoke-UrlDownload -Uri $asset.browser_download_url -OutputPath $destination -Force:$Overwrite -ErrorAction Stop
                    $downloadSuccessful = $downloadResult
                }
                catch
                {
                    $lastException = $_

                    if ($attempt -le $MaxRetries)
                    {
                        # Calculate exponential backoff: 2^attempt seconds
                        $waitTime = [System.Math]::Pow(2, $attempt)

                        Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_RetryingDownload -f $asset.name, $attempt, $MaxRetries, $waitTime)

                        Start-Sleep -Seconds $waitTime
                    }
                    else
                    {
                        # Max retries exceeded
                        if ($ErrorActionPreference -eq 'Stop')
                        {
                            throw
                        }
                    }
                }
            }

            if (-not $downloadSuccessful)
            {
                # Download failed after all retries
                if ($ErrorActionPreference -eq 'Stop')
                {
                    throw ($script:localizedData.Save_GitHubReleaseAsset_DownloadFailed -f $asset.name)
                }
                else
                {
                    $errorMessage = $script:localizedData.Save_GitHubReleaseAsset_DownloadFailed -f $asset.name

                    if ($lastException)
                    {
                        # Create an ErrorRecord with full metadata using the captured exception
                        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $lastException.Exception,
                            'SGRA0006',
                            [System.Management.Automation.ErrorCategory]::OperationStopped,
                            $asset.name
                        )
                        $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($errorMessage)
                        Write-Error -ErrorRecord $errorRecord
                    }
                    else
                    {
                        # Fallback if no exception was captured
                        Write-Error -Message $errorMessage -Category OperationStopped -ErrorId 'SGRA0006' -TargetObject $asset.name
                    }
                }
            }

            # Verify file hash if FileHash parameter is provided and download was successful
            if ($downloadSuccessful -and $PSBoundParameters.ContainsKey('FileHash'))
            {
                # Determine the expected hash based on the type of FileHash parameter
                $expectedHash = if ($FileHash -is [System.String])
                {
                    # String type: use the hash for the current asset
                    $FileHash
                }
                elseif ($FileHash -is [System.Collections.Hashtable] -and $FileHash.ContainsKey($asset.name))
                {
                    # Hashtable type: look up the hash for this asset
                    $FileHash[$asset.name]
                }
                else
                {
                    # No hash available for this asset, skip validation
                    $null
                }

                if ($expectedHash)
                {
                    Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_VerifyingHash -f $asset.name)

                    $hashMatches = Test-FileHash -Path $destination -Algorithm SHA256 -ExpectedHash $expectedHash

                    if (-not $hashMatches)
                    {
                        # Hash mismatch - get actual hash before deleting, then delete the file and write error
                        $actualHash = (Get-FileHash -Path $destination -Algorithm SHA256).Hash
                        Remove-Item -Path $destination -Force

                        $errorMessage = $script:localizedData.Save_GitHubReleaseAsset_HashMismatch -f $asset.name, $expectedHash, $actualHash

                        if ($ErrorActionPreference -eq 'Stop')
                        {
                            throw $errorMessage
                        }
                        else
                        {
                            Write-Error -Message $errorMessage -Category InvalidData -ErrorId 'SGRA0002' -TargetObject $asset.name
                        }

                        # Skip further processing for this asset
                        continue
                    }
                    else
                    {
                        Write-Verbose -Message ($script:localizedData.Save_GitHubReleaseAsset_HashVerified -f $asset.name)
                    }
                }
            }
        }

        # Complete the progress bar
        Write-Progress -Activity $script:localizedData.Save_GitHubReleaseAsset_Progress_DownloadingAssets_Activity -Completed

        Write-Verbose -Message $script:localizedData.Save_GitHubReleaseAsset_DownloadsCompleted
    }
}
#EndRegion '.\Public\Save-GitHubReleaseAsset.ps1' 412