From 5217b0b7587ccfe509ba8adc3a7748b9bae14d7a Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Mon, 13 Sep 2021 10:31:37 +0200 Subject: [PATCH] Add pipes to write pretty PowerShell #53 This commit introduces two pipes: `inlinePowerShell`, `escapeDoubleQuotes`. The types when used together allows writing adding clean and real PowerShell scripts as they are (without inlinining or escaping them), removing the need to have hard-coded inlining/escaping. It enables writing better PowerShell, makes it easier to maintain and extend PowerShell scripts. Also allows writing more stable code with less "unseen" bugs due to manual escaping/inlining. This commit naturally reveals and fixes double quotes not being escaped in "Empty trash bin" script. This is solved by unifying the use of RunPowerShell function by all scripts using PowerShell. The function inlines and escapes the scripts as compile time to be send them to PowerShell.exe as an argument and then invokes PowerShell.exe with generated ugly code. --- docs/templating.md | 6 + .../PipeDefinitions/EscapeDoubleQuotes.ts | 12 ++ .../Pipes/PipeDefinitions/InlinePowerShell.ts | 12 ++ .../Compiler/Expressions/Pipes/PipeFactory.ts | 7 +- src/application/collections/windows.yaml | 120 +++++++++--------- .../EscapeDoubleQuotes.spec.ts | 31 +++++ .../PipeDefinitions/InlinePowerShell.spec.ts | 47 +++++++ .../Pipes/PipeDefinitions/PipeTestRunner.ts | 20 +++ 8 files changed, 196 insertions(+), 59 deletions(-) create mode 100644 src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts diff --git a/docs/templating.md b/docs/templating.md index b875ecbb..351f8b05 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -79,3 +79,9 @@ A function can call other functions such as: - Pipes are provided and defined by the compiler and consumed by collection files. - Pipes can be combined with [parameter substitution](#parameter-substitution) and [with](#with). - ❗ Pipe names must be camelCase without any space or special characters. +- **Existing pipes** + - `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line. + - `escapeDoubleQuotes`: Escapes `"` characters to be used inside double quotes (`"`) +- **Example usages** + - `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}` + - `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}` diff --git a/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts new file mode 100644 index 00000000..45fa4c6a --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.ts @@ -0,0 +1,12 @@ +import { IPipe } from '../IPipe'; + +export class EscapeDoubleQuotes implements IPipe { + public readonly name: string = 'escapeDoubleQuotes'; + public apply(raw: string): string { + return raw?.replaceAll('"', '\\"'); + /* + In batch, it also works with 4 double quotes but looks bloated + An easy test: PowerShell -Command "Write-Host '\"Hello World\"'" + */ + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts new file mode 100644 index 00000000..80edbfcd --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.ts @@ -0,0 +1,12 @@ +import { IPipe } from '../IPipe'; + +export class InlinePowerShell implements IPipe { + public readonly name: string = 'inlinePowerShell'; + public apply(raw: string): string { + return raw + ?.split(/\r\n|\r|\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('; '); + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.ts b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.ts index 6e4fd53f..47b75f4e 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.ts @@ -1,6 +1,11 @@ import { IPipe } from './IPipe'; +import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell'; +import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes'; -const RegisteredPipes = [ ]; +const RegisteredPipes = [ + new EscapeDoubleQuotes(), + new InlinePowerShell(), +]; export interface IPipeFactory { get(pipeName: string): IPipe; diff --git a/src/application/collections/windows.yaml b/src/application/collections/windows.yaml index 6fa8d339..d57ba5c2 100644 --- a/src/application/collections/windows.yaml +++ b/src/application/collections/windows.yaml @@ -392,10 +392,10 @@ actions: call: function: RunPowerShell parameters: - code: - $bin = (New-Object -ComObject Shell.Application).NameSpace(10); + code: |- + $bin = (New-Object -ComObject Shell.Application).NameSpace(10) $bin.items() | ForEach { - Write-Host "Deleting $($_.Name) from Recycle Bin"; + Write-Host "Deleting $($_.Name) from Recycle Bin" Remove-Item $_.Path -Recurse -Force } - @@ -2884,15 +2884,15 @@ actions: call: function: RunPowerShell parameters: - code: - $key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces'; - Get-ChildItem $key | foreach { - Set-ItemProperty -Path \"$key\$($_.pschildname)\" -Name NetbiosOptions -Value 2 -Verbose + code: |- + $key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces' + Get-ChildItem $key | ForEach { + Set-ItemProperty -Path "$key\$($_.PSChildName)" -Name NetbiosOptions -Value 2 -Verbose } - revertCode: - $key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces'; - Get-ChildItem $key | foreach { - Set-ItemProperty -Path \"$key\$($_.pschildname)\" -Name NetbiosOptions -Value 0 -Verbose + revertCode: |- + $key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces' + Get-ChildItem $key | ForEach { + Set-ItemProperty -Path "$key\$($_.PSChildName)" -Name NetbiosOptions -Value 0 -Verbose } - category: Remove bloatware @@ -3814,13 +3814,13 @@ actions: call: function: RunPowerShell parameters: - code: - $installer = (Get-ChildItem \"$env:ProgramFiles*\Microsoft\Edge\Application\*\Installer\setup.exe\"); + code: |- + $installer = (Get-ChildItem "$env:ProgramFiles*\Microsoft\Edge\Application\*\Installer\setup.exe") if (!$installer) { - Write-Host Could not find the installer; + Write-Host 'Could not find the installer' } else { & $installer.FullName -Uninstall -System-Level -Verbose-Logging -Force-Uninstall - }; + } - category: Disable built-in Windows features children: @@ -4416,13 +4416,13 @@ functions: function: RunPowerShell parameters: code: Get-AppxPackage '{{ $packageName }}' | Remove-AppxPackage - revertCode: - $package = Get-AppxPackage -AllUsers '{{ $packageName }}'; + revertCode: |- + $package = Get-AppxPackage -AllUsers '{{ $packageName }}' if (!$package) { - Write-Error \"Cannot reinstall '{{ $packageName }}'\" -ErrorAction Stop + Write-Error "Cannot reinstall '{{ $packageName }}'" -ErrorAction Stop } - $manifest = $package.InstallLocation + '\AppxManifest.xml'; - Add-AppxPackage -DisableDevelopmentMode -Register \"$manifest\" + $manifest = $package.InstallLocation + '\AppxManifest.xml' + Add-AppxPackage -DisableDevelopmentMode -Register "$manifest" - name: UninstallSystemApp parameters: @@ -4433,40 +4433,44 @@ functions: call: function: RunPowerShell parameters: - code: - $package = (Get-AppxPackage -AllUsers '{{ $packageName }}'); + code: |- + $package = Get-AppxPackage -AllUsers '{{ $packageName }}' if (!$package) { - Write-Host 'Not installed'; - exit 0; + Write-Host 'Not installed' + exit 0 } - $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); + $directories = @($package.InstallLocation, "$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)") foreach($dir in $directories) { - if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } - cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - $files = Get-ChildItem -File -Path $dir -Recurse -Force; + if ( !$dir -Or !(Test-Path "$dir") ) { continue } + cmd /c ('takeown /f "' + $dir + '" /r /d y 1> nul') + if($LASTEXITCODE) { throw 'Failed to take ownership' } + cmd /c ('icacls "' + $dir + '" /grant administrators:F /t 1> nul') + if($LASTEXITCODE) { throw 'Failed to take ownership' } + $files = Get-ChildItem -File -Path $dir -Recurse -Force foreach($file in $files) { - if($file.Name.EndsWith('.OLD')) { continue; } - $newName = $file.FullName + '.OLD'; - Write-Host \"Rename '$($file.FullName)' to '$newName'\"; - Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; + if($file.Name.EndsWith('.OLD')) { continue } + $newName = $file.FullName + '.OLD' + Write-Host "Rename '$($file.FullName)' to '$newName'" + Move-Item -LiteralPath "$($file.FullName)" -Destination "$newName" -Force } } - revertCode: - $package = (Get-AppxPackage -AllUsers '{{ $packageName }}'); + revertCode: |- + $package = Get-AppxPackage -AllUsers '{{ $packageName }}' if (!$package) { - Write-Error 'App could not be found' -ErrorAction Stop; + Write-Error 'App could not be found' -ErrorAction Stop } - $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); + $directories = @($package.InstallLocation, "$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)") foreach($dir in $directories) { - if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } - cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - $files = Get-ChildItem -File -Path \"$dir\*.OLD\" -Recurse -Force; + if ( !$dir -Or !(Test-Path "$dir") ) { continue; } + cmd /c ('takeown /f "' + $dir + '" /r /d y 1> nul') + if($LASTEXITCODE) { throw 'Failed to take ownership' } + cmd /c ('icacls "' + $dir + '" /grant administrators:F /t 1> nul') + if($LASTEXITCODE) { throw 'Failed to take ownership' } + $files = Get-ChildItem -File -Path "$dir\*.OLD" -Recurse -Force foreach($file in $files) { - $newName = $file.FullName.Substring(0, $file.FullName.Length - 4); - Write-Host \"Rename '$($file.FullName)' to '$newName'\"; - Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; + $newName = $file.FullName.Substring(0, $file.FullName.Length - 4) + Write-Host "Rename '$($file.FullName)' to '$newName'" + Move-Item -LiteralPath "$($file.FullName)" -Destination "$newName" -Force } } - @@ -4477,9 +4481,9 @@ functions: function: RunPowerShell parameters: code: Get-WindowsCapability -Online -Name '{{ $capabilityName }}*' | Remove-WindowsCapability -Online - revertCode: - $capability = Get-WindowsCapability -Online -Name '{{ $capabilityName }}*'; - Add-WindowsCapability -Name \"$capability.Name\" -Online + revertCode: |- + $capability = Get-WindowsCapability -Online -Name '{{ $capabilityName }}*' + Add-WindowsCapability -Name "$capability.Name" -Online - name: RenameSystemFile parameters: @@ -4516,25 +4520,25 @@ functions: Write-Host \"No updates. Settings file was not at $jsonfile\"; exit 0; } - $json = Get-Content $jsonfile | Out-String | ConvertFrom-Json; - $json | Add-Member -Type NoteProperty -Name '{{ $setting }}' -Value {{ $powerShellValue }} -Force; - $json | ConvertTo-Json | Set-Content $jsonfile; - revertCode: - $jsonfile = \"$env:APPDATA\Code\User\settings.json\"; + $json = Get-Content $jsonfile | Out-String | ConvertFrom-Json + $json | Add-Member -Type NoteProperty -Name '{{ $setting }}' -Value {{ $powerShellValue }} -Force + $json | ConvertTo-Json | Set-Content $jsonfile + revertCode: |- + $jsonfile = "$env:APPDATA\Code\User\settings.json" if (!(Test-Path $jsonfile -PathType Leaf)) { - Write-Error \"Settings file could not be found at $jsonfile\" -ErrorAction Stop; + Write-Error "Settings file could not be found at $jsonfile" -ErrorAction Stop } - $json = Get-Content $jsonfile | ConvertFrom-Json; - $json.PSObject.Properties.Remove('{{ $setting }}'); - $json | ConvertTo-Json | Set-Content $jsonfile; + $json = Get-Content $jsonfile | ConvertFrom-Json + $json.PSObject.Properties.Remove('{{ $setting }}') + $json | ConvertTo-Json | Set-Content $jsonfile - name: RunPowerShell parameters: - name: code - name: revertCode optional: true - code: PowerShell -ExecutionPolicy Unrestricted -Command "{{ $code }}" + code: PowerShell -ExecutionPolicy Unrestricted -Command "{{ $code | inlinePowerShell | escapeDoubleQuotes }}" revertCode: |- {{ with $revertCode }} - PowerShell -ExecutionPolicy Unrestricted -Command "{{ . }}" + PowerShell -ExecutionPolicy Unrestricted -Command "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts new file mode 100644 index 00000000..70519e43 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts @@ -0,0 +1,31 @@ +import 'mocha'; +import { runPipeTests } from './PipeTestRunner'; +import { EscapeDoubleQuotes } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes'; + +describe('EscapeDoubleQuotes', () => { + // arrange + const sut = new EscapeDoubleQuotes(); + // act + runPipeTests(sut, [ + { + name: 'using "', + input: 'hello "world"', + expectedOutput: 'hello \\"world\\"', + }, + { + name: 'not using any double quotes', + input: 'hello world', + expectedOutput: 'hello world', + }, + { + name: 'consecutive double quotes', + input: '""hello world""', + expectedOutput: '\\"\\"hello world\\"\\"', + }, + { + name: 'returns undefined when if input is undefined', + input: undefined, + expectedOutput: undefined, + }, + ]); +}); diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts new file mode 100644 index 00000000..d883cbd8 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell.spec.ts @@ -0,0 +1,47 @@ +import 'mocha'; +import { InlinePowerShell } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell'; +import { runPipeTests } from './PipeTestRunner'; + +describe('InlinePowerShell', () => { + // arrange + const sut = new InlinePowerShell(); + // act + runPipeTests(sut, [ + { + name: 'no new line', + input: 'Write-Host \'Hello, World!\'', + expectedOutput: 'Write-Host \'Hello, World!\'', + }, + { + name: '\\n new line', + input: '$things = Get-ChildItem C:\\Windows\\\nforeach ($thing in $things) {\nWrite-Host $thing.Name -ForegroundColor Magenta\n}', + expectedOutput: '$things = Get-ChildItem C:\\Windows\\; foreach ($thing in $things) {; Write-Host $thing.Name -ForegroundColor Magenta; }', + }, + { + name: '\\n double empty lines are ignored', + input: '$things = Get-ChildItem C:\\Windows\\\n\nforeach ($thing in $things) {\n\nWrite-Host $thing.Name -ForegroundColor Magenta\n\n\n}', + expectedOutput: '$things = Get-ChildItem C:\\Windows\\; foreach ($thing in $things) {; Write-Host $thing.Name -ForegroundColor Magenta; }', + }, + { + name: '\\r new line', + input: '$things = Get-ChildItem C:\\Windows\\\rforeach ($thing in $things) {\rWrite-Host $thing.Name -ForegroundColor Magenta\r}', + expectedOutput: '$things = Get-ChildItem C:\\Windows\\; foreach ($thing in $things) {; Write-Host $thing.Name -ForegroundColor Magenta; }', + }, + { + name: '\\r and \\n newlines combined', + input: '$things = Get-ChildItem C:\\Windows\\\r\nforeach ($thing in $things) {\n\rWrite-Host $thing.Name -ForegroundColor Magenta\n\r}', + expectedOutput: '$things = Get-ChildItem C:\\Windows\\; foreach ($thing in $things) {; Write-Host $thing.Name -ForegroundColor Magenta; }', + }, + { + name: 'trims whitespaces on lines', + input: ' $things = Get-ChildItem C:\\Windows\\ \nforeach ($thing in $things) {\n\tWrite-Host $thing.Name -ForegroundColor Magenta\r \n}', + expectedOutput: '$things = Get-ChildItem C:\\Windows\\; foreach ($thing in $things) {; Write-Host $thing.Name -ForegroundColor Magenta; }', + }, + { + name: 'returns undefined when if input is undefined', + input: undefined, + expectedOutput: undefined, + }, + ]); +}); + diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts new file mode 100644 index 00000000..9287168f --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/PipeTestRunner.ts @@ -0,0 +1,20 @@ +import 'mocha'; +import { expect } from 'chai'; +import { IPipe } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipe'; + +interface ITestCase { + readonly name: string; + readonly input: string; + readonly expectedOutput: string; +} + +export function runPipeTests(sut: IPipe, testCases: ITestCase[]) { + for (const testCase of testCases) { + it(testCase.name, () => { + // act + const actual = sut.apply(testCase.input); + // assert + expect(actual).to.equal(testCase.expectedOutput); + }); + } +}