Files
orca/src/main/linux-package-install-command.test.ts
T
JinjingandClaude Opus 5 5887b36eff fix(updater): recover Linux .deb/.rpm installs that fail escalation (#12183)
* fix(updater): recover Linux .deb/.rpm installs that fail escalation

A `.deb` install fails with `No authentication agent found` when the session
has no polkit agent. Orca reported "Quit and reopen Orca, then try again" —
wrong advice — and its only action was Retry Download, discarding a verified
160 MB package that was still in the updater cache.

Keep the one-click install path, but make a failed root-package install
recoverable without downloading again:

- Retain the downloaded package and its expected SHA-512 from the
  `update-downloaded` event, mirroring electron-updater's cache-name rule.
- Capture the child stderr that BaseUpdater logs but drops from the `error`
  event, redact it (ANSI, control bytes, `<home>`, `<package>`, `<user>`,
  1 KiB cap), and classify the failure. Classification reads the original
  text — redaction can rewrite a matched phrase.
- Send a structured `linux-package-install` recovery status and render a
  dedicated card: Copy Install Command / Try Automatic Install Again /
  Show Package.
- Revalidate on every action: cache containment, lstat, streamed SHA-512,
  timingSafeEqual. Concurrent requests coalesce into one hash pass.
- Build the command from fixed tokens plus one POSIX-single-quoted absolute
  path, resolving sudo and the package manager only from /usr/bin, /bin,
  /usr/sbin, /sbin. Orca never runs it.
- Disable `autoInstallOnAppQuit` for .deb/.rpm so an ordinary quit cannot
  trigger the same failing escalation after the UI is gone.

Extracts the error-card presentation into UpdateErrorCardContent so
UpdateCard does not absorb another stateful surface.

Lifecycle breadcrumbs carry package type, reason, exit code and version —
never a path, command, username or raw child output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Improve Linux package install recovery diagnostics

- Distinguish invalid-package-path errors from missing package manager
- Expand ANSI escape sequence stripping to handle OSC hyperlinks and DCS
- Prevent generic error logs from overwriting specific diagnostic verdicts
- Add error handling for shell.openUrl in update UI
- Fix test isolation with proper afterEach hooks

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:46:49 -07:00

255 lines
9.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { statSyncMock } = vi.hoisted(() => ({ statSyncMock: vi.fn() }))
vi.mock('node:fs', () => ({ statSync: statSyncMock }))
// Executables the fake filesystem exposes, keyed by absolute path.
let executables: Map<string, { file: boolean; mode: number }>
function install(absolutePath: string, options?: { file?: boolean; mode?: number }): void {
executables.set(absolutePath, { file: options?.file ?? true, mode: options?.mode ?? 0o755 })
}
async function loadCommandModule() {
return import('./linux-package-install-command')
}
beforeEach(() => {
vi.resetModules()
executables = new Map()
statSyncMock.mockReset().mockImplementation((candidate: string) => {
const entry = executables.get(candidate)
if (!entry) {
const error = new Error(`ENOENT: ${candidate}`) as NodeJS.ErrnoException
error.code = 'ENOENT'
throw error
}
return { isFile: () => entry.file, mode: entry.mode }
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('quoteForPosixShell', () => {
it('wraps plain values in single quotes', async () => {
const { quoteForPosixShell } = await loadCommandModule()
expect(quoteForPosixShell('/tmp/orca.deb')).toBe("'/tmp/orca.deb'")
})
it('quotes spaces', async () => {
const { quoteForPosixShell } = await loadCommandModule()
expect(quoteForPosixShell('/tmp/Orca Setup.deb')).toBe("'/tmp/Orca Setup.deb'")
})
it('neutralizes expansion, command substitution, separators, and double quotes', async () => {
const { quoteForPosixShell } = await loadCommandModule()
expect(quoteForPosixShell('/tmp/$HOME.deb')).toBe("'/tmp/$HOME.deb'")
expect(quoteForPosixShell('/tmp/`id`.deb')).toBe("'/tmp/`id`.deb'")
expect(quoteForPosixShell('/tmp/a;rm -rf ~.deb')).toBe("'/tmp/a;rm -rf ~.deb'")
expect(quoteForPosixShell('/tmp/"quoted".deb')).toBe('\'/tmp/"quoted".deb\'')
})
it('closes and reopens the quote around a literal single quote', async () => {
const { quoteForPosixShell } = await loadCommandModule()
expect(quoteForPosixShell("/tmp/it's.deb")).toBe(`'/tmp/it'"'"'s.deb'`)
})
it('escapes every single quote in a value', async () => {
const { quoteForPosixShell } = await loadCommandModule()
expect(quoteForPosixShell("'; rm -rf /; '")).toBe(`''"'"'; rm -rf /; '"'"''`)
})
it('quotes an empty value', async () => {
const { quoteForPosixShell } = await loadCommandModule()
expect(quoteForPosixShell('')).toBe("''")
})
})
describe('resolveTrustedExecutable', () => {
it('resolves from /usr/bin first', async () => {
install('/usr/bin/sudo')
install('/bin/sudo')
const { resolveTrustedExecutable } = await loadCommandModule()
expect(resolveTrustedExecutable('sudo')).toBe('/usr/bin/sudo')
})
it('falls through the remaining trusted directories', async () => {
install('/sbin/dpkg')
const { resolveTrustedExecutable } = await loadCommandModule()
expect(resolveTrustedExecutable('dpkg')).toBe('/sbin/dpkg')
})
it('consults only the trusted directories', async () => {
const { resolveTrustedExecutable } = await loadCommandModule()
expect(resolveTrustedExecutable('sudo')).toBeNull()
expect(statSyncMock.mock.calls.map((call) => call[0])).toEqual([
'/usr/bin/sudo',
'/bin/sudo',
'/usr/sbin/sudo',
'/sbin/sudo'
])
})
it('ignores a user-writable absolute PATH decoy', async () => {
install('/home/user/.local/bin/sudo')
vi.stubEnv('PATH', '/home/user/.local/bin:/usr/bin')
const { resolveTrustedExecutable } = await loadCommandModule()
expect(resolveTrustedExecutable('sudo')).toBeNull()
expect(statSyncMock).not.toHaveBeenCalledWith('/home/user/.local/bin/sudo')
})
it('rejects a non-executable file', async () => {
install('/usr/bin/apt', { mode: 0o644 })
const { resolveTrustedExecutable } = await loadCommandModule()
expect(resolveTrustedExecutable('apt')).toBeNull()
})
it('accepts a file executable only by group or other', async () => {
install('/usr/bin/apt', { mode: 0o011 })
const { resolveTrustedExecutable } = await loadCommandModule()
expect(resolveTrustedExecutable('apt')).toBe('/usr/bin/apt')
})
it('rejects a directory of the same name', async () => {
install('/usr/bin/apt', { file: false })
const { resolveTrustedExecutable } = await loadCommandModule()
expect(resolveTrustedExecutable('apt')).toBeNull()
})
})
describe('buildLinuxPackageInstallCommand', () => {
it('refuses a non-absolute package path before looking for anything', async () => {
// Why: zypper/dnf/yum/rpm take no `--`, so a relative or dash-leading path would become an option.
install('/usr/bin/sudo')
install('/usr/bin/apt')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
for (const packagePath of ['orca.deb', './orca.deb', '--force-all', '-i']) {
expect(buildLinuxPackageInstallCommand('deb', packagePath)).toEqual({
ok: false,
reason: 'invalid-package-path'
})
}
})
it('reports no-sudo when sudo is absent', async () => {
install('/usr/bin/apt')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
ok: false,
reason: 'no-sudo'
})
})
it('reports no-package-manager when no manager is installed', async () => {
install('/usr/bin/sudo')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
ok: false,
reason: 'no-package-manager'
})
})
it('does not fall back to an rpm manager for a deb package', async () => {
install('/usr/bin/sudo')
install('/usr/bin/dnf')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
ok: false,
reason: 'no-package-manager'
})
})
it('builds an apt command', async () => {
install('/usr/bin/sudo')
install('/usr/bin/apt')
install('/usr/bin/dpkg')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
ok: true,
command: "/usr/bin/sudo /usr/bin/apt install -- '/tmp/orca.deb'"
})
})
it('falls back to dpkg', async () => {
install('/usr/bin/sudo')
install('/usr/bin/dpkg')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
ok: true,
command: "/usr/bin/sudo /usr/bin/dpkg -i -- '/tmp/orca.deb'"
})
})
it('builds a zypper command', async () => {
install('/usr/bin/sudo')
install('/usr/bin/zypper')
install('/usr/bin/dnf')
install('/usr/bin/yum')
install('/usr/bin/rpm')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
ok: true,
command:
"/usr/bin/sudo /usr/bin/zypper --no-refresh install --allow-unsigned-rpm -f '/tmp/orca.rpm'"
})
})
it('prefers dnf over yum and rpm', async () => {
install('/usr/bin/sudo')
install('/usr/bin/dnf')
install('/usr/bin/yum')
install('/usr/bin/rpm')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
ok: true,
command: "/usr/bin/sudo /usr/bin/dnf install --nogpgcheck '/tmp/orca.rpm'"
})
})
it('falls back to yum', async () => {
install('/usr/bin/sudo')
install('/usr/bin/yum')
install('/usr/bin/rpm')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
ok: true,
command: "/usr/bin/sudo /usr/bin/yum install --nogpgcheck '/tmp/orca.rpm'"
})
})
it('falls back to rpm', async () => {
install('/usr/bin/sudo')
install('/sbin/rpm')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
ok: true,
command: "/usr/bin/sudo /sbin/rpm -Uvh '/tmp/orca.rpm'"
})
})
it('never adds an unattended-confirmation flag', async () => {
install('/usr/bin/sudo')
install('/usr/bin/apt')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
const result = buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')
expect(result.ok).toBe(true)
expect(result.ok ? result.command : '').not.toMatch(
/(^|\s)(-y|--yes|--noconfirm|--assumeyes)(\s|$)/
)
})
it('quotes a hostile package path', async () => {
install('/usr/bin/sudo')
install('/usr/bin/apt')
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
expect(buildLinuxPackageInstallCommand('deb', "/tmp/a b'; id #.deb")).toEqual({
ok: true,
command: `/usr/bin/sudo /usr/bin/apt install -- '/tmp/a b'"'"'; id #.deb'`
})
})
})