functions/_getExtensionContentHash.ps1

# <copyright file="_getExtensionContentHash.ps1" company="Endjin Limited">
# Copyright (c) Endjin Limited. All rights reserved.
# </copyright>

function _getExtensionContentHash {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [string] $Path
    )

    # Files written when an extension is installed, which differ between installs of the same content
    $excludedFiles = @(
        'PSGetModuleInfo.xml'   # Written by Save-PSResource, includes the install date & location
        '.zf-source.json'       # Written by ZeroFailed, records where an extension was installed from
    )

    # Uses .NET APIs rather than cmdlets (e.g. Get-FileHash), since this can run when module auto-loading is disabled
    $rootPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    try {
        # Build a manifest of every file's relative path and hash, sorted so the order files are found in does not matter
        $manifestLines = [System.Collections.Generic.List[string]]::new()
        # Follow links (symbolic links and junctions), as the files in a linked folder are loaded like any others, so the
        # hash must include them. A link to a folder that has already been visited (a loop) is skipped.
        foreach ($file in (Get-ChildItem -LiteralPath $rootPath -File -Recurse -Force -FollowSymlink -WarningAction SilentlyContinue)) {
            $relativePath = [IO.Path]::GetRelativePath($rootPath, $file.FullName).Replace('\', '/')
            if ($relativePath -in $excludedFiles) {
                continue
            }

            $bytes = [IO.File]::ReadAllBytes($file.FullName)

            # Treat a file as text when its start contains no NUL bytes (the same heuristic as git), and
            # normalise its line endings, so the hash does not depend on how git was configured to check it out.
            $isText = [Array]::IndexOf($bytes, [byte]0, 0, [Math]::Min($bytes.Length, 8000)) -lt 0
            if ($isText -and [Array]::IndexOf($bytes, [byte]13) -ge 0) {
                # Latin1 maps every byte to a single character, so the round-trip leaves all other bytes unchanged
                $text = [Text.Encoding]::Latin1.GetString($bytes)
                $bytes = [Text.Encoding]::Latin1.GetBytes($text.Replace("`r`n", "`n"))
            }

            $fileHash = [Convert]::ToHexString($sha256.ComputeHash($bytes)).ToLowerInvariant()
            $manifestLines.Add("$relativePath`0$fileHash`n")
        }
        $manifestLines.Sort([StringComparer]::Ordinal)

        $manifestBytes = [Text.Encoding]::UTF8.GetBytes([string]::Concat($manifestLines))
        $contentHash = [Convert]::ToHexString($sha256.ComputeHash($manifestBytes)).ToLowerInvariant()

        return "sha256:$contentHash"
    }
    finally {
        $sha256.Dispose()
    }
}