Support/Package/Schema/Eigenverft.Manifested.Sandbox.Package.RepositoryInventory.Management.ps1

<#
    Eigenverft.Manifested.Sandbox.Package - repository inventory management helpers.
#>


function Assert-PackageRepositoryId {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RepositoryId
    )

    if ([string]::IsNullOrWhiteSpace($RepositoryId)) {
        throw 'RepositoryId must not be empty.'
    }
    if ($RepositoryId -notmatch '^[A-Za-z][A-Za-z0-9_-]*$') {
        throw "RepositoryId '$RepositoryId' is invalid. Use letters, numbers, '-' or '_' and start with a letter."
    }
}

function Assert-PackageRepositorySource {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RepositoryId,

        [Parameter(Mandatory = $true)]
        [psobject]$SourceValue,

        [Parameter(Mandatory = $true)]
        [string]$DocumentPath
    )

    foreach ($requiredProperty in @('kind', 'enabled', 'searchOrder', 'trusted', 'trustMode')) {
        if (-not $SourceValue.PSObject.Properties[$requiredProperty]) {
            throw "Package repository source '$RepositoryId' in '$DocumentPath' is missing '$requiredProperty'."
        }
    }
    if ($SourceValue.PSObject.Properties['priority']) {
        throw "Package repository source '$RepositoryId' in '$DocumentPath' still uses retired property 'priority'. Use 'searchOrder'."
    }

    $kind = [string]$SourceValue.kind
    $trustMode = [string]$SourceValue.trustMode
    if ($kind -notin @('moduleLocal', 'filesystem', 'httpsCatalog')) {
        throw "Package repository source '$RepositoryId' in '$DocumentPath' has unsupported kind '$kind'."
    }
    if ($trustMode -notin @('moduleShipped', 'unsigned', 'unsignedExplicit', 'signedCatalog')) {
        throw "Package repository source '$RepositoryId' in '$DocumentPath' has unsupported trustMode '$trustMode'."
    }

    if ([string]::Equals($kind, 'moduleLocal', [System.StringComparison]::OrdinalIgnoreCase)) {
        if (-not $SourceValue.PSObject.Properties['definitionRoot'] -or [string]::IsNullOrWhiteSpace([string]$SourceValue.definitionRoot)) {
            throw "Package repository source '$RepositoryId' in '$DocumentPath' is missing definitionRoot."
        }
        if (-not [bool]$SourceValue.trusted -or -not [string]::Equals($trustMode, 'moduleShipped', [System.StringComparison]::OrdinalIgnoreCase)) {
            throw "Package repository source '$RepositoryId' in '$DocumentPath' kind moduleLocal must use trusted=true and trustMode='moduleShipped'."
        }
    }
    elseif ([string]::Equals($kind, 'filesystem', [System.StringComparison]::OrdinalIgnoreCase)) {
        if (-not $SourceValue.PSObject.Properties['basePath'] -or [string]::IsNullOrWhiteSpace([string]$SourceValue.basePath)) {
            throw "Package repository source '$RepositoryId' in '$DocumentPath' is missing basePath."
        }
        if ([bool]$SourceValue.trusted -and -not [string]::Equals($trustMode, 'unsignedExplicit', [System.StringComparison]::OrdinalIgnoreCase)) {
            throw "Package repository source '$RepositoryId' in '$DocumentPath' kind filesystem must use trustMode='unsignedExplicit' when trusted=true."
        }
        if ([string]::Equals($trustMode, 'unsignedExplicit', [System.StringComparison]::OrdinalIgnoreCase) -and -not [bool]$SourceValue.trusted) {
            throw "Package repository source '$RepositoryId' in '$DocumentPath' uses trustMode='unsignedExplicit' but trusted is false."
        }
    }
    elseif ([string]::Equals($kind, 'httpsCatalog', [System.StringComparison]::OrdinalIgnoreCase)) {
        foreach ($requiredHttpsProperty in @('baseUri', 'catalogPath')) {
            if (-not $SourceValue.PSObject.Properties[$requiredHttpsProperty] -or [string]::IsNullOrWhiteSpace([string]$SourceValue.$requiredHttpsProperty)) {
                throw "Package repository source '$RepositoryId' in '$DocumentPath' kind httpsCatalog is missing $requiredHttpsProperty."
            }
        }
    }
}

