Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/credential-store-local/src/windows.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

type WindowsCredentialResult = {
Expand All @@ -9,6 +10,17 @@ type WindowsCredentialResult = {

const windowsScript = fileURLToPath(new URL("../runtime/windows-credential.ps1", import.meta.url));

/**
* The OS copy of Windows PowerShell. Resolved through SystemRoot because a bare name lets
* CreateProcess search the current directory first, where a planted powershell.exe would run.
*/
function windowsPowerShell(): string {
const systemRoot = process.env.SystemRoot;
return systemRoot === undefined
? "powershell.exe"
: join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
}

/** PasswordVault password field; OAuth JSON routinely exceeds this. */
export const windowsLockerPasswordLimit = 512;

Expand All @@ -31,7 +43,7 @@ export function windowsCredential(
): Promise<WindowsCredentialResult> {
if (secret !== undefined) assertWindowsLockerSecret(secret, account);
return new Promise((resolve, reject) => {
const child = execFile("powershell.exe", [
const child = execFile(windowsPowerShell(), [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-File", windowsScript, "-Operation", operation,
], {
Expand Down
30 changes: 25 additions & 5 deletions packages/runtime-local/src/programs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,24 @@ async function startDetached(start: ManagedProgramCommand, root: string, logFd:
});
}

/** A PowerShell single-quoted literal, which escapes by doubling the quote and nothing else. */
function powershellLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
/**
* A PowerShell single-quoted literal, which escapes by doubling the quote and nothing else.
* Windows PowerShell accepts U+2018–U+201B as quote delimiters beside the ASCII quote, so an
* unescaped typographic quote would end the literal early and reopen script parsing.
*/
export function powershellLiteral(value: string): string {
return `'${value.replace(/['\u2018-\u201B]/gu, (quote) => quote + quote)}'`;
}

/**
* The OS copy of Windows PowerShell. Resolved through SystemRoot because a bare name lets
* CreateProcess search the current directory first, where a planted powershell.exe would run.
*/
function windowsPowerShell(): string {
const systemRoot = process.env.SystemRoot;
return systemRoot === undefined
? "powershell.exe"
: join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
}

/**
Expand Down Expand Up @@ -238,7 +253,12 @@ export async function startWithOwnConsole(
): Promise<{ readonly pid?: number; readonly detail?: string }> {
const errorPath = processErrorLogPath(logPath);
const environment = Object.entries(start.env ?? {})
.map(([name, value]) => `$env:${name} = ${powershellLiteral(String(value))}`).join("\n");
.map(([name, value]) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
throw new Error(`Program environment name ${name} must be a portable variable name`);
}
return `$env:${name} = ${powershellLiteral(String(value))}`;
}).join("\n");
const argumentList = start.args.length === 0
? ""
: ` -ArgumentList ${powershellLiteral(windowsCommandLine(start.args))}`;
Expand All @@ -255,7 +275,7 @@ export async function startWithOwnConsole(

const shell = await new Promise<{ readonly out: string; readonly err: string }>((settle) => {
const encoded = Buffer.from(script, "utf16le").toString("base64");
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
const child = spawn(windowsPowerShell(), ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime-local/test/powershell-literal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { powershellLiteral } from "../src/programs.js";

test("PowerShell literals escape every quote form the tokenizer accepts", () => {
assert.equal(powershellLiteral("plain"), "'plain'");
assert.equal(powershellLiteral("it's"), "'it''s'");
// Windows PowerShell accepts U+2018–U+201B as quote delimiters too; each must
// double or the literal ends early and reopens script parsing.
assert.equal(powershellLiteral("it’s"), "'it’’s'");
assert.equal(powershellLiteral("a‘b‚c‛d"), "'a‘‘b‚‚c‛‛d'");
});