Pentia.Assert-WebProjectConsistency.psm1

<#
.SYNOPSIS
Runs various sanity checks on a web project and it's configuration files.
 
.DESCRIPTION
Checks for:
- SlowCheetah
- Build actions set for XDT files
- Reserved file names like "Web.config"
- Configuration file encoding
 
.PARAMETER ProjectFilePath
The absolute path to the .csproj-file.
 
.PARAMETER BuildConfiguration
The current build configuration.
 
.EXAMPLE
Assert-WebProjectConsistency -ProjectFilePath "D:\Projects\MySolution\src\MyProject\code\MyProject.csproj" -BuildConfiguration "Staging"
#>

function Assert-WebProjectConsistency {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Scope = 'function')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$ProjectFilePath,

        [Parameter(Mandatory = $true)]
        [string]$BuildConfiguration
    )
    process {
        if (-not (Test-Path $ProjectFilePath -PathType Leaf)) {
            throw "File '$ProjectFilePath' not found."
        }

        Write-Verbose "Processing '$ProjectFilePath'..."

        Write-Verbose "Checking for missing build configuration..."
        if (Test-BuildConfigurationExists -ProjectFilePath $ProjectFilePath -BuildConfiguration $BuildConfiguration) {
            Write-Verbose "Build configuration is mentioned in project file."
        }

        Write-Verbose "Checking for missing content files..."
        if (Test-ContentFileExists -ProjectFilePath $ProjectFilePath) {
            Write-Verbose "All content files exist on disk."
        }

        Write-Verbose "Checking for SlowCheetah..."
        if (-not (Test-SlowCheetah -ProjectFilePath $ProjectFilePath)) {
            Write-Verbose "SlowCheetah is not installed."
        }

        Write-Verbose "Checking for XDT build actions..."
        if (Test-XdtBuildActionContent -ProjectFilePath $ProjectFilePath -BuildConfiguration $BuildConfiguration) {
            Write-Verbose "Build action of XDTs is 'Content'."
        }

        Write-Verbose "Checking for reserved file names..."
        if (-not (Test-ReservedFilePath -ProjectFilePath $ProjectFilePath)) {
            Write-Verbose "Reserved file names are not used."
        }

        Write-Verbose "Checking for XML declaration..."
        if (Test-XmlDeclaration -Path $ProjectFilePath) {
            Write-Verbose "XML declaration found."
        }

        Write-Verbose "Checking for correct file encoding..."
        if (Test-XmlFileEncoding -Path $ProjectFilePath) {
            Write-Verbose "File encoding matches encoding specified in XML declaration."
        }
    }
}

function Test-SlowCheetah {
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProjectFilePath
    )
    [xml]$projectFileContents = Get-Content -Path $ProjectFilePath
    if ($projectFileContents.OuterXml -match "SlowCheetah") {
        Write-Warning "Found SlowCheetah references in '$ProjectFilePath'."
        return $true
    }
    $false
}

function Test-XdtBuildActionContent {
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProjectFilePath,

        [Parameter(Mandatory = $true)]
        [string]$BuildConfiguration
    )
    [xml]$projectFileContents = Get-Content -Path $ProjectFilePath
    $valid = $true
    $elements = Get-ElementsWithIncludeAttribute -Xml $projectFileContents
    foreach ($element in $elements) {
        $filePath = $element.GetAttribute("Include")
        $appliesToCurrentBuildConfiguration = $filePath.EndsWith(".$BuildConfiguration.config", [System.StringComparison]::InvariantCultureIgnoreCase)
        if (-not $appliesToCurrentBuildConfiguration) {
            continue
        }
        $buildAction = $element.LocalName
        if ($buildAction.Equals("Content")) {
            continue
        }
        Write-Warning "Found potential XDT '$filePath' with build action '$buildAction' in '$ProjectFilePath'."
        $valid = $false
    }
    $valid
}

function Get-ElementsWithIncludeAttribute {
    param (
        [Parameter(Mandatory = $true)]
        [xml]$Xml
    )
    return Select-Xml -Xml $Xml -XPath "//*[@Include != '']" | Select-Object -ExpandProperty "Node"
}

function Test-ReservedFilePath {
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProjectFilePath
    )
    $reservedFilePaths = @("Web.config")
    [xml]$projectFileContents = Get-Content -Path $ProjectFilePath
    $elements = Get-ElementsWithIncludeAttribute -Xml $projectFileContents
    $containsReservedFileName = $false
    foreach ($element in $elements) {
        $filePath = $element.GetAttribute("Include")
        $buildAction = $element.LocalName
        if ($reservedFilePaths -contains $filePath -and $buildAction -eq "Content") {
            Write-Warning "Found file reference '$filePath' using reserved path '$filePath' with build action '$buildAction' in '$ProjectFilePath'."
            $containsReservedFileName = $true
        }
    }
    $containsReservedFileName
}