function Assert-PackageRepositoryInventorySchema {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$RepositoryInventoryDocumentInfo
    )

    $document = $RepositoryInventoryDocumentInfo.Document
    if (-not $document.PSObject.Properties['inventoryVersion']) {
        throw "Package repository inventory '$($RepositoryInventoryDocumentInfo.Path)' is missing inventoryVersion."
    }
    if (-not $document.PSObject.Properties['repositorySources'] -or $null -eq $document.repositorySources) {
        throw "Package repository inventory '$($RepositoryInventoryDocumentInfo.Path)' is missing repositorySources."
    }

    foreach ($sourceProperty in @($document.repositorySources.PSObject.Properties)) {
        Assert-PackageRepositorySource -RepositoryId $sourceProperty.Name -SourceValue $sourceProperty.Value -DocumentPath $RepositoryInventoryDocumentInfo.Path
    }
}

function Get-PackageRepositoryInventoryInfo {
    [CmdletBinding()]
    param()

    $inventoryPath = Get-PackageRepositoryInventoryPath
    $documentInfo = Read-PackageJsonDocument -Path $inventoryPath
    Assert-PackageRepositoryInventorySchema -RepositoryInventoryDocumentInfo $documentInfo
    $documentInfo | Add-Member -MemberType NoteProperty -Name Exists -Value $true -Force
    return $documentInfo
}

function Get-PackageRepositoryInventoryEditInfo {
    [CmdletBinding()]
    param()

    $documentInfo = Get-PackageRepositoryInventoryInfo
    if (-not $documentInfo.Document.PSObject.Properties['repositorySources'] -or $null -eq $documentInfo.Document.repositorySources) {
        $documentInfo.Document | Add-Member -MemberType NoteProperty -Name 'repositorySources' -Value ([pscustomobject]@{}) -Force
    }
    return $documentInfo
}

function Save-PackageRepositoryInventoryDocument {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$DocumentInfo
    )

    Assert-PackageRepositoryInventorySchema -RepositoryInventoryDocumentInfo $DocumentInfo

    $directory = Split-Path -Parent $DocumentInfo.Path
    if (-not [string]::IsNullOrWhiteSpace($directory)) {
        $null = New-Item -ItemType Directory -Path $directory -Force
    }

    $temporaryPath = '{0}.{1}.tmp' -f $DocumentInfo.Path, ([guid]::NewGuid().ToString('N'))
    try {
        $DocumentInfo.Document | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $temporaryPath -Encoding UTF8
        Move-Item -LiteralPath $temporaryPath -Destination $DocumentInfo.Path -Force
    }
    finally {
        if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
            Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
        }
    }
}

function Get-PackageRepositorySourceProperty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Document,

        [Parameter(Mandatory = $true)]
        [string]$RepositoryId
    )

    if (-not $Document.PSObject.Properties['repositorySources'] -or $null -eq $Document.repositorySources) {
        return $null
    }

    return $Document.repositorySources.PSObject.Properties[$RepositoryId]
}

function Get-PackageNextRepositorySearchOrder {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Document
    )

    $maxSearchOrder = 0
    foreach ($sourceProperty in @($Document.repositorySources.PSObject.Properties)) {
        if ($sourceProperty.Value -and $sourceProperty.Value.PSObject.Properties['searchOrder']) {
            $current = [int]$sourceProperty.Value.searchOrder
            if ($current -gt $maxSearchOrder) {
                $maxSearchOrder = $current
            }
        }
    }

    return ([int]([Math]::Ceiling(($maxSearchOrder + 1) / 100.0) * 100))
}

function Get-PackageRepositorySearchOrderAfter {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Document,

        [Parameter(Mandatory = $true)]
        [string]$AfterRepositoryId
    )

    $afterProperty = Get-PackageRepositorySourceProperty -Document $Document -RepositoryId $AfterRepositoryId
    if (-not $afterProperty) {
        throw "Package repository '$AfterRepositoryId' was not found, so no searchOrder can be placed after it."
    }
    if (-not $afterProperty.Value.PSObject.Properties['searchOrder']) {
        throw "Package repository '$AfterRepositoryId' has no searchOrder."
    }

    $afterOrder = [int]$afterProperty.Value.searchOrder
    $nextHigherOrder = $null
    foreach ($sourceProperty in @($Document.repositorySources.PSObject.Properties)) {
        if (-not $sourceProperty.Value -or -not $sourceProperty.Value.PSObject.Properties['searchOrder']) {
            continue
        }

        $currentOrder = [int]$sourceProperty.Value.searchOrder
        if ($currentOrder -gt $afterOrder -and ($null -eq $nextHigherOrder -or $currentOrder -lt $nextHigherOrder)) {
            $nextHigherOrder = $currentOrder
        }
    }

    if ($null -eq $nextHigherOrder) {
        return ($afterOrder + 100)
    }

    $candidate = [int][Math]::Floor(($afterOrder + $nextHigherOrder) / 2)
    if ($candidate -le $afterOrder -or $candidate -ge $nextHigherOrder) {
        throw "No integer searchOrder slot is available between '$AfterRepositoryId' ($afterOrder) and the next repository ($nextHigherOrder). Use -SearchOrder explicitly."
    }

    return $candidate
}

