Skip to content

Commit 7b81b93

Browse files
authored
fix: preserve PowerShell session during install
Prevent irm | iex from terminating the caller PowerShell session and add exact same-session acceptance coverage.
1 parent 52dcabd commit 7b81b93

3 files changed

Lines changed: 68 additions & 26 deletions

File tree

install.ps1

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,40 @@
11
#Requires -Version 5.1
22

3+
& {
4+
$DirectFileInvocation = -not [string]::IsNullOrWhiteSpace($PSCommandPath)
5+
$InstallerFailed = $false
36
$ErrorActionPreference = 'Stop'
47
Set-StrictMode -Version 2
58

69
# PowerShell 5.1 otherwise uses a legacy code page when stdout/stderr is
710
# redirected, corrupting Korean output consumed by CI and automation.
8-
$script:Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
9-
[Console]::OutputEncoding = $script:Utf8NoBom
10-
$OutputEncoding = $script:Utf8NoBom
11+
$OriginalConsoleOutputEncoding = [Console]::OutputEncoding
12+
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
13+
[Console]::OutputEncoding = $Utf8NoBom
14+
$OutputEncoding = $Utf8NoBom
1115

12-
$script:OriginalProgressPreference = $ProgressPreference
16+
$OriginalProgressPreference = $ProgressPreference
1317
$ProgressPreference = 'SilentlyContinue'
1418

1519
$requestedLanguage = [Environment]::GetEnvironmentVariable('CONTEXA_LANG')
1620
if ([string]::IsNullOrWhiteSpace($requestedLanguage)) {
1721
$requestedLanguage = [System.Globalization.CultureInfo]::CurrentUICulture.TwoLetterISOLanguageName
1822
}
19-
$script:InstallerLanguage = if ($requestedLanguage -match '^(?i:ko)(?:[-_].*)?$') { 'ko' } else { 'en' }
23+
$InstallerLanguage = if ($requestedLanguage -match '^(?i:ko)(?:[-_].*)?$') { 'ko' } else { 'en' }
2024

2125
function Select-InstallerText {
2226
param([string]$English, [string]$KoreanUtf8Base64)
23-
if ($script:InstallerLanguage -eq 'ko') {
27+
if ($InstallerLanguage -eq 'ko') {
2428
return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($KoreanUtf8Base64))
2529
}
2630
return $English
2731
}
2832

29-
$script:Repository = 'contexa-security/contexa-cli'
30-
$script:DefaultChannelManifestUrl = 'https://raw.githubusercontent.com/contexa-security/contexa-cli/snapshot-channel/channel-manifest.json'
31-
$script:DefaultChannelSignatureUrl = 'https://raw.githubusercontent.com/contexa-security/contexa-cli/snapshot-channel/channel-manifest.json.sig'
32-
$script:DefaultDownloadBase = 'https://github.com/contexa-security/contexa-cli/releases/download'
33-
$script:PublicKeyXml = '<RSAKeyValue><Modulus>osHQvVy9S+AGAvskLk13njD9SoRHMURAbU2RQWZgQt2t0vN3Ib7aVMIwStGdJhaDIuPHTg0WrwM6ogPDDqfFmHHm8XkviBHnkgFQWvovLHtRudSgU6g+5ReaT0G0HsWFC3aGVJhOEwo5EqJJxZgjIc533CJTyn6ZbV8C0PGPP3kZQb1C/zPCaVtQg02v3Vm1C+sivBfCFRRJlcXhfc5hvbtB40DcRFkJfkBbdHBwdAnRfuH8OnIeL9dWEFyNgR7ZIREnjqNahtZbUM9gBS1p1Zw3ffTls2QSyMvQobqwNOdfP2/LN0K8uiJJ8K7nh524wGANdTlmKY2cAAkUbZsO2FK7sLCcVDXShQptXFj31DEzdQCb9hAnarXK5C6qBFxloDGzV8b+xlALFQBIO8xwXlxR8jZq+CiVJmWHUr78A0fubstaBUSgpU1ZzdUl0plI6MczU/udM7miH/O1ih7t0ox745ahU/7eXEYOLNRAJs2gidol7m+apyY/qV7DIMhz</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>'
33+
$Repository = 'contexa-security/contexa-cli'
34+
$DefaultChannelManifestUrl = 'https://raw.githubusercontent.com/contexa-security/contexa-cli/snapshot-channel/channel-manifest.json'
35+
$DefaultChannelSignatureUrl = 'https://raw.githubusercontent.com/contexa-security/contexa-cli/snapshot-channel/channel-manifest.json.sig'
36+
$DefaultDownloadBase = 'https://github.com/contexa-security/contexa-cli/releases/download'
37+
$PublicKeyXml = '<RSAKeyValue><Modulus>osHQvVy9S+AGAvskLk13njD9SoRHMURAbU2RQWZgQt2t0vN3Ib7aVMIwStGdJhaDIuPHTg0WrwM6ogPDDqfFmHHm8XkviBHnkgFQWvovLHtRudSgU6g+5ReaT0G0HsWFC3aGVJhOEwo5EqJJxZgjIc533CJTyn6ZbV8C0PGPP3kZQb1C/zPCaVtQg02v3Vm1C+sivBfCFRRJlcXhfc5hvbtB40DcRFkJfkBbdHBwdAnRfuH8OnIeL9dWEFyNgR7ZIREnjqNahtZbUM9gBS1p1Zw3ffTls2QSyMvQobqwNOdfP2/LN0K8uiJJ8K7nh524wGANdTlmKY2cAAkUbZsO2FK7sLCcVDXShQptXFj31DEzdQCb9hAnarXK5C6qBFxloDGzV8b+xlALFQBIO8xwXlxR8jZq+CiVJmWHUr78A0fubstaBUSgpU1ZzdUl0plI6MczU/udM7miH/O1ih7t0ox745ahU/7eXEYOLNRAJs2gidol7m+apyY/qV7DIMhz</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>'
3438

3539
function Get-PositiveIntEnvironment {
3640
param([string]$Name, [int]$DefaultValue, [int]$Maximum)
@@ -184,7 +188,7 @@ function Convert-BytesToText {
184188
function Get-TrustedPublicKeyXml {
185189
param([string]$DownloadBase)
186190
if ([string]::IsNullOrWhiteSpace($env:CONTEXA_TRUSTED_PUBLIC_KEY_XML)) {
187-
return $script:PublicKeyXml
191+
return $PublicKeyXml
188192
}
189193
$uri = [Uri]$DownloadBase
190194
if (-not $uri.IsLoopback) {
@@ -226,10 +230,10 @@ function Get-TargetRelease {
226230
}
227231

228232
$manifestUrl = if ([string]::IsNullOrWhiteSpace($env:CONTEXA_CHANNEL_MANIFEST_URL)) {
229-
$script:DefaultChannelManifestUrl
233+
$DefaultChannelManifestUrl
230234
} else { $env:CONTEXA_CHANNEL_MANIFEST_URL.Trim() }
231235
$signatureUrl = if ([string]::IsNullOrWhiteSpace($env:CONTEXA_CHANNEL_SIGNATURE_URL)) {
232-
$script:DefaultChannelSignatureUrl
236+
$DefaultChannelSignatureUrl
233237
} else { $env:CONTEXA_CHANNEL_SIGNATURE_URL.Trim() }
234238
$manifestBytes = Invoke-BoundedDownload $manifestUrl
235239
$signatureBytes = Invoke-BoundedDownload $signatureUrl
@@ -526,7 +530,7 @@ function Invoke-ContexaInstaller {
526530
}
527531

528532
Write-Host (' ' + (Select-InstallerText 'Starting Contexa CLI installation.' 'Q29udGV4YSBDTEkg7ISk7LmY66W8IOyLnOyeke2VqeuLiOuLpC4='))
529-
$downloadBase = if ([string]::IsNullOrWhiteSpace($env:CONTEXA_RELEASE_DOWNLOAD_BASE)) { $script:DefaultDownloadBase } else { $env:CONTEXA_RELEASE_DOWNLOAD_BASE.TrimEnd('/') }
533+
$downloadBase = if ([string]::IsNullOrWhiteSpace($env:CONTEXA_RELEASE_DOWNLOAD_BASE)) { $DefaultDownloadBase } else { $env:CONTEXA_RELEASE_DOWNLOAD_BASE.TrimEnd('/') }
530534
$targetRelease = Get-TargetRelease $downloadBase
531535
$version = $targetRelease.ReleaseTag
532536
$expectedCliVersion = $targetRelease.CliVersion
@@ -656,24 +660,32 @@ function Invoke-ContexaInstaller {
656660

657661
try {
658662
Invoke-ContexaInstaller
659-
exit 0
660663
} catch {
664+
$InstallerFailed = $true
661665
$failureCode = 'INSTALLER_OPERATION_FAILED'
662666
if ($_.Exception.Message -match '\[([A-Z][A-Z0-9_]+)\]') {
663667
$failureCode = $Matches[1]
664668
}
665-
if ($script:InstallerLanguage -eq 'ko') {
666-
[Console]::Error.WriteLine(
669+
if ($InstallerLanguage -eq 'ko') {
670+
$failureSummary =
667671
'Contexa ' +
668672
(Select-InstallerText 'installer failed [' '7ISk7LmYIO2UhOuhnOq3uOueqCDsi6TtjKggWw==') +
669673
$failureCode +
670674
(Select-InstallerText ']: Check the error code, fix the cause, and run the same command again.' 'XTog7Jik66WYIOy9lOuTnOulvCDtmZXsnbjtlZjqs6Ag7JuQ7J247J2EIOyImOygle2VnCDrkqQg6rCZ7J2AIOuqheugueydhCDri6Tsi5wg7Iuk7ZaJ7ZWY7IS47JqULg==')
671-
)
672675
} else {
673-
[Console]::Error.WriteLine('Contexa installer failed [' + $failureCode + ']: ' + $_.Exception.Message)
674-
}
675-
[Console]::Error.WriteLine((Select-InstallerText 'The existing CLI was preserved when possible. Fix the reported cause and run the same command again.' '6rCA64ql7ZWcIOqyveyasCDquLDsobQgQ0xJ66W8IOuztOyhtO2WiOyKteuLiOuLpC4g67O06rOg65CcIOybkOyduOydhCDtlbTqsrDtlZwg65KkIOqwmeydgCDrqoXroLnsnYQg64uk7IucIOyLpO2Wie2VmOyEuOyalC4='))
676-
exit 1
676+
$failureSummary = 'Contexa installer failed [' + $failureCode + ']: ' + $_.Exception.Message
677+
}
678+
$preservationMessage = Select-InstallerText 'The existing CLI was preserved when possible. Fix the reported cause and run the same command again.' '6rCA64ql7ZWcIOqyveyasCDquLDsobQgQ0xJ66W8IOuztOyhtO2WiOyKteuLiOuLpC4g67O06rOg65CcIOybkOyduOydhCDtlbTqsrDtlZwg65KkIOqwmeydgCDrqoXroLnsnYQg64uk7IucIOyLpO2Wie2VmOyEuOyalC4='
679+
[Console]::Error.WriteLine($failureSummary)
680+
[Console]::Error.WriteLine($preservationMessage)
681+
throw [System.InvalidOperationException]::new(
682+
('Contexa installer failed [' + $failureCode + '].'),
683+
$_.Exception
684+
)
677685
} finally {
678-
$ProgressPreference = $script:OriginalProgressPreference
686+
$ProgressPreference = $OriginalProgressPreference
687+
if (-not $InstallerFailed -or -not $DirectFileInvocation) {
688+
[Console]::OutputEncoding = $OriginalConsoleOutputEncoding
689+
}
690+
}
679691
}

test/installer-runtime.test.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ function runWindowsInstaller(env, timeout = 10000) {
226226
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ps1], env, timeout);
227227
}
228228

229+
function runWindowsInstallerViaIex(env, timeout = 10000, catchFailure = false) {
230+
const sourcePath = ps1.replace(/'/g, "''");
231+
const invocation = `Invoke-Expression ([IO.File]::ReadAllText('${sourcePath}'))`;
232+
const command = catchFailure
233+
? `try { ${invocation} } catch { Write-Output ('__INSTALL_ERROR_CAUGHT__:' + $_.Exception.Message) }; Write-Output '__SHELL_ALIVE__'`
234+
: `${invocation}; Write-Output '__SHELL_ALIVE__'`;
235+
return run(powershell,
236+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], env, timeout);
237+
}
229238
function runPwshInstaller(env) {
230239
return run(pwsh, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ps1], env);
231240
}
@@ -393,7 +402,8 @@ test('PowerShell installer performs install, no-op, update, rollback and uninsta
393402
await withServer(releaseHandler(files), async (base) => {
394403
for (let iteration = 1; iteration <= lifecycleRepeats; iteration += 1) {
395404
const installDir = path.join(temp, `설치 경로 with space ${iteration}`);
396-
const first = await runWindowsInstaller(windowsInstallerEnv(base, installDir, '9.9.1-test', xml));
405+
const first = await runWindowsInstallerViaIex(windowsInstallerEnv(base, installDir, '9.9.1-test', xml));
406+
assert.match(first.stdout, /__SHELL_ALIVE__/);
397407
assert.equal(first.code, 0, first.stderr || first.stdout);
398408
assert.match(first.stdout, /Starting Contexa CLI installation/);
399409
assert.match(first.stdout, /Release v9\.9\.1-test found\. Checking authenticity/);
@@ -403,7 +413,8 @@ test('PowerShell installer performs install, no-op, update, rollback and uninsta
403413
const originalTime = fs.statSync(installed).mtimeMs;
404414
const originalDigest = sha256(fs.readFileSync(installed));
405415

406-
const same = await runWindowsInstaller(windowsInstallerEnv(base, installDir, '9.9.1-test', xml));
416+
const same = await runWindowsInstallerViaIex(windowsInstallerEnv(base, installDir, '9.9.1-test', xml));
417+
assert.match(same.stdout, /__SHELL_ALIVE__/);
407418
assert.equal(same.code, 0, same.stderr || same.stdout);
408419
assert.equal(fs.statSync(installed).mtimeMs, originalTime, 'same-version install must not replace the binary');
409420
assert.equal(sha256(fs.readFileSync(installed)), originalDigest);
@@ -426,6 +437,21 @@ test('PowerShell installer performs install, no-op, update, rollback and uninsta
426437
}
427438
});
428439
});
440+
test('PowerShell IEX failure preserves the caller session',
441+
{ skip: process.platform !== 'win32', timeout: 10000 }, async (t) => {
442+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'contexa-installer-iex-failure-'));
443+
t.after(() => fs.rmSync(temp, { recursive: true, force: true }));
444+
const result = await runWindowsInstallerViaIex({
445+
...process.env,
446+
CONTEXA_LANG: 'en',
447+
CONTEXA_INSTALL_DIR: path.join(temp, 'bin'),
448+
CONTEXA_SKIP_PATH_UPDATE: '1',
449+
CONTEXA_HTTP_CONNECT_TIMEOUT_SEC: 'invalid',
450+
}, 10000, true);
451+
assert.equal(result.code, 0, result.stderr || result.stdout);
452+
assert.match(result.stdout, /__INSTALL_ERROR_CAUGHT__:.*INSTALLER_OPERATION_FAILED/s);
453+
assert.match(result.stdout, /__SHELL_ALIVE__/);
454+
});
429455
test('PowerShell installer bounds HTTP retries and preserves the existing binary for the full fault matrix',
430456
{ skip: process.platform !== 'win32', timeout: 600000 }, async (t) => {
431457
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'contexa-installer-failures-'));

test/installers.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ function assertNoReplacementOrBoxDrawing(src) {
2828
test('install.ps1 enforces the signed, bounded and atomic installation contract', () => {
2929
const src = read(ps1Path);
3030
assert.ok(src.startsWith('#Requires -Version 5.1'));
31+
assert.match(src, /^& \{$/m, 'installer must isolate its preferences and functions from the IEX caller');
32+
assert.equal(/^\s*exit\s+[01]\s*$/m.test(src), false,
33+
'installer invoked through IEX must never terminate the caller PowerShell session');
34+
assert.match(src, /\[Console\]::OutputEncoding = \$OriginalConsoleOutputEncoding/);
3135
assert.equal(src.includes('E:\\projects'), false);
3236
assert.equal(src.includes('localBuildPath'), false);
3337
assert.equal(src.includes('docker'), false, 'installer must not block on Docker');

0 commit comments

Comments
 (0)