Public/Get-NSPFortinetFirmware.ps1

function Get-NSPFortinetFirmware {
    <#
    .SYNOPSIS
        Lists firmware files on the Fortinet support portal for a product, release and model: the
        latest patch of a branch (7.6) or an exact one (7.6.7).
    .DESCRIPTION
        Reads the portal's firmware download tree (the Downloads > Firmware Download page), which is
        laid out as <Product>\v<major>.00\<branch>\<patch>, for example FortiGate\v7.00\7.6\7.6.7.
        Run Connect-NSPFortinetSupport first. Nothing is downloaded; pipe the result to
        Save-NSPFortinetFirmware.

        Returns one object per file:
          Product, Version the product and the patch folder (7.6.7)
          Model the model from the file name: FGT_1001F-v7.6.7... gives 1001F; other
                             families keep their prefix (FWF_60F, FGR_60F); empty for other files
          Name, SizeKB, Date as the portal lists them
          HasChecksum whether the portal publishes a checksum for the file
          FilePath the portal path, used to request the download
    .PARAMETER Product
        The portal's product name, as the firmware page lists it. Default FortiGate.
    .PARAMETER Version
        A branch (7.6: its newest patch) or a patch (7.6.7). Several are allowed. Fortinet
        publishes a patch's images in stages, so with -Model a model missing from the newest patch
        comes from the newest of the last 3 patches that has it (7.6.7 until 7.6.8 has it).
    .PARAMETER Model
        Models to keep, matched against Model or the file name's prefix_model (1001F, FWF_60F,
        VM64*, FGT_100F). Wildcards work. A file with no model in its name (release notes, MIBs)
        is left out when this is used.
    .PARAMETER Filter
        Wildcards on the whole file name (*.out). Applied after -Model.
    .PARAMETER Path
        A portal folder to list directly, for products laid out differently
        (FortiGate\v7.00\7.6\7.6.7). Replaces -Version.
    .EXAMPLE
        Get-NSPFortinetFirmware -Version 7.6 -Model 1001F, 100F
    .EXAMPLE
        Get-NSPFortinetFirmware -Version 7.4, 7.6 -Model 60F -Filter *.out | Save-NSPFortinetFirmware -Destination D:\Firmware
    .EXAMPLE
        Get-NSPFortinetFirmware -Version 7.6.7 | Out-GridView -PassThru | Save-NSPFortinetFirmware -Destination D:\Firmware
    #>

    [CmdletBinding(DefaultParameterSetName = 'Version')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(ValueFromPipelineByPropertyName)][string]$Product = 'FortiGate',
        [Parameter(ParameterSetName = 'Version', Mandatory, ValueFromPipelineByPropertyName)][ValidatePattern('^\d+\.\d+(\.\d+)?$')][string[]]$Version,
        [Parameter(ValueFromPipelineByPropertyName)][string[]]$Model,
        [Parameter(ValueFromPipelineByPropertyName)][string[]]$Filter,
        [Parameter(ParameterSetName = 'Path', Mandatory)][string]$Path
    )

    process {
        $list = { param([string]$Folder) Invoke-NSPFortinetSupportApi -Path 'ticket/api/tk/download/firmwareDownloadInfo' -Body @{ productId = $Product; relativePath = $Folder } }
        # Each request is a list of folders to try, newest first. Only a branch has more than one.
        $requests = @(if ($PSCmdlet.ParameterSetName -eq 'Path') { [pscustomobject]@{ Folders = @($Path.Trim('\')) } } else {
                foreach ($wanted in $Version) {
                    $parts = $wanted.Split('.')
                    $branch = "$($parts[0]).$($parts[1])"
                    # Walk down: the branch folder may sit under v<major>.00 or straight under the product.
                    $current = $Product
                    $inMajorFolder = $false
                    while ($true) {
                        $names = @((& $list $current).data | Where-Object { -not $_.isFile } | ForEach-Object name)
                        if ($names -contains $branch) { $current = "$current\$branch"; break }
                        $majorFolder = @($names | Where-Object { $_ -match "^v$($parts[0])\.\d+$" } | Select-Object -First 1)
                        if (-not $majorFolder.Count -or $inMajorFolder) { throw "$Product has no $branch folder under '$current' (found: $($names -join ', '))." }
                        $current = "$current\$($majorFolder[0])"
                        $inMajorFolder = $true
                    }
                    $patches = @((& $list $current).data | Where-Object { -not $_.isFile -and $_.name -match "^$([regex]::Escape($branch))\.\d+$" } | ForEach-Object name)
                    if ($parts.Count -eq 3) {
                        if ($patches -notcontains $wanted) { throw "$Product $wanted is not on the portal (under '$current': $($patches -join ', '))." }
                        [pscustomobject]@{ Folders = @("$current\$wanted") }
                    } else {
                        if (-not $patches.Count) { throw "$Product $branch has no patch folders under '$current'." }
                        # Fortinet publishes a patch's images in stages, so a model may only be in an
                        # earlier patch for a while: with -Model, look back up to $lookBack patches.
                        $lookBack = if ($Model) { 3 } else { 1 }
                        $newest = @($patches | Sort-Object { [version]$_ } -Descending | Select-Object -First $lookBack)
                        [pscustomobject]@{ Folders = @($newest | ForEach-Object { "$current\$_" }) }
                    }
                }
            })

        foreach ($request in $requests) {
            $found = [Collections.Generic.List[string]]::new()
            foreach ($folder in $request.Folders) {
                # Stop once every -Model pattern has a file from a newer patch.
                if ($found.Count -and -not @($Model | Where-Object { $pattern = $_; -not @($found | Where-Object { $_ -like $pattern }).Count }).Count) { break }
                $patch = Split-Path -Leaf $folder
                $fromThisPatch = [Collections.Generic.List[string]]::new()
                foreach ($item in @((& $list $folder).data | Where-Object isFile)) {
                    # FGT_1001F-v7.6.7... is 1001F. Other families in the same folder keep their prefix
                    # (FWF_60F, FGR_60F), so a FortiWiFi 60F is never mistaken for a FortiGate 60F.
                    $fileModel = ''
                    $fullModel = ''
                    if ($item.name -match '^(?<family>[A-Za-z0-9]+)_(?<model>[^-]+?)-v\d') {
                        $fullModel = "$($Matches['family'])_$($Matches['model'])"
                        $fileModel = if ($Matches['family'] -eq 'FGT') { $Matches['model'] } else { $fullModel }
                    }
                    if ($Model -and -not ($fileModel -and @($Model | Where-Object { $fileModel -like $_ -or $fullModel -like $_ }).Count)) { continue }
                    if ($Filter -and -not @($Filter | Where-Object { $item.name -like $_ }).Count) { continue }
                    # A model a newer patch already supplied is not taken again from an older one.
                    if ($fileModel -and $found.Contains($fileModel)) { continue }
                    if ($fileModel) { $fromThisPatch.Add($fileModel) }
                    [pscustomobject]@{
                        PSTypeName  = 'NSP.FortiGate.FortinetFirmware'
                        Product     = $Product
                        Version     = $patch
                        Model       = $fileModel
                        Name        = $item.name
                        SizeKB      = [long]$item.size
                        Date        = $item.dateModified
                        HasChecksum = [bool]$item.displayChecksum
                        FilePath    = "$folder\$($item.name)"
                    }
                }
                foreach ($name in $fromThisPatch) { if (-not $found.Contains($name)) { $found.Add($name) } }
            }
        }
    }
}