SchemaCache.ps1

# Persistent DSC schema cache: serializes DynamicKeyword definitions of a module
# to JSON so the fast host can register them without any module parsing.

$script:SchemaCacheFormatVersion = 1

function Get-DscSchemaCacheUserPath
{
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [string]
        $ModuleName,

        [Parameter(Mandatory)]
        [version]
        $ModuleVersion,

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

    $root = Join-Path ([System.Environment]::GetFolderPath('LocalApplicationData')) 'M365DSC.PSDesiredStateConfiguration\SchemaCache'
    $safeFingerprint = $Fingerprint.Replace(':', '_')
    Join-Path $root "${ModuleName}_${ModuleVersion}_${safeFingerprint}.json"
}

function ConvertTo-DscKeywordSchemaObject
{
    param (
        [Parameter(Mandatory)]
        [System.Management.Automation.Language.DynamicKeyword]
        $Keyword
    )

    $properties = @{}
    foreach ($item in $Keyword.Properties.GetEnumerator())
    {
        $property = $item.Value
        $valueMap = @()
        if ($property.ValueMap)
        {
            foreach ($entry in $property.ValueMap.GetEnumerator())
            {
                $valueMap += @{ key = $entry.Key; value = $entry.Value }
            }
        }
        $properties[$item.Key] = @{
            name           = $property.Name
            typeConstraint = $property.TypeConstraint
            mandatory      = $property.Mandatory
            isKey          = $property.IsKey
            attributes     = @($property.Attributes)
            values         = @($property.Values)
            valueMap       = $valueMap
        }
    }

    @{
        keyword                   = $Keyword.Keyword
        resourceName              = $Keyword.ResourceName
        implementingModule        = $Keyword.ImplementingModule
        implementingModuleVersion = if ($Keyword.ImplementingModuleVersion) { $Keyword.ImplementingModuleVersion.ToString() } else { $null }
        nameMode                  = $Keyword.NameMode.ToString()
        bodyMode                  = $Keyword.BodyMode.ToString()
        directCall                = $Keyword.DirectCall
        metaStatement             = $Keyword.MetaStatement
        properties                = $properties
    }
}

function ConvertFrom-DscKeywordSchemaObject
{
    [OutputType([System.Management.Automation.Language.DynamicKeyword])]
    param (
        [Parameter(Mandatory)]
        $SchemaObject
    )

    $keyword = New-Object -TypeName System.Management.Automation.Language.DynamicKeyword
    $keyword.Keyword = $SchemaObject.keyword
    $keyword.ResourceName = $SchemaObject.resourceName
    $keyword.ImplementingModule = $SchemaObject.implementingModule
    if ($SchemaObject.implementingModuleVersion)
    {
        $keyword.ImplementingModuleVersion = [version]$SchemaObject.implementingModuleVersion
    }
    $keyword.NameMode = [System.Management.Automation.Language.DynamicKeywordNameMode]$SchemaObject.nameMode
    $keyword.BodyMode = [System.Management.Automation.Language.DynamicKeywordBodyMode]$SchemaObject.bodyMode
    $keyword.DirectCall = [bool]$SchemaObject.directCall
    $keyword.MetaStatement = [bool]$SchemaObject.metaStatement

    foreach ($item in $SchemaObject.properties.PSObject.Properties)
    {
        $source = $item.Value
        $property = New-Object -TypeName System.Management.Automation.Language.DynamicKeywordProperty
        $property.Name = $source.name
        $property.TypeConstraint = $source.typeConstraint
        $property.Mandatory = [bool]$source.mandatory
        $property.IsKey = [bool]$source.isKey
        foreach ($attribute in @($source.attributes)) { if ($null -ne $attribute) { $property.Attributes.Add($attribute) } }
        foreach ($value in @($source.values)) { if ($null -ne $value) { $property.Values.Add($value) } }
        foreach ($mapEntry in @($source.valueMap))
        {
            if ($null -ne $mapEntry)
            {
                $property.ValueMap[[string]$mapEntry.key] = $mapEntry.value
            }
        }
        $keyword.Properties.Add($item.Name, $property)
    }

    $keyword
}