function New-PackageFilesystemRepositorySource {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$BasePath,

        [Parameter(Mandatory = $true)]
        [int]$SearchOrder,

        [bool]$Enabled = $true,

        [bool]$Trusted = $false,

        [AllowNull()]
        [string]$TrustReason = $null
    )

    if ([string]::IsNullOrWhiteSpace($BasePath)) {
        throw 'BasePath must not be empty.'
    }

    $source = [ordered]@{
        kind        = 'filesystem'
        enabled     = $Enabled
        searchOrder = $SearchOrder
        basePath    = $BasePath
        trusted     = $Trusted
        trustMode   = if ($Trusted) { 'unsignedExplicit' } else { 'unsigned' }
    }

    if ($Trusted) {
        $source['trustedAtUtc'] = [DateTime]::UtcNow.ToString('o')
        if (-not [string]::IsNullOrWhiteSpace($TrustReason)) {
            $source['trustReason'] = $TrustReason
        }
    }

    return [pscustomobject]$source
}

function Resolve-PackageRepositoryRootForDisplay {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Source,

        [AllowNull()]
        [string]$ApplicationRootDirectory = $null
    )

    $kind = [string]$Source.kind
    try {
        if ([string]::Equals($kind, 'moduleLocal', [System.StringComparison]::OrdinalIgnoreCase)) {
            return Resolve-ConfiguredPath -PathValue ([string]$Source.definitionRoot) -BaseDirectory (Split-Path -Parent (Get-PackageConfigurationRoot)) -Tokens @{}
        }
        if ([string]::Equals($kind, 'filesystem', [System.StringComparison]::OrdinalIgnoreCase)) {
            if (-not [string]::IsNullOrWhiteSpace($ApplicationRootDirectory)) {
                return Resolve-PackageConfiguredPath -PathValue ([string]$Source.basePath) -ApplicationRootDirectory $ApplicationRootDirectory
            }
            return Resolve-PackagePathValue -PathValue ([string]$Source.basePath)
        }
    }
    catch {
        return $null
    }

    return $null
}

function Get-PackageFileSha256 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        return $null
    }

    return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}

function Select-PackageRepositorySummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RepositoryId,

        [Parameter(Mandatory = $true)]
        [psobject]$Source,

        [Parameter(Mandatory = $true)]
        [string]$InventoryPath,

        [AllowNull()]
        [string]$ApplicationRootDirectory = $null
    )

    $enabled = if ($Source.PSObject.Properties['enabled']) { [bool]$Source.enabled } else { $true }
    $trusted = if ($Source.PSObject.Properties['trusted']) { [bool]$Source.trusted } else { $false }
    $kind = if ($Source.PSObject.Properties['kind']) { [string]$Source.kind } else { $null }
    $trustMode = if ($Source.PSObject.Properties['trustMode']) { [string]$Source.trustMode } else { $null }

    $notes = New-Object System.Collections.Generic.List[string]
    if (-not $enabled) {
        $notes.Add('Disabled; package commands will not use this repository.') | Out-Null
    }
    if (-not $trusted) {
        $notes.Add('Untrusted; definitions cannot be executed until the repository is trusted.') | Out-Null
    }
    if ($kind -eq 'httpsCatalog') {
        $notes.Add('HTTPS catalog repositories are reserved for future support and are not executable in v1.') | Out-Null
    }
    if ($trusted -and [string]::Equals($trustMode, 'unsignedExplicit', [System.StringComparison]::OrdinalIgnoreCase)) {
        $notes.Add('Unsigned definitions are trusted by explicit local configuration.') | Out-Null
    }

    return [pscustomobject]@{
        RepositoryId     = $RepositoryId
        Kind             = $kind
        Enabled          = $enabled
        Trusted          = $trusted
        TrustMode        = $trustMode
        Effective        = ($enabled -and $trusted -and $kind -in @('moduleLocal', 'filesystem'))
        SearchOrder      = if ($Source.PSObject.Properties['searchOrder']) { [int]$Source.searchOrder } else { $null }
        DefinitionRoot   = if ($Source.PSObject.Properties['definitionRoot']) { [string]$Source.definitionRoot } else { $null }
        BasePath         = if ($Source.PSObject.Properties['basePath']) { [string]$Source.basePath } else { $null }
        ResolvedRootPath = Resolve-PackageRepositoryRootForDisplay -Source $Source -ApplicationRootDirectory $ApplicationRootDirectory
        InventoryPath    = $InventoryPath
        TrustedAtUtc     = if ($Source.PSObject.Properties['trustedAtUtc']) { [string]$Source.trustedAtUtc } else { $null }
        TrustReason      = if ($Source.PSObject.Properties['trustReason']) { [string]$Source.trustReason } else { $null }
        Notes            = @($notes.ToArray())
    }
}

