Files
orca/src/main/linux-package-update-recovery.ts
T
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00

343 lines
13 KiB
TypeScript

import { createHash, timingSafeEqual } from 'node:crypto'
import { createReadStream } from 'node:fs'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { shell } from 'electron'
import type {
LinuxPackageInstallRecovery,
LinuxRootPackageType
} from '../shared/update-status-types'
import { getLinuxRootPackageType } from './linux-update-package-type'
import { buildLinuxPackageInstallCommand } from './linux-package-install-command'
const SHA512_BYTE_LENGTH = 64
// electron-updater downloads into <cacheRoot>/<updaterCacheDirName>/pending; nothing else is trusted.
const PENDING_DIRECTORY_NAME = 'pending'
export type LinuxPackageArtifact = {
packageType: LinuxRootPackageType
version: string
path: string
sha512: string
}
export type LinuxPackageRecoveryUnavailableReason =
| 'missing'
| 'not-regular'
| 'hash-mismatch'
| 'no-sudo'
| 'no-package-manager'
| 'invalid-package-path'
| 'read-failed'
export type LinuxPackageInstructionsResult =
| { ok: true; command: string; packageFileName: string }
| { ok: false; reason: LinuxPackageRecoveryUnavailableReason }
export type LinuxPackageRevealResult =
| { ok: true }
| { ok: false; reason: LinuxPackageRecoveryUnavailableReason }
type ValidationResult =
| { ok: true; artifact: LinuxPackageArtifact }
| { ok: false; reason: 'missing' | 'not-regular' | 'hash-mismatch' | 'read-failed' }
let trackedArtifact: LinuxPackageArtifact | null = null
// Why: the renderer debounces clicks, but the IPC boundary must not allow parallel hashing of a 160 MB package.
let inFlightValidation: { key: string; promise: Promise<ValidationResult> } | null = null
export function getTrackedLinuxPackageArtifact(): LinuxPackageArtifact | null {
return trackedArtifact
}
export function clearTrackedLinuxPackageArtifact(): void {
trackedArtifact = null
}
/** Clears the artifact only when a different version demonstrably takes over the update cycle. */
export function clearTrackedLinuxPackageArtifactForOtherVersion(version: unknown): void {
if (!trackedArtifact) {
return
}
// Why: an unknown/empty version is not evidence of another cycle and must not destroy recovery.
if (typeof version !== 'string' || version.length === 0) {
return
}
if (version === trackedArtifact.version) {
return
}
trackedArtifact = null
}
/**
* Mirrors electron-updater's cache-name rule: resolve the manifest URL against a dummy base, decode
* the pathname, require the package extension, and match the basename of the downloaded file.
* A malformed encoding or an ambiguous file/hash pairing disables cached-package recovery rather
* than guessing a digest.
*/
function resolveExpectedSha512(
files: unknown,
downloadedFile: string,
packageType: LinuxRootPackageType
): string | null {
if (!Array.isArray(files)) {
return null
}
const targetName = path.basename(downloadedFile)
const extension = `.${packageType}`
let resolved: string | null = null
for (const entry of files) {
const url = (entry as { url?: unknown })?.url
if (typeof url !== 'string' || url.length === 0) {
continue
}
let pathname: string
try {
pathname = decodeURIComponent(new URL(url, 'http://update-file-name.invalid/').pathname)
} catch {
return null
}
if (!pathname.toLowerCase().endsWith(extension)) {
continue
}
if (path.posix.basename(pathname) !== targetName) {
continue
}
const sha512 = (entry as { sha512?: unknown })?.sha512
if (typeof sha512 !== 'string' || sha512.length === 0) {
return null
}
if (resolved !== null && resolved !== sha512) {
return null
}
resolved = sha512
}
return resolved
}
/**
* Retains the verified download so a failed root-package install stays recoverable without paying
* for the 160 MB transfer again. Only the in-memory event metadata is trusted for the digest.
*/
export function captureLinuxPackageArtifact(event: unknown): void {
const packageType = getLinuxRootPackageType()
if (!packageType) {
return
}
const downloadedFile = (event as { downloadedFile?: unknown })?.downloadedFile
const version = (event as { version?: unknown })?.version
if (typeof downloadedFile !== 'string' || !path.isAbsolute(downloadedFile)) {
return
}
if (!downloadedFile.toLowerCase().endsWith(`.${packageType}`)) {
return
}
if (typeof version !== 'string' || version.length === 0) {
return
}
const sha512 = resolveExpectedSha512(
(event as { files?: unknown })?.files,
downloadedFile,
packageType
)
// Why: a malformed digest can never validate. Arming recovery on it would send the user to the
// alarming "no longer matches the verified release" path for what is a release-metadata problem.
if (!sha512 || !decodeExpectedDigest(sha512)) {
// Why: an unresolvable digest only means THIS event cannot arm recovery. A previously retained
// artifact carries its own digest and is revalidated on every use, so dropping it would force a
// needless 160 MB redownload of a file that is still on disk and still verifiable.
return
}
trackedArtifact = { packageType, version, path: downloadedFile, sha512 }
}
function isInsideDirectory(root: string, target: string): boolean {
const relative = path.relative(root, target)
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative)
}
/**
* The cache root electron-updater downloads into. Mirrors its own `getAppCacheDir()` rule, which on
* Linux resolves to the same directory Electron reports as the `cache` path.
*/
function getUpdaterCacheRoot(): string {
// Mirrors upstream exactly, including its lack of an absoluteness check — diverging here would put
// the real download outside the directory this module treats as the cache.
return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache')
}
/**
* Rejects traversal and a symlinked parent that escapes the updater cache.
*
* The security boundary is the cache-root anchor below: electron-updater builds `downloadedFile` from
* the `fileName` in the cache's own writable `update-info.json` without calling `basename`, so without
* it a manipulated entry could point at `/tmp` or `/dev/shm` where a DIFFERENT principal can win the
* swap race. The `<cacheRoot>/<updaterCacheDirName>/pending` shape check narrows the accepted paths to
* the one place electron-updater actually downloads into; it is defence in depth, not a boundary — a
* same-uid process can create its own `pending` directory, and per the design's trust model such a
* process can already overwrite the real cached package anyway.
*/
async function isContainedInCache(filePath: string): Promise<boolean> {
const cacheRoot = path.resolve(getUpdaterCacheRoot())
if (!isInsideDirectory(cacheRoot, path.resolve(filePath))) {
return false
}
const realCacheRoot = path.resolve(await fsp.realpath(cacheRoot))
const realParent = path.resolve(await fsp.realpath(path.dirname(filePath)))
if (!isInsideDirectory(realCacheRoot, path.join(realParent, path.basename(filePath)))) {
return false
}
if (path.basename(realParent) !== PENDING_DIRECTORY_NAME) {
return false
}
// The updater cache directory sits directly under the cache root, so `pending` is exactly two down.
return path.dirname(path.dirname(realParent)) === realCacheRoot
}
function decodeExpectedDigest(sha512: string): Buffer | null {
const trimmed = sha512.trim()
const decoded = Buffer.from(trimmed, 'base64')
if (decoded.byteLength !== SHA512_BYTE_LENGTH) {
return null
}
// Why: Buffer.from silently drops invalid base64 characters; round-tripping rejects malformed input.
// The round-trip emits standard base64, so a URL-safe digest would be rejected — electron-updater's
// latest-linux.yml is standard base64, and failing closed on an unrecognized encoding is correct.
return decoded.toString('base64') === trimmed ? decoded : null
}
function streamSha512(filePath: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const hash = createHash('sha512')
const stream = createReadStream(filePath)
stream.on('error', reject)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('end', () => resolve(hash.digest()))
})
}
async function validateArtifact(artifact: LinuxPackageArtifact): Promise<ValidationResult> {
const expectedDigest = decodeExpectedDigest(artifact.sha512)
if (!expectedDigest) {
return { ok: false, reason: 'hash-mismatch' }
}
try {
if (!(await isContainedInCache(artifact.path))) {
return { ok: false, reason: 'not-regular' }
}
const stats = await fsp.lstat(artifact.path)
if (stats.isSymbolicLink() || !stats.isFile()) {
return { ok: false, reason: 'not-regular' }
}
const actualDigest = await streamSha512(artifact.path)
if (
actualDigest.byteLength !== expectedDigest.byteLength ||
!timingSafeEqual(actualDigest, expectedDigest)
) {
return { ok: false, reason: 'hash-mismatch' }
}
return { ok: true, artifact }
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code
return { ok: false, reason: code === 'ENOENT' ? 'missing' : 'read-failed' }
}
}
/**
* Hashes the artifact, joining an identical pass already in flight. `fresh` opts out of that reuse:
* a verdict that reaches a root installer must cover the bytes as of this call, not as of whenever
* some earlier Copy/Show click started streaming.
*/
function runValidation(
artifact: LinuxPackageArtifact,
options?: { fresh?: boolean }
): Promise<ValidationResult> {
const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}`
if (!options?.fresh && inFlightValidation?.key === key) {
return inFlightValidation.promise
}
const promise: Promise<ValidationResult> = validateArtifact(artifact).finally(() => {
// Why: identity, not key — a fresh install pass may already have replaced this entry.
if (inFlightValidation?.promise === promise) {
inFlightValidation = null
}
})
inFlightValidation = { key, promise }
return promise
}
/**
* Revalidates the retained package against the digest captured from the release metadata. This is
* an integrity check against that HTTPS metadata, not a package signature and not a privilege
* boundary — a same-user process can still replace the file after this returns.
*/
async function validateTrackedArtifact(
recovery: LinuxPackageInstallRecovery
): Promise<ValidationResult> {
const artifact = trackedArtifact
if (
!artifact ||
artifact.version !== recovery.version ||
artifact.packageType !== recovery.packageType
) {
return { ok: false, reason: 'missing' }
}
return runValidation(artifact)
}
export async function resolveLinuxPackageInstallInstructions(
recovery: LinuxPackageInstallRecovery
): Promise<LinuxPackageInstructionsResult> {
const validation = await validateTrackedArtifact(recovery)
if (!validation.ok) {
return validation
}
const { artifact } = validation
const command = buildLinuxPackageInstallCommand(artifact.packageType, artifact.path)
if (!command.ok) {
return command
}
return {
ok: true,
command: command.command,
packageFileName: path.basename(artifact.path)
}
}
/**
* Re-proves the retained package immediately before the privileged installer consumes it.
*
* The cache path is user-writable, so a digest checked when the download finished says nothing
* about the bytes `dpkg -i` will read minutes later. Re-hashing here does not close the race —
* only an immutable handoff would — but it shrinks the window from "since the download" to
* "since this call", and it catches the artifact being swapped or deleted outright. Takes the
* artifact rather than a recovery so both the retry and the plain "Restart to Update" install
* are covered.
*/
export async function revalidateLinuxPackageForInstall(
artifact: LinuxPackageArtifact
): Promise<{ ok: true } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason }> {
const validation = await runValidation(artifact, { fresh: true })
return validation.ok ? { ok: true } : { ok: false, reason: validation.reason }
}
export async function revealLinuxPackage(
recovery: LinuxPackageInstallRecovery
): Promise<LinuxPackageRevealResult> {
const validation = await validateTrackedArtifact(recovery)
if (!validation.ok) {
return validation
}
// Why: this cache path must not travel through the workspace shell:openPath API, whose execution
// host can be an SSH or WSL machine rather than the one that owns the installed package.
try {
shell.showItemInFolder(validation.artifact.path)
} catch {
// Why: every other failure in this module reports through {ok:false}; a raw throw here would
// reject the IPC with an unredacted message and skip the lifecycle record.
return { ok: false, reason: 'read-failed' }
}
return { ok: true }
}