function Export-DscSchemaCache
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ParameterSetName = 'ByName', Position = 0)]
        [string]
        $ModuleName,

        [Parameter(ParameterSetName = 'ByName')]
        [version]
        $RequiredVersion,

        [Parameter(Mandatory, ParameterSetName = 'ByModuleInfo')]
        [System.Management.Automation.PSModuleInfo]
        $Module,

        [string]
        $OutputPath
    )

    if ($PSCmdlet.ParameterSetName -eq 'ByName')
    {
        $candidates = Get-Module -ListAvailable -Name $ModuleName | Sort-Object -Property Version -Descending
        if ($RequiredVersion)
        {
            $candidates = $candidates | Where-Object { $_.Version -eq $RequiredVersion }
        }
        $Module = $candidates | Select-Object -First 1
        if (-not $Module)
        {
            throw "Module '$ModuleName' $(if ($RequiredVersion) { "version $RequiredVersion " })was not found."
        }
    }

    Reset-DscKeywordState
    $defaultFunctions = New-Object -TypeName 'System.Collections.Generic.Dictionary[string,scriptblock]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::LoadDefaultCimKeywords($defaultFunctions)
    $defaultKeywordNames = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($keyword in [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::GetCachedKeywords())
    {
        $null = $defaultKeywordNames.Add($keyword.Keyword)
    }

    $resources = New-Object -TypeName 'System.Collections.Generic.List[string]'
    $resources.Add('*')
    $throwaway = New-Object -TypeName 'System.Collections.Generic.Dictionary[string,scriptblock]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $null = [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::ImportClassResourcesFromModule($Module, $resources, $throwaway)

    $keywords = @()
    foreach ($keyword in [Microsoft.PowerShell.DesiredStateConfiguration.Internal.DscClassCache]::GetCachedKeywords())
    {
        if (-not $defaultKeywordNames.Contains($keyword.Keyword))
        {
            $keywords += ConvertTo-DscKeywordSchemaObject -Keyword $keyword
        }
    }

    $fingerprint = Get-DscModuleFingerprint -Module $Module
    $sourceHash = @{}
    foreach ($pattern in '*.psm1', '*.psd1', '*.mof')
    {
        foreach ($file in [System.IO.Directory]::EnumerateFiles($Module.ModuleBase, $pattern, [System.IO.SearchOption]::AllDirectories))
        {
            $relative = $file.Substring($Module.ModuleBase.Length).TrimStart('\', '/')
            $sourceHash[$relative] = (Get-FileHash -Path $file -Algorithm SHA256).Hash
        }
    }

    $cache = @{
        formatVersion = $script:SchemaCacheFormatVersion
        generator     = @{
            psdscVersion = $ExecutionContext.SessionState.Module.Version.ToString()
            psVersion    = $PSVersionTable.PSVersion.ToString()
        }
        module        = @{
            name        = $Module.Name
            version     = $Module.Version.ToString()
            fingerprint = $fingerprint
            sourceHash  = $sourceHash
        }
        keywords      = $keywords
    }

    Reset-DscKeywordState

    if (-not $OutputPath)
    {
        $OutputPath = Join-Path $Module.ModuleBase 'DscSchemaCache.json'
    }
    $directory = Split-Path $OutputPath -Parent
    if ($directory -and -not (Test-Path $directory))
    {
        $null = New-Item -ItemType Directory -Force -Path $directory
    }
    $json = ConvertTo-Json -InputObject $cache -Depth 12 -Compress
    [System.IO.File]::WriteAllText($OutputPath, $json, [System.Text.UTF8Encoding]::new($false))

    Write-Verbose -Message "Schema cache for $($Module.Name) $($Module.Version) written to $OutputPath ($($keywords.Count) keywords)."
    [PSCustomObject]@{
        ModuleName    = $Module.Name
        ModuleVersion = $Module.Version
        ResourceCount = @($keywords | Where-Object { $_.nameMode -eq 'NameRequired' }).Count
        KeywordCount  = $keywords.Count
        Fingerprint   = $fingerprint
        Path          = $OutputPath
    }
}
Export-ModuleMember -Function Export-DscSchemaCache

function Test-DscSchemaCache
{
    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [string]
        $ModulePath,

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

        [switch]
        $Detailed
    )

    if (-not (Test-Path $CachePath))
    {
        Write-Warning -Message "Schema cache '$CachePath' does not exist."
        return $false
    }

    $manifestPath = if ([System.IO.Path]::GetExtension($ModulePath) -eq '.psd1') { $ModulePath } else { Get-ChildItem -Path $ModulePath -Filter '*.psd1' | Select-Object -First 1 -ExpandProperty FullName }
    $moduleBase = Split-Path $manifestPath -Parent
    $manifest = Import-PowerShellDataFile -Path $manifestPath
    $cache = ConvertFrom-Json -InputObject ([System.IO.File]::ReadAllText($CachePath))

    $valid = $true
    if ($cache.formatVersion -gt $script:SchemaCacheFormatVersion)
    {
        Write-Warning -Message "Cache format version $($cache.formatVersion) is newer than supported ($script:SchemaCacheFormatVersion)."
        $valid = $false
    }
    if ($cache.module.version -ne $manifest.ModuleVersion)
    {
        Write-Warning -Message "Cache is for module version $($cache.module.version); manifest declares $($manifest.ModuleVersion)."
        $valid = $false
    }

    foreach ($entry in $cache.module.sourceHash.PSObject.Properties)
    {
        $file = Join-Path $moduleBase $entry.Name
        if (-not (Test-Path $file))
        {
            Write-Warning -Message "File in cache no longer exists: $($entry.Name)"
            $valid = $false
        }
        elseif ($Detailed -and (Get-FileHash -Path $file -Algorithm SHA256).Hash -ne $entry.Value)
        {
            Write-Warning -Message "File changed since cache generation: $($entry.Name)"
            $valid = $false
        }
    }

    $valid
}
Export-ModuleMember -Function Test-DscSchemaCache

