orca/src/main/win32-utils.ts

170 lines
5.8 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { delimiter, join } from 'node:path'
import { existsSync } from 'node:fs'
/**
* Full path to icacls.exe. Electron's main process may have a stripped PATH
* that excludes System32, causing bare `icacls` to throw ENOENT.
*/
export function getIcaclsExePath(): string {
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe`
}
/**
* Full path to cmd.exe, respecting the ComSpec convention used elsewhere in
* the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts).
* Falls back to SystemRoot-based path if ComSpec is unset.
*/
export function getCmdExePath(): string {
return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe`
}
/** Whether a resolved command path points to a Windows batch script (.cmd/.bat). */
export function isWindowsBatchScript(commandPath: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
}
export function resolveWindowsCommand(
command: string,
env: NodeJS.ProcessEnv = process.env
): string {
if (process.platform !== 'win32') {
return command
}
if (/[\\/]/.test(command) || /\.[a-z0-9]+$/i.test(command)) {
return command
}
const pathEnv = env.PATH ?? env.Path
if (!pathEnv) {
return command
}
for (const directory of pathEnv.split(delimiter).filter(Boolean)) {
for (const name of [`${command}.cmd`, `${command}.exe`, `${command}.bat`, command]) {
const candidate = join(directory, name)
if (existsSync(candidate)) {
return candidate
}
}
}
return command
}
export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
export class UnsafeWindowsBatchArgumentsError extends Error {
constructor() {
super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
this.name = 'UnsafeWindowsBatchArgumentsError'
}
}
function hasUnsafeWindowsBatchSyntax(value: string): boolean {
return /[&|<>^"%!\r\n]/.test(value)
}
/** Check whether an error is a Windows permission error (EACCES or EPERM). */
export function isPermissionError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EACCES' ||
(error as NodeJS.ErrnoException).code === 'EPERM')
)
}
// Why: USERNAME-only identity resolution silently no-ops under services, CI,
// and hardened envs where USERNAME is unset. Fall back to the SID via
// `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative
// and always available on Windows. Cached because it never changes in-process.
let cachedIdentity: string | null | undefined
function resolveCurrentIdentity(): string | null {
if (cachedIdentity !== undefined) {
return cachedIdentity
}
if (process.env.USERNAME) {
cachedIdentity = process.env.USERNAME
return cachedIdentity
}
try {
const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
timeout: 5000
}).trim()
// CSV columns: "DOMAIN\\user","S-1-5-21-..."
const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output)
cachedIdentity = sidMatch ? `*${sidMatch[1]}` : null
} catch {
cachedIdentity = null
}
return cachedIdentity
}
/**
* Grant Full Control (OI)(CI)(F) on a directory for the current user.
* Used to fix Chromium's Protected DACL propagation which leaves child
* directories with Inherit-Only ACEs that deny direct file creation.
*
* Why /grant:r not /inheritance:e: Chromium's ACEs carry the Inherit-Only
* flag when propagated to children, so restoring inheritance does not grant
* the directory itself any effective permissions. An explicit ACE survives
* future DACL propagation and grants create-file rights.
*/
export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }): void {
const identity = resolveCurrentIdentity()
if (!identity) {
return
}
const args = [dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`]
if (options?.recursive) {
args.push('/T', '/C')
}
// Why: /T walks the entire subtree; a 10s cap can starve on large userData
// dirs (tens of thousands of cached chromium files), making the startup
// grant silently fail. Give recursive calls a generous budget.
const timeout = options?.recursive ? 60_000 : 10_000
execFileSync(getIcaclsExePath(), args, {
stdio: 'ignore',
windowsHide: true,
timeout
})
}
/**
* Resolve spawn parameters for a command that may be a Windows batch script.
*
* Why: Node's spawn() cannot execute .cmd/.bat files directly without
* shell:true, but shell:true with an args array triggers DEP0190 because
* args are concatenated, not escaped. Routing through cmd.exe /c explicitly
* avoids the deprecation warning while passing args correctly.
*
* Why /d: disables per-machine/user AutoRun registry commands so a background
* spawn cannot inherit surprising side effects from the user's shell config.
*
* SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the command
* line. Args with cmd metacharacters are rejected instead of escaped because
* the agent prompt may contain arbitrary staged diff text.
*/
export function getSpawnArgsForWindows(
command: string,
args: string[]
): { spawnCmd: string; spawnArgs: string[] } {
if (isWindowsBatchScript(command)) {
for (const value of [command, ...args]) {
if (hasUnsafeWindowsBatchSyntax(value)) {
throw new UnsafeWindowsBatchArgumentsError()
}
}
// Why: when Node passes a pre-quoted command line as one argv entry,
// cmd.exe sees literal escaped quotes on Windows and refuses to run .cmd
// shims. Separate argv entries let Node quote spaces without breaking cmd.
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
}
return { spawnCmd: command, spawnArgs: args }
}