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); + }); + } +}