function Get-DscSchemaCache
{
    param (
        [Parameter(Mandatory)]
        [System.Management.Automation.PSModuleInfo]
        $Module,

        [string[]]
        $SchemaCachePath
    )

    $fingerprint = Get-DscModuleFingerprint -Module $Module
    $candidates = @()
    if ($SchemaCachePath)
    {
        $candidates += $SchemaCachePath
    }
    $candidates += (Join-Path $Module.ModuleBase 'DscSchemaCache.json')
    $candidates += Get-DscSchemaCacheUserPath -ModuleName $Module.Name -ModuleVersion $Module.Version -Fingerprint $fingerprint

    foreach ($candidate in $candidates)
    {
        if (-not (Test-Path $candidate))
        {
            continue
        }
        try
        {
            $cache = ConvertFrom-Json -InputObject ([System.IO.File]::ReadAllText($candidate))
        }
        catch
        {
            Write-Warning -Message "Schema cache '$candidate' could not be parsed: $($_.Exception.Message)"
            continue
        }
        if ($cache.formatVersion -gt $script:SchemaCacheFormatVersion)
        {
            Write-Warning -Message "Schema cache '$candidate' has unsupported format version $($cache.formatVersion)."
            continue
        }
        if ($cache.module.name -ne $Module.Name -or $cache.module.version -ne $Module.Version.ToString())
        {
            Write-Warning -Message "Schema cache '$candidate' is for $($cache.module.name) $($cache.module.version), not $($Module.Name) $($Module.Version)."
            continue
        }
        if ($cache.module.fingerprint -ne $fingerprint)
        {
            Write-Warning -Message "Schema cache '$candidate' is stale (module files changed)."
            continue
        }
        return $cache
    }

    $null
}

function New-DscSchemaCacheForModule
{
    param (
        [Parameter(Mandatory)]
        [System.Management.Automation.PSModuleInfo]
        $Module
    )

    $fingerprint = Get-DscModuleFingerprint -Module $Module
    $userPath = Get-DscSchemaCacheUserPath -ModuleName $Module.Name -ModuleVersion $Module.Version -Fingerprint $fingerprint
    Write-Verbose -Message "Generating schema cache for $($Module.Name) $($Module.Version) (one-time operation)."
    $null = Export-DscSchemaCache -Module $Module -OutputPath $userPath
    Get-DscSchemaCache -Module $Module -SchemaCachePath $userPath
}