Public/Repair-IntuneScript.ps1

function Repair-IntuneScript {
    <#
    .EXTERNALHELP IntuneScriptLab-Help.xml
    .SYNOPSIS
        Applies the mechanical fixes for findings that have one, and reports what is left.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('IntuneScriptLab.Repair')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'PSPath')]
        [SupportsWildcards()]
        [string[]]$Path,

        [ValidateSet('Auto', 'Detection', 'Remediation', 'PlatformScript', 'Win32Detection', 'Win32Requirement')]
        [string]$ScriptType = 'Auto',

        [string[]]$IncludeRule,

        [string[]]$ExcludeRule,

        $Settings
    )

    process {
        $files = foreach ($item in $Path) {
            foreach ($resolved in (Resolve-Path -Path $item -ErrorAction Stop)) {
                if (Test-Path -LiteralPath $resolved.ProviderPath -PathType Container) {
                    Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -Filter *.ps1 -File |
                        ForEach-Object { $_.FullName }
                }
                else { $resolved.ProviderPath }
            }
        }

        foreach ($file in $files) {
            $testSplat = @{ Path = $file; ScriptType = $ScriptType }
            if ($IncludeRule) { $testSplat.IncludeRule = $IncludeRule }
            if ($ExcludeRule) { $testSplat.ExcludeRule = $ExcludeRule }
            if ($PSBoundParameters.ContainsKey('Settings')) { $testSplat.Settings = $Settings }
            $findings = @(Test-IntuneScript @testSplat)
            $fixable = @($findings | Where-Object { $_.Fix })
            $fixes = [System.Collections.Generic.List[object]]::new()
            $written = $false

            if ($fixable) {
                $bytes = [System.IO.File]::ReadAllBytes($file)
                $encoding = if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
                    [System.Text.Encoding]::Unicode
                }
                elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
                    [System.Text.Encoding]::BigEndianUnicode
                }
                elseif ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and
                    $bytes[2] -eq 0xBF) {
                    [System.Text.UTF8Encoding]::new($true)
                }
                else {
                    # No BOM: UTF-8 when the bytes decode as such, otherwise the ANSI code page
                    # Windows PowerShell 5.1 reads it in (a UTF-8 decode would turn every non-ASCII
                    # character into U+FFFD and lose it for good)
                    try {
                        $null = [System.Text.UTF8Encoding]::new($false, $true).GetString($bytes)
                        [System.Text.UTF8Encoding]::new($false)
                    }
                    catch { Get-IslOemEncoding -Kind ANSI }
                }
                $text = $encoding.GetString($bytes).TrimStart([char]0xFEFF)
                $lineStarts = [System.Collections.Generic.List[int]]::new()
                $lineStarts.Add(0)
                for ($i = 0; $i -lt $text.Length; $i++) {
                    if ($text[$i] -eq "`n") { $lineStarts.Add($i + 1) }
                }

                # Text edits from the end of the file backwards, so earlier offsets stay valid
                $edits = foreach ($finding in $fixable) {
                    if (-not $finding.Fix.ContainsKey('Replacement')) { continue }
                    if ($finding.Line -lt 1 -or $finding.Line -gt $lineStarts.Count) { continue }
                    $start = $lineStarts[$finding.Line - 1] + $finding.Column - 1
                    $before = "$($finding.Text)"
                    $current = if ($start + $before.Length -le $text.Length) {
                        $text.Substring($start, $before.Length)
                    }
                    else { '' }
                    if ($current -ne $before) {
                        Write-Warning "$file line $($finding.Line): the text changed since the analysis; skipped"
                        continue
                    }
                    [pscustomobject]@{ Start = $start; Finding = $finding; Before = $before }
                }
                foreach ($edit in ($edits | Sort-Object Start -Descending)) {
                    $after = "$($edit.Finding.Fix.Replacement)"
                    $tail = $text.Substring($edit.Start + $edit.Before.Length)
                    $text = $text.Substring(0, $edit.Start) + $after + $tail
                    $fixes.Add([pscustomobject]@{
                            PSTypeName = 'IntuneScriptLab.Fix'
                            RuleName   = $edit.Finding.RuleName
                            Line       = $edit.Finding.Line
                            Before     = $edit.Before
                            After      = $after
                        })
                }
                $encodingFix = @($fixable | Where-Object { $_.Fix.ContainsKey('Encoding') }) |
                    Select-Object -First 1
                $target = $encoding
                if ($encodingFix) {
                    $target = [System.Text.UTF8Encoding]::new($true)
                    $fixes.Add([pscustomobject]@{
                            PSTypeName = 'IntuneScriptLab.Fix'
                            RuleName   = $encodingFix.RuleName
                            Line       = 0
                            Before     = $encoding.WebName
                            After      = 'utf-8 with BOM'
                        })
                }

                if ($fixes.Count -gt 0) {
                    $rules = @($fixes | ForEach-Object { $_.RuleName } | Sort-Object -Unique) -join ', '
                    if ($PSCmdlet.ShouldProcess($file, "Apply $($fixes.Count) fix(es): $rules")) {
                        [System.IO.File]::WriteAllText($file, $text, $target)
                        $written = $true
                    }
                }
            }

            $remaining = if ($written) { @(Test-IntuneScript @testSplat).Count }
            else { $findings.Count - $fixes.Count }
            [pscustomobject]@{
                PSTypeName = 'IntuneScriptLab.Repair'
                Path       = $file
                Applied    = $fixes.Count
                Remaining  = $remaining
                Written    = $written
                Fixes      = $fixes.ToArray()
            }
        }
    }
}