function Get-PackageRepositorySummaries {
    [CmdletBinding()]
    param()

    $documentInfo = Get-PackageRepositoryInventoryEditInfo
    $applicationRootDirectory = $null
    try {
        $globalDocumentInfo = Read-PackageJsonDocument -Path (Get-PackageGlobalConfigPath)
        Assert-PackageGlobalConfigSchema -GlobalDocumentInfo $globalDocumentInfo
        $applicationRootDirectory = Resolve-PackageApplicationRootDirectory -GlobalConfiguration $globalDocumentInfo.Document.package
    }
    catch {
        Write-Warning "Repository summaries could not resolve application root. Showing raw repository inventory only. $($_.Exception.Message)"
    }

    foreach ($sourceProperty in @($documentInfo.Document.repositorySources.PSObject.Properties)) {
        Select-PackageRepositorySummary -RepositoryId $sourceProperty.Name -Source $sourceProperty.Value -InventoryPath $documentInfo.Path -ApplicationRootDirectory $applicationRootDirectory
    }
}

function New-PackageRepositoryCommandResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Action,

        [Parameter(Mandatory = $true)]
        [string]$RepositoryId,

        [Parameter(Mandatory = $true)]
        [string]$InventoryPath,

        [AllowNull()]
        [psobject]$Before,

        [AllowNull()]
        [psobject]$After,

        [string]$Status = 'Updated',

        [string[]]$Notes = @()
    )

    foreach ($note in @($Notes)) {
        if (-not [string]::IsNullOrWhiteSpace($note)) {
            Write-Warning $note
        }
    }

    return [pscustomobject]@{
        Action        = $Action
        RepositoryId  = $RepositoryId
        InventoryPath = $InventoryPath
        Status        = $Status
        Before        = $Before
        After         = $After
        Notes         = @($Notes)
    }
}

function Resolve-PackageRepositoryRootPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RepositoryId,

        [Parameter(Mandatory = $true)]
        [psobject]$Source,

        [AllowNull()]
        [string]$ApplicationRootDirectory = $null
    )

    $kind = [string]$Source.kind
    if (-not [bool]$Source.enabled) {
        throw "Package repository '$RepositoryId' is disabled in RepositoryInventory.json."
    }
    if (-not [bool]$Source.trusted) {
        throw "Package repository '$RepositoryId' is not trusted. Use Trust-PackageRepository for trusted filesystem repositories."
    }

    if ([string]::Equals($kind, 'moduleLocal', [System.StringComparison]::OrdinalIgnoreCase)) {
        if ([string]::Equals($RepositoryId, (Get-PackageDefaultRepositoryId), [System.StringComparison]::OrdinalIgnoreCase)) {
            return [System.IO.Path]::GetFullPath((Join-Path (Get-PackageRepositoriesRoot) $RepositoryId))
        }
        return Resolve-ConfiguredPath -PathValue ([string]$Source.definitionRoot) -BaseDirectory (Split-Path -Parent (Get-PackageConfigurationRoot)) -Tokens @{}
    }

    if ([string]::Equals($kind, 'filesystem', [System.StringComparison]::OrdinalIgnoreCase)) {
        if (-not [string]::Equals([string]$Source.trustMode, 'unsignedExplicit', [System.StringComparison]::OrdinalIgnoreCase)) {
            throw "Package repository '$RepositoryId' is filesystem but does not use trustMode='unsignedExplicit'. Use Trust-PackageRepository -AllowUnsignedDefinitions."
        }
        if (-not [string]::IsNullOrWhiteSpace($ApplicationRootDirectory)) {
            return Resolve-PackageConfiguredPath -PathValue ([string]$Source.basePath) -ApplicationRootDirectory $ApplicationRootDirectory
        }
        return Resolve-PackagePathValue -PathValue ([string]$Source.basePath)
    }

    if ([string]::Equals($kind, 'httpsCatalog', [System.StringComparison]::OrdinalIgnoreCase)) {
        throw "Package repository '$RepositoryId' uses kind 'httpsCatalog', which is reserved for future support and is not implemented yet."
    }

    throw "Package repository '$RepositoryId' uses unsupported kind '$kind'."
}