function Test-XmlDeclaration {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    $measurement = Get-XmlDeclaration $Path | Measure-Object
    if ($measurement.Count -eq 0) {
        Write-Warning "File '$Path' doesn't contain an XML declaration."
        return $false
    }
    $true
}

function Get-XmlDeclaration {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    [xml]$xml = Get-Content $Path
    $xml.ChildNodes | Where-Object { $_.NodeType -eq "XmlDeclaration" }
}

function Test-XmlFileEncoding {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    $xmlEncoding = Get-XmlDeclaration $Path | Select-Object -ExpandProperty "encoding"
    $normalizedXmlEncoding = $xmlEncoding -replace "\W", "" # "utf-8" -> "utf8"
    $fileEncoding = Get-FileEncoding -Path $Path
    if ($fileEncoding.Equals($normalizedXmlEncoding, [System.StringComparison]::InvariantCultureIgnoreCase)) {
        return $true
    }
    Write-Warning "Found file '$Path' saved as encoding '$fileEncoding', which doesn't match XML declaration encoding '$xmlEncoding'."
    $false
}

# Copied from https://stackoverflow.com/questions/3710374/get-encoding-of-a-file-in-windows
function Get-FileEncoding {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    $bytes = [byte[]](Get-Content $Path -Encoding byte -ReadCount 4 -TotalCount 4)

    if (-not $bytes) { return 'utf8' }

    switch -regex ('{0:x2}{1:x2}{2:x2}{3:x2}' -f $bytes[0], $bytes[1], $bytes[2], $bytes[3]) {
        '^efbbbf' { return 'utf8' }
        '^2b2f76' { return 'utf7' }
        '^fffe' { return 'unicode' }
        '^feff' { return 'bigendianunicode' }
        '^0000feff' { return 'utf32' }
        default { return 'ascii' }
    }
}

function Test-ContentFileExists {
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProjectFilePath
    )
    [xml]$projectFileContents = Get-Content -Path $ProjectFilePath
    $valid = $true
    $elements = Get-ElementsWithIncludeAttribute -Xml $projectFileContents
    foreach ($element in $elements) {
        $buildAction = $element.LocalName
        if (-not $buildAction.Equals("Content")) {
            continue
        }
        $contentFilePath = $element.GetAttribute("Include")
        $absolutePath = Get-AbsoluteContentFilePath -ProjectFilePath $ProjectFilePath -ContentFilePath $contentFilePath
        if (Test-Path $absolutePath) {
            continue
        }
        Write-Warning "Content file '$absolutePath' referenced in '$ProjectFilePath' doesn't exist on disk."
        $valid = $false
    }
    $valid
}

function Get-AbsoluteContentFilePath {
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProjectFilePath,

        [Parameter(Mandatory = $true)]
        [string]$ContentFilePath
    )
    if ([System.IO.Path]::IsPathRooted($ContentFilePath)) {
        $absolutePath = $ContentFilePath
    }
    else {
        $projectDirectory = [System.IO.Path]::GetDirectoryName($ProjectFilePath)
        $absolutePath = [System.IO.Path]::Combine($projectDirectory, $ContentFilePath)
    }
    Add-Type -AssemblyName "System.Web"
    [System.Web.HttpUtility]::UrlDecode($absolutePath)
}

<#
Currently we only check whether or not the build configuration is mentionend in any "Condition" attribute in the project file.
Time will tell Whether this is a good enough indicator/sanity check or not.
Alternatively we could parse the .sln-file, but this conflicts with the idea of checking a single project file at a time.
#>

function Test-BuildConfigurationExists {
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProjectFilePath,

        [Parameter(Mandatory = $true)]
        [string]$BuildConfiguration
    )
    [xml]$projectFileContents = Get-Content -Path $ProjectFilePath
    $conditionAttributes = Select-Xml -Xml $projectFileContents -XPath "//*[@Condition != '']/@Condition" | Select-Object -ExpandProperty "Node"
    foreach ($conditionAttribute in $conditionAttributes) {
        # <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "">
        $isBuildConfigurationMentioned = $conditionAttribute.Value.ToLowerInvariant().Contains("== '$BuildConfiguration|".ToLowerInvariant())
        if ($isBuildConfigurationMentioned) {
            return $true
        }
    }
    Write-Warning "Project file '$ProjectFilePath' doesn't mention the build configuration '$BuildConfiguration', indicating that the build configuration is missing for this project."
    $false
}

Export-ModuleMember -Function Assert-WebProjectConsistency