Private/Get-InforcerSettingsCatalogPath.ps1

function Get-InforcerSettingsCatalogPath {
    <#
    .SYNOPSIS
        Resolves the path to settings.json via a 6-tier cache strategy.
    .DESCRIPTION
        Resolution order:
        1. Explicit path (if provided and file exists)
        2. Fresh local cache (< 24h old)
        3. Stale local cache (> 24h) with remote freshness check
        4. First-time download from GitHub Release
        5. Fallback: no cache, download failed -> $null (caller proceeds without catalog)
        6. Offline with stale cache -> return stale path with warning
    .PARAMETER ExplicitPath
        User-specified path to settings.json. If provided and valid, returned immediately.
    .PARAMETER CacheDirectory
        Override cache directory. Defaults to ~/.inforcercommunity/data.
    .PARAMETER BaseUrl
        Override base URL for GitHub Release assets. For testing.
    .PARAMETER MaxDownloadBytes
        Largest accepted settings.json or categories.json. A bigger or malformed download (not a
        JSON array of objects with an id) never replaces the cache. For testing.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$ExplicitPath,

        [Parameter()]
        [string]$CacheDirectory,

        [Parameter()]
        [string]$BaseUrl = 'https://github.com/royklo/IntuneSettingsCatalogData/releases/latest/download',

        [Parameter()]
        [long]$MaxDownloadBytes = 512MB
    )

    # ConvertFrom-Json may turn ISO timestamps into DateTime, so compare a normalized form.
    $toStamp = {
        param($v)
        if ($v -is [datetime]) { $v.ToUniversalTime().ToString('o') } elseif ($null -ne $v) { [string]$v }
    }
    $isCatalogFile = {
        param([string]$File)
        $item = Get-Item -LiteralPath $File -ErrorAction SilentlyContinue
        if (-not $item -or $item.Length -gt $MaxDownloadBytes) { return $false }
        $stream = [System.IO.File]::OpenRead($File)
        $doc = $null
        try {
            # Parses the whole file (rejects truncated or non-JSON content) without building PowerShell objects.
            $doc = [System.Text.Json.JsonDocument]::Parse($stream)
            $first = @($doc.RootElement.EnumerateArray() | Select-Object -First 1)
            $first.Count -eq 1 -and $first[0].GetProperty('id').ValueKind -eq [System.Text.Json.JsonValueKind]::String
        } catch {
            $false
        } finally {
            if ($doc) { $doc.Dispose() }
            $stream.Dispose()
        }
    }

    # Tier 1: Explicit path
    if (-not [string]::IsNullOrEmpty($ExplicitPath)) {
        if (Test-Path -LiteralPath $ExplicitPath) {
            Write-Verbose "Using explicit settings catalog path: $ExplicitPath"
            return $ExplicitPath
        }
        Write-Warning "Explicit settings catalog path not found: $ExplicitPath"
        return $null
    }

    # Resolve cache directory
    if ([string]::IsNullOrEmpty($CacheDirectory)) {
        $CacheDirectory = Join-Path ([System.Environment]::GetFolderPath('UserProfile')) '.inforcercommunity' 'data'
    }
    $settingsPath = Join-Path $CacheDirectory 'settings.json'
    $categoriesPath = Join-Path $CacheDirectory 'categories.json'
    $metaPath = Join-Path $CacheDirectory 'cache-meta.json'
    $hasCachedFile = Test-Path -LiteralPath $settingsPath

    # Read cache metadata
    $cacheMeta = $null
    if (Test-Path -LiteralPath $metaPath) {
        try {
            $cacheMeta = Get-Content -Path $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
        } catch {
            Write-Verbose "Could not read cache-meta.json: $_"
        }
    }

    # Tier 2: Fresh cache (< 24h)
    if ($hasCachedFile -and $null -ne $cacheMeta -and $cacheMeta.lastChecked) {
        # ConvertFrom-Json may auto-deserialize ISO strings to DateTime objects
        if ($cacheMeta.lastChecked -is [datetime]) {
            $lastChecked = $cacheMeta.lastChecked.ToUniversalTime()
        } else {
            $lastChecked = [datetime]::Parse($cacheMeta.lastChecked, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind)
        }
        $age = [datetime]::UtcNow - $lastChecked
        if ($age.TotalHours -lt 24) {
            Write-Verbose "Using fresh cached settings catalog (age: $([math]::Round($age.TotalHours, 1))h)"
            return $settingsPath
        }
    }

    # Tier 3/4: Need to check remote or download
    $lastUpdatedUrl = "$BaseUrl/last-updated.json"
    $settingsUrl = "$BaseUrl/settings.json"
    $categoriesUrl = "$BaseUrl/categories.json"

    # Save and restore ProgressPreference (disable progress bar for perf)
    $originalProgress = $ProgressPreference
    try {
        $ProgressPreference = 'SilentlyContinue'

        # Check remote freshness
        $remoteInfo = $null
        try {
            $response = Invoke-WebRequest -Uri $lastUpdatedUrl -UseBasicParsing -ErrorAction Stop
            # GitHub serves release assets as application/octet-stream, so Content is a byte[].
            $remoteJson = if ($response.Content -is [byte[]]) {
                [System.Text.Encoding]::UTF8.GetString($response.Content)
            } else { $response.Content -as [string] }
            $remoteInfo = $remoteJson | ConvertFrom-Json -Depth 100 -ErrorAction Stop
        } catch {
            Write-Verbose "Could not fetch last-updated.json: $_"
        }

        # Schema version check
        if ($null -ne $remoteInfo -and $remoteInfo.schemaVersion -and $remoteInfo.schemaVersion -ne 1) {
            Write-Warning "Settings catalog data has schema version $($remoteInfo.schemaVersion) but this module supports version 1. Update the InforcerCommunity module for full compatibility."
            if ($hasCachedFile) {
                Write-Warning 'Falling back to cached settings catalog.'
                return $settingsPath
            }
            return $null
        }

        # Tier 3: Stale cache -- check if remote is newer. An unknown timestamp on either side downloads.
        if ($hasCachedFile -and $null -ne $cacheMeta -and $null -ne $remoteInfo) {
            $cachedStamp = & $toStamp $cacheMeta.releaseTimestamp
            $remoteStamp = & $toStamp $remoteInfo.updatedAt
            if ($cachedStamp -and $remoteStamp -and $cachedStamp -eq $remoteStamp) {
                # Remote unchanged -- refresh lastChecked and use cache
                $cacheMeta.lastChecked = [datetime]::UtcNow.ToString('o')
                $cacheMeta | ConvertTo-Json -Depth 100 | Set-Content -Path $metaPath -Encoding UTF8
                Write-Verbose 'Remote data unchanged -- refreshed cache TTL'
                return $settingsPath
            }
            Write-Verbose 'Remote data is newer than cache -- downloading update'
        }

        # Tier 4: Download (first time or update needed)
        if ($null -ne $remoteInfo -or -not $hasCachedFile) {
            if (-not (Test-Path -LiteralPath $CacheDirectory)) {
                New-Item -ItemType Directory -Path $CacheDirectory -Force | Out-Null
            }

            $downloaded = $false
            $maxAttempts = 2  # Single retry
            for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
                try {
                    Write-Host 'Downloading Settings Catalog data...' -ForegroundColor Cyan
                    $tmpSettings = "$settingsPath.tmp"
                    $tmpCategories = "$categoriesPath.tmp"
                    Invoke-WebRequest -Uri $settingsUrl -OutFile $tmpSettings -UseBasicParsing -ErrorAction Stop
                    Invoke-WebRequest -Uri $categoriesUrl -OutFile $tmpCategories -UseBasicParsing -ErrorAction Stop
                    if (-not (& $isCatalogFile $tmpSettings) -or -not (& $isCatalogFile $tmpCategories)) {
                        Remove-Item -LiteralPath $tmpSettings, $tmpCategories -Force -ErrorAction SilentlyContinue
                        Write-Verbose "The downloaded Settings Catalog data is not a valid catalog or is larger than $MaxDownloadBytes bytes; the cache is not replaced."
                        break
                    }
                    Move-Item -LiteralPath $tmpSettings -Destination $settingsPath -Force
                    Move-Item -LiteralPath $tmpCategories -Destination $categoriesPath -Force

                    $downloaded = $true
                    break
                } catch {
                    # Clean up partial downloads
                    Remove-Item -Path "$settingsPath.tmp" -Force -ErrorAction SilentlyContinue
                    Remove-Item -Path "$categoriesPath.tmp" -Force -ErrorAction SilentlyContinue

                    if ($attempt -lt $maxAttempts) {
                        Write-Verbose "Download attempt $attempt failed: $_. Retrying in 2s..."
                        Start-Sleep -Seconds 2
                    } else {
                        Write-Verbose "Download failed after $maxAttempts attempts: $_"
                    }
                }
            }

            if ($downloaded) {
                # Update cache metadata
                $newMeta = @{
                    lastChecked     = [datetime]::UtcNow.ToString('o')
                    releaseTimestamp = $(if ($remoteInfo) { & $toStamp $remoteInfo.updatedAt } else { $null })
                    schemaVersion   = $(if ($remoteInfo) { $remoteInfo.schemaVersion } else { 1 })
                }
                $newMeta | ConvertTo-Json -Depth 100 | Set-Content -Path $metaPath -Encoding UTF8
                Write-Host ' Settings Catalog data cached successfully.' -ForegroundColor Green
                return $settingsPath
            }
        }

    } finally {
        $ProgressPreference = $originalProgress
    }

    # Tier 5/6: Download failed
    if ($hasCachedFile) {
        Write-Warning 'Could not refresh Settings Catalog data. Using stale cached version.'
        return $settingsPath
    }

    Write-Warning 'Settings Catalog data not available. Settings Catalog policies will show raw settingDefinitionId values.'
    return $null
}