function Resolve-PackageDefinitionSnapshotReference {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$RepositoryId = (Get-PackageDefaultRepositoryId),

        [Parameter(Mandatory = $true)]
        [string]$DefinitionId,

        [Parameter(Mandatory = $true)]
        [string]$PackageInventoryFilePath,

        [AllowNull()]
        [string]$LiveResolutionError = $null
    )

    $resolvedRepositoryId = if ([string]::IsNullOrWhiteSpace($RepositoryId)) { Get-PackageDefaultRepositoryId } else { [string]$RepositoryId }
    if (-not (Test-Path -LiteralPath $PackageInventoryFilePath -PathType Leaf)) {
        throw "Package repository '$resolvedRepositoryId' definition '$DefinitionId' could not be resolved from the live repository source, and no package inventory exists for snapshot fallback. Live error: $LiveResolutionError"
    }

    $inventoryInfo = Read-PackageJsonDocument -Path $PackageInventoryFilePath
    $records = @(
        foreach ($record in @($inventoryInfo.Document.records)) {
            if ([string]::Equals([string]$record.definitionRepositoryId, $resolvedRepositoryId, [System.StringComparison]::OrdinalIgnoreCase) -and
                [string]::Equals([string]$record.definitionId, $DefinitionId, [System.StringComparison]::OrdinalIgnoreCase)) {
                $snapshotPath = if ($record.PSObject.Properties['definitionSnapshotPath']) { [string]$record.definitionSnapshotPath } elseif ($record.PSObject.Properties['definitionLocalPath']) { [string]$record.definitionLocalPath } else { $null }
                if (-not [string]::IsNullOrWhiteSpace($snapshotPath) -and (Test-Path -LiteralPath $snapshotPath -PathType Leaf)) {
                    $record
                }
            }
        }
    )

    if ($records.Count -eq 0) {
        throw "Package repository '$resolvedRepositoryId' definition '$DefinitionId' could not be resolved from the live repository source, and no usable definition snapshot was found in '$PackageInventoryFilePath'. Live error: $LiveResolutionError"
    }

    $selectedRecord = @($records | Sort-Object -Property updatedAtUtc -Descending | Select-Object -First 1)[0]
    $selectedSnapshotPath = if ($selectedRecord.PSObject.Properties['definitionSnapshotPath']) { [string]$selectedRecord.definitionSnapshotPath } else { [string]$selectedRecord.definitionLocalPath }

    return [pscustomobject]@{
        RepositoryId       = $resolvedRepositoryId
        DefinitionId       = [string]$DefinitionId
        DefinitionPath     = [System.IO.Path]::GetFullPath($selectedSnapshotPath)
        SourceKind         = 'snapshot'
        SourcePath         = if ($selectedRecord.PSObject.Properties['definitionSourcePath']) { [string]$selectedRecord.definitionSourcePath } else { $null }
        SourceHash         = if ($selectedRecord.PSObject.Properties['definitionSourceHash']) { [string]$selectedRecord.definitionSourceHash } else { $null }
        SnapshotPath       = [System.IO.Path]::GetFullPath($selectedSnapshotPath)
        SnapshotHash       = Get-PackageFileSha256 -Path $selectedSnapshotPath
        ResolvedAtUtc      = [DateTime]::UtcNow.ToString('o')
        SnapshotFallback   = $true
        FallbackReason     = $LiveResolutionError
        InventoryRecord    = $selectedRecord
    }
}