From a0be827650a28bd67f41abe6637714f55039824d Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 7 Oct 2026 22:57:16 +0800 Subject: [PATCH] test(main): cover updater fallback, ipc sender guard, log sanitizer, sftp timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updater-fallback.mjs (82 assertions): GitHub probe fallback to Gitea, timeout budgets, in-flight check never stuck in 'checking'; updater.ts gains a setUpdateTimeouts test seam, electron-updater aliased to a stub - ipc-guard.mjs (43): trusted-frame guard exercised through real registerIpc handlers with forged senderFrames; electron-stub now records registrations via globalThis so bundle and test share one instance - log-sanitizer.mjs (71): CSI/OSC/charset state machine, alt-screen fold, byte-split fuzz equal to whole-chunk output; fixes a wrong comment - sftp-timeout.mjs (39): per-op timeouts (metadata 30s, transfer chunk 60s, open 10s) evict half-dead channels with one retry, slow-but-progressing transfers untouched, late rejections never unhandled - reservedAccelerators.ts: single pure isReservedAccelerator shared by the settings recorder and applyGlobalShortcut (the two tables had drifted — main now also refuses Ctrl+=/-/0/PgUp/PgDn legacy values); 56 assertions - ssh-loopback.mjs loads the real ssh.ts via a bundle (50 assertions): TOFU pinning, fail-closed stores, auth gate, connect budget Offline suite grows 13 -> 17. Renderer test framework evaluated: not introducing vitest/jsdom; pure logic keeps being extracted and tested through the existing bundle harness. --- .gitignore | 6 + package.json | 2 +- src/main/globalShortcuts.ts | 32 +- src/main/logSanitizer.ts | 4 +- src/main/sftp.ts | 105 +++- src/main/updater.ts | 39 +- src/renderer/src/settings/SettingsTabs.tsx | 26 +- src/shared/i18n/dicts/en/main.ts | 1 + src/shared/i18n/dicts/ja/main.ts | 1 + src/shared/i18n/dicts/zh-CN/main.ts | 1 + src/shared/i18n/dicts/zh-TW/main.ts | 1 + src/shared/reservedAccelerators.ts | 61 +++ tests/build-bundles.cjs | 31 +- tests/electron-stub.cjs | 81 +++- tests/electron-updater-stub.cjs | 73 +++ tests/ipc-guard.mjs | 226 +++++++++ tests/log-sanitizer.mjs | 277 +++++++++++ tests/reserved-accelerators.mjs | 119 +++++ tests/sftp-timeout.mjs | 456 +++++++++++++++++ tests/ssh-loopback.mjs | 465 ++++++++++++++---- tests/updater-fallback.mjs | 537 +++++++++++++++++++++ 21 files changed, 2370 insertions(+), 174 deletions(-) create mode 100644 src/shared/reservedAccelerators.ts create mode 100644 tests/electron-updater-stub.cjs create mode 100644 tests/ipc-guard.mjs create mode 100644 tests/log-sanitizer.mjs create mode 100644 tests/reserved-accelerators.mjs create mode 100644 tests/sftp-timeout.mjs create mode 100644 tests/updater-fallback.mjs diff --git a/.gitignore b/.gitignore index 683850f..0951cdd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,12 @@ tests/.session-e2e.cjs tests/.hl-split-smoke.cjs tests/.hl-rules.cjs tests/.zmodem-e2e.cjs +tests/.ssh.cjs +tests/.updater.cjs +tests/.ipc.cjs +tests/.ipc-channels.cjs +tests/.log-sanitizer.cjs +tests/.reserved-accelerators.cjs release/ # stray local test artifacts diff --git a/package.json b/package.json index 16c8184..21af7b9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "pretest": "npm run typecheck", - "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs", + "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs", "predist": "npm test && npm install --package-lock-only", "dist": "electron-vite build && electron-builder --win msi nsis", "dist:dir": "electron-vite build && electron-builder --win --dir" diff --git a/src/main/globalShortcuts.ts b/src/main/globalShortcuts.ts index 807a777..d1ecbf6 100644 --- a/src/main/globalShortcuts.ts +++ b/src/main/globalShortcuts.ts @@ -1,33 +1,13 @@ import { BrowserWindow, globalShortcut } from 'electron' - -/** - * Chords the app owns outright: Ctrl+L is the panic lock, captured in the main - * window's before-input-event. The settings recorder (SettingsTabs.tsx, - * RESERVED_EXACT_ACCELERATORS) refuses to save it, but that guard only covers - * values entered after it existed — a shortcut persisted by an older build - * still arrives here, and a global registration intercepts the key at the OS - * level even while the window is focused, silently killing the lock shortcut. - * Normalized (modifier aliases + case folded) so every spelling is caught. - */ -const RESERVED_ACCELERATORS = new Set(['control+l', 'commandorcontrol+l']) - -function normalizeAccelerator(accelerator: string): string { - return accelerator - .split('+') - .map((part) => { - const p = part.trim().toLowerCase() - if (p === 'ctrl') return 'control' - if (p === 'cmdorctrl' || p === 'commandorctrl') return 'commandorcontrol' - return p - }) - .join('+') -} +import { isReservedAccelerator } from '@shared/reservedAccelerators' /** * Register the global show/hide toggle for the main window. * * - accelerator '' / undefined => disabled (no global key bound). - * - A reserved chord (Ctrl+L) is skipped: see RESERVED_ACCELERATORS. + * - A reserved chord (Ctrl+L, Ctrl+=/-/0/PgUp/PgDn) is skipped: see + * @shared/reservedAccelerators for why and for the shared table the settings + * recorder uses too. * - Passing an invalid accelerator string makes Electron's register() throw; * we swallow that here so a bad user-supplied value never crashes the app. * - register() returning false means the accelerator is already taken by @@ -39,9 +19,9 @@ export function applyGlobalShortcut(accelerator: string | undefined): void { globalShortcut.unregisterAll() if (!accelerator) return - if (RESERVED_ACCELERATORS.has(normalizeAccelerator(accelerator))) { + if (isReservedAccelerator(accelerator)) { console.warn( - `[global-shortcut] "${accelerator}" is reserved for the Ctrl+L lock shortcut; not registering` + `[global-shortcut] "${accelerator}" is reserved for an in-app shortcut; not registering` ) return } diff --git a/src/main/logSanitizer.ts b/src/main/logSanitizer.ts index f9bcb5d..2385b81 100644 --- a/src/main/logSanitizer.ts +++ b/src/main/logSanitizer.ts @@ -66,7 +66,9 @@ export class LogSanitizer { } else if (c === '\n') { out += this.emitLine() } else if (c === '\t' || c >= ' ') { - // printable + tab; DEL and C0 controls (bell etc.) are dropped + // printable + tab; C0 controls (bell, backspace, …) are dropped. + // Note DEL (0x7F) is not a C0 control and passes this test, so it + // is kept as an ordinary character. if (this.alt) this.altDirty = true else this.line += c } diff --git a/src/main/sftp.ts b/src/main/sftp.ts index fec8a31..6ba269a 100644 --- a/src/main/sftp.ts +++ b/src/main/sftp.ts @@ -33,18 +33,87 @@ export function registerSftpClientProvider(provider: (id: string) => Client | un } function p(fn: (cb: (err: Error | null, res: T) => void) => void): Promise { + return bounded(rawP(fn), timeouts.op) +} + +/** For ssh2 calls whose callback only yields an error. */ +function pVoid(fn: (cb: (err: Error | null) => void) => void): Promise { + return bounded(rawVoid(fn), timeouts.op) +} + +/** + * Transfer-chunk variants: a 256KB read/write on a slow link is legitimately + * seconds, so these run on the wider `transfer` budget instead of the metadata + * one. Same class of failure, different tolerance. + */ +function pTransfer(fn: (cb: (err: Error | null, res: T) => void) => void): Promise { + return bounded(rawP(fn), timeouts.transfer) +} + +function pVoidTransfer(fn: (cb: (err: Error | null) => void) => void): Promise { + return bounded(rawVoid(fn), timeouts.transfer) +} + +/** + * Operation budgets. + * + * Two classes, because one number cannot serve both: metadata round trips are + * milliseconds on any working link, while a 256KB transfer chunk on a slow link + * is legitimately seconds (and the upload path additionally waits on a peer + * that ACKs lazily — see the transfer section). The metadata budget is the + * "channel is dead" detector; the transfer budget is a backstop for the same + * failure at chunk granularity, set wide enough that it cannot fire on a merely + * slow transfer. + */ +const timeouts = { op: 30_000, transfer: 60_000, open: 10_000 } + +/** + * Test seam: shrink the budgets so the offline harness does not have to wait + * them out. Mirrors setLocalPathPolicy — injected, never read from renderer + * input. + */ +export function setSftpTimeouts(patch: { op?: number; transfer?: number; open?: number }): void { + if (patch.op !== undefined) timeouts.op = patch.op + if (patch.transfer !== undefined) timeouts.transfer = patch.transfer + if (patch.open !== undefined) timeouts.open = patch.open +} + +/** Unbounded primitive behind `p` / `pVoid`. */ +function rawP(fn: (cb: (err: Error | null, res: T) => void) => void): Promise { return new Promise((resolve, reject) => { fn((err, res) => (err ? reject(err) : resolve(res))) }) } -/** For ssh2 calls whose callback only yields an error. */ -function pVoid(fn: (cb: (err: Error | null) => void) => void): Promise { +function rawVoid(fn: (cb: (err: Error | null) => void) => void): Promise { return new Promise((resolve, reject) => { fn(err => (err ? reject(err) : resolve())) }) } +/** + * Reject `promise` once `ms` elapses. ssh2's SFTP callbacks are raw socket + * completions: a channel that is half-dead (peer gone, no FIN ever delivered, + * the case mobile / NAT'd links produce) accepts the request and then never + * calls back — the operation, and the UI spinner behind it, used to wait + * forever. The losing side of the race is left pending on purpose: it is only + * dropped, never cancelled, so a late reply cannot resurrect the operation. + */ +function bounded(promise: Promise, ms: number): Promise { + // The race may already have been decided by the time this one rejects; an + // unhandled rejection would take the whole process down. + promise.catch(() => undefined) + let timer: NodeJS.Timeout | undefined + const expiry = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new SftpTimeoutError(t('main.sftp.opTimeout', { seconds: Math.round(ms / 1000) }))) + }, ms) + }) + return Promise.race([promise, expiry]).finally(() => { + if (timer) clearTimeout(timer) + }) +} + type StatLike = { isDirectory(): boolean; size: number; mtime: number; mode: number; uid: number; gid: number } function lstat(sftp: SFTPWrapper, path: string): Promise { @@ -79,9 +148,6 @@ export function formatMode(mode: number): string { */ const sftpCache = new Map() -/** How long an SFTP subsystem open may take before the client counts as dead. */ -const SFTP_OPEN_TIMEOUT_MS = 10_000 - /** * Our own "session is gone" error. `isTransportError` classifies on the class, * not on the message text: the message is translated, the classifier must not @@ -117,7 +183,7 @@ async function sftpOf(sessionId: string): Promise { // back; bound the wait (like execQuiet does) so withSftp can evict and retry. const timer = setTimeout( () => reject(new SftpTimeoutError(t('main.sftp.openTimeout'))), - SFTP_OPEN_TIMEOUT_MS + timeouts.open ) try { client.sftp((err, sftp_) => { @@ -189,13 +255,16 @@ export function closeSftp(sessionId: string): void { const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm']) +/** readdir, both shapes ssh2 can yield (plain names or `{filename}` objects). */ +function readdir(sftp: SFTPWrapper, dir: string): Promise { + return p>(cb => sftp.readdir(dir, cb)).then(raw => + raw.map(n => (typeof n === 'string' ? n : n.filename)) + ) +} + export function listRemote(sessionId: string, dir: string): Promise { return withSftp(sessionId, async sftp => { - const raw = await new Promise>((resolve, reject) => { - sftp.readdir(dir, (err, names) => (err ? reject(err) : resolve(names))) - }) - // ssh2 readdir yields plain strings OR {filename} objects depending on version/options - const names = raw.map(n => (typeof n === 'string' ? n : n.filename)) + const names = await readdir(sftp, dir) const entries: SftpEntry[] = [] const queue = [...names] const base = dir.replace(/\/+$/, '') || '/' @@ -240,10 +309,7 @@ async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean): await pVoid(cb => sftp.unlink(path, cb)) return } - const raw = await new Promise>((resolve, reject) => { - sftp.readdir(path, (err, names) => (err ? reject(err) : resolve(names))) - }) - const names = raw.map(n => (typeof n === 'string' ? n : n.filename)) + const names = await readdir(sftp, path) const base = path.replace(/\/+$/, '') || '/' for (const name of names) { const child = `${base}/${name}` @@ -472,7 +538,7 @@ export function uploadRemote( lastEmit = now emit({ transferId: id, kind: 'upload', state: 'running', file: name, bytes: pos, totalBytes: size }) } - const pr = pVoid(cb => sftp.write(handle, buf, 0, bytesRead, offset, cb)) + const pr = pVoidTransfer(cb => sftp.write(handle, buf, 0, bytesRead, offset, cb)) inflight.add( pr.catch((err: Error) => { firstError = firstError ?? err @@ -485,7 +551,10 @@ export function uploadRemote( await localHandle.close() } await Promise.race([ - pVoid(cb => sftp.close(handle, cb)), + // The stall guard owns this wait (10s), so the close itself stays + // unbounded-by-`bounded` — otherwise a timeout landing after the + // race is decided would reject with nothing listening. + rawVoid(cb => sftp.close(handle, cb)), new Promise((r) => setTimeout(r, 10_000)) ]) await Promise.allSettled(inflight) @@ -547,7 +616,7 @@ export function downloadRemote( const buf = Buffer.alloc(CHUNK) for (;;) { if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled')) - const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb => + const { bytesRead } = await pTransfer<{ bytesRead: number; buffer: Buffer }>(cb => sftp.read(handle, buf, 0, CHUNK, pos, cb) ) if (bytesRead === 0) break diff --git a/src/main/updater.ts b/src/main/updater.ts index 2f9b9c0..a5abb26 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -23,11 +23,30 @@ const GITHUB_RELEASES_API = 'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10' const GITHUB_PROBE_URL = 'https://api.github.com/repos/billowliu2/OpenTerminal/releases/latest' -const GITHUB_PROBE_TIMEOUT_MS = 20_000 -/** Overall budget for ONE check attempt — see withTimeout. */ -const CHECK_TIMEOUT_MS = 30_000 -/** Changelog / releases-API fetches: a stalled response must not hang the About tab. */ -const FETCH_TIMEOUT_MS = 15_000 + +/** + * Time budgets, kept in one mutable object so the offline harness + * (tests/updater-fallback.mjs) can drive the timeout and fallback paths without + * waiting out the production values. Nothing in the app calls + * `setUpdateTimeouts`; the numbers below are the shipping ones. + * + * - `probe`: GitHub connectivity probe. Short enough that a proxy-less user + * falls back to Gitea instead of hanging on a dead proxy/DNS. + * - `check`: overall budget for ONE check attempt — see withTimeout, which + * exists because electron-updater's own socket idle timeout only fires on + * silence, so a slow trickling response can hold an attempt open forever. + * - `fetch`: changelog / releases-API fetches; a stalled response must not + * hang the About tab. + */ +const budgets = { + probe: 20_000, + check: 30_000, + fetch: 15_000 +} + +export function setUpdateTimeouts(patch: Partial): void { + Object.assign(budgets, patch) +} let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() } let activeFeed: 'gitea' | 'github' = 'gitea' @@ -113,7 +132,7 @@ async function checkWithFallback(): Promise { if (await probeGithub()) { useFeed('github') try { - await withTimeout(autoUpdater.checkForUpdates(), CHECK_TIMEOUT_MS) + await withTimeout(autoUpdater.checkForUpdates(), budgets.check) return } catch (err) { console.warn('[updater] github feed failed, falling back to gitea:', err) @@ -126,7 +145,7 @@ async function checkWithFallback(): Promise { try { // Bounded as well: a still-stuck GitHub check is handed back to us here, and // it must not leave the state at "checking" forever. - await withTimeout(autoUpdater.checkForUpdates(), CHECK_TIMEOUT_MS) + await withTimeout(autoUpdater.checkForUpdates(), budgets.check) } catch (err) { if (githubErr === undefined) throw err const giteaErr = err instanceof Error ? err.message : String(err) @@ -166,7 +185,7 @@ async function directFetch(url: string): Promise { // to the releases APIs instead of leaving the view spinning. return s.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + signal: AbortSignal.timeout(budgets.fetch) }) } @@ -181,7 +200,7 @@ async function probeGithub(): Promise { await s.setProxy({ mode: 'system' }) const resp = await s.fetch(GITHUB_PROBE_URL, { headers: { 'User-Agent': 'OpenTerminal' }, - signal: AbortSignal.timeout(GITHUB_PROBE_TIMEOUT_MS) + signal: AbortSignal.timeout(budgets.probe) }) return resp.ok } catch { @@ -215,7 +234,7 @@ async function fetchChangelog(): Promise { try { const resp = await net.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + signal: AbortSignal.timeout(budgets.fetch) }) if (!resp.ok) continue const data = (await resp.json()) as Array<{ diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index 27d359d..c626293 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react' import { Input, InputNumber, Radio, Select, Switch } from 'antd' import type { ThemeColors } from '@shared/theme' import { DEFAULT_LANGUAGE, LANGUAGES, t, type Language } from '@shared/i18n' +import { isReservedAccelerator } from '@shared/reservedAccelerators' import { useSettingsStore, useResolvedTheme } from './store' import { DEFAULT_FONT_STACK, @@ -482,28 +483,11 @@ function acceleratorFromEvent(e: React.KeyboardEvent): string } /** - * Keys this app binds while Control is held: font size (Ctrl+=/-/0, main.tsx) - * and tab cycling (Ctrl+PgUp/PgDn, Workspace). Neither handler looks at the - * other modifiers, so any Control combo on one of these keys would shadow the - * in-app action — the recorder refuses it instead of saving a shortcut that - * silently loses its original meaning. + * The reserved-accelerator table (in-app font/tab chords and the Ctrl+L panic + * lock) lives in @shared/reservedAccelerators so this recorder and the main + * process's registration guard (`applyGlobalShortcut`) cannot drift apart — + * they are the two halves of one rule. See that module for the rationale. */ -const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'PageUp', 'PageDown']) - -/** - * Whole chords the app owns outright, matched exactly (modifier set included). - * Ctrl+L is the panic lock, captured in main's before-input-event: a global - * registration intercepts the key at the OS level even while this window is - * focused, so binding it here would silently disable the lock shortcut. - */ -const RESERVED_EXACT_ACCELERATORS = new Set(['Control+L']) - -/** true when `accel` (e.g. "Control+Shift+=") collides with an in-app shortcut */ -function isReservedAccelerator(accel: string): boolean { - if (RESERVED_EXACT_ACCELERATORS.has(accel)) return true - const parts = accel.split('+') - return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1]) -} /** t() falls back to the key itself when a translation is missing. */ function tOr(key: string, fallback: string): string { diff --git a/src/shared/i18n/dicts/en/main.ts b/src/shared/i18n/dicts/en/main.ts index 7af12d6..52cca6a 100644 --- a/src/shared/i18n/dicts/en/main.ts +++ b/src/shared/i18n/dicts/en/main.ts @@ -35,6 +35,7 @@ const main: Record = { 'main.sftp.invalidUidGid': 'Invalid uid/gid', 'main.sftp.commandTimeout': 'Command timed out', 'main.sftp.openTimeout': 'Opening the SFTP channel timed out', + 'main.sftp.opTimeout': 'The SFTP operation stopped responding ({seconds}s); the connection is probably dead — reconnect and try again', 'main.sftp.commandExitCode': 'Command exited with code {code}', 'main.sftp.cancelled': 'Cancelled', 'main.sftp.localNotAllowed': 'Local path not authorised: {path}. Pick it again with the "choose file / choose directory" dialog.', diff --git a/src/shared/i18n/dicts/ja/main.ts b/src/shared/i18n/dicts/ja/main.ts index ddc5492..a7af9c1 100644 --- a/src/shared/i18n/dicts/ja/main.ts +++ b/src/shared/i18n/dicts/ja/main.ts @@ -35,6 +35,7 @@ const main: Record = { 'main.sftp.invalidUidGid': '不正な uid/gid', 'main.sftp.commandTimeout': 'コマンドがタイムアウトしました', 'main.sftp.openTimeout': 'SFTP チャネルのオープンがタイムアウトしました', + 'main.sftp.opTimeout': 'SFTP 操作が {seconds} 秒応答しません。接続が切断された可能性があります。再接続して再試行してください', 'main.sftp.commandExitCode': 'コマンドの終了コード {code}', 'main.sftp.cancelled': 'キャンセルしました', 'main.sftp.localNotAllowed': 'ローカルパスが許可されていません: {path}。「ファイルを選択 / ディレクトリを選択」ダイアログで選び直してください。', diff --git a/src/shared/i18n/dicts/zh-CN/main.ts b/src/shared/i18n/dicts/zh-CN/main.ts index 13b1660..fcb5185 100644 --- a/src/shared/i18n/dicts/zh-CN/main.ts +++ b/src/shared/i18n/dicts/zh-CN/main.ts @@ -35,6 +35,7 @@ const main: Record = { 'main.sftp.invalidUidGid': '非法 uid/gid', 'main.sftp.commandTimeout': '命令执行超时', 'main.sftp.openTimeout': 'SFTP 通道打开超时', + 'main.sftp.opTimeout': 'SFTP 操作无响应({seconds} 秒),连接可能已中断,请重新连接会话后重试', 'main.sftp.commandExitCode': '命令退出码 {code}', 'main.sftp.cancelled': '已取消', 'main.sftp.localNotAllowed': '本地路径未被授权: {path}。请通过「选择文件 / 选择目录」对话框重新选择。', diff --git a/src/shared/i18n/dicts/zh-TW/main.ts b/src/shared/i18n/dicts/zh-TW/main.ts index 6ee4352..a8f646a 100644 --- a/src/shared/i18n/dicts/zh-TW/main.ts +++ b/src/shared/i18n/dicts/zh-TW/main.ts @@ -35,6 +35,7 @@ const main: Record = { 'main.sftp.invalidUidGid': 'uid/gid 無效', 'main.sftp.commandTimeout': '命令執行逾時', 'main.sftp.openTimeout': 'SFTP 通道開啟逾時', + 'main.sftp.opTimeout': 'SFTP 操作無回應({seconds} 秒),連線可能已中斷,請重新連線後再試', 'main.sftp.commandExitCode': '指令結束碼 {code}', 'main.sftp.cancelled': '已取消', 'main.sftp.localNotAllowed': '本機路徑未獲授權: {path}。請改用「選擇檔案 / 選擇目錄」對話框重新選擇。', diff --git a/src/shared/reservedAccelerators.ts b/src/shared/reservedAccelerators.ts new file mode 100644 index 0000000..774b8a5 --- /dev/null +++ b/src/shared/reservedAccelerators.ts @@ -0,0 +1,61 @@ +/** + * Accelerators the app binds itself, shared by the two places that must agree + * on them: + * + * - the settings recorder (`SettingsTabs.tsx`) refuses to SAVE such a chord, so + * a global registration never shadows the in-app action; + * - `applyGlobalShortcut` (main) refuses to REGISTER one. The recorder only + * guards values entered after it existed — a shortcut persisted by an older + * build still arrives there, and a globalShortcut registration intercepts the + * key at the OS level even while the window is focused. + * + * Both sides used to keep their own table and only the renderer's was covered by + * a test; keeping the decision here (pure, no imports) makes the two guards + * provably identical and table-testable under plain Node. + */ + +/** + * Modifier aliases, folded to Electron's canonical spelling. `cmdorctrl`, + * `commandorctrl` and `commandorcontrol` all collapse to `control`: the only + * platform this app is packaged for is Windows, where CommandOrControl resolves + * to Control, and a legacy value saved with the portable spelling would + * otherwise slip past the reserved check into a real registration. + */ +function canonicalPart(part: string): string { + const p = part.trim().toLowerCase() + if (p === 'ctrl') return 'control' + if (p === 'cmdorctrl' || p === 'commandorctrl' || p === 'commandorcontrol') return 'control' + return p +} + +/** Split an accelerator into its canonical parts (`Ctrl+L` -> `['control','l']`). */ +export function acceleratorParts(accelerator: string): string[] { + return accelerator.split('+').map(canonicalPart) +} + +/** + * Whole chords the app owns outright, matched exactly (modifier set included). + * Ctrl+L is the panic lock, captured in main's before-input-event: a global + * registration intercepts the key at the OS level even while this window is + * focused, so binding it would silently disable the lock shortcut. + */ +const RESERVED_EXACT = new Set(['control+l']) + +/** + * Keys this app binds while Control is held: font size (Ctrl+=/-/0, main.tsx) + * and tab cycling (Ctrl+PgUp/PgDn, Workspace). Neither handler looks at the + * other modifiers, so any Control combo on one of these keys would shadow the + * in-app action — the recorder refuses it, and the main process must not + * register a legacy value that has the same effect. + */ +const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'pageup', 'pagedown']) + +/** + * True when `accelerator` collides with a shortcut the app already answers + * itself (e.g. `Control+Shift+=`, `Ctrl+L`). + */ +export function isReservedAccelerator(accelerator: string): boolean { + const parts = acceleratorParts(accelerator) + if (RESERVED_EXACT.has(parts.join('+'))) return true + return parts.includes('control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1]) +} diff --git a/tests/build-bundles.cjs b/tests/build-bundles.cjs index 48fbe26..bdb3193 100644 --- a/tests/build-bundles.cjs +++ b/tests/build-bundles.cjs @@ -13,9 +13,16 @@ const esbuild = require('esbuild') const ROOT = path.join(__dirname, '..') +/** Loader tweaks every bundle shares: `?asset` imports land on text loaders. */ +const LOADERS = { '.png': 'text' } + const BUNDLES = [ // Real session layer: pty.ts also re-exports the ssh + sysinfo engines. { entry: 'src/main/pty.ts', out: 'tests/.session-e2e.cjs', external: ['@lydell/node-pty', 'ssh2'] }, + // The real SSH service on its own (no electron surface at all), so the + // loopback harness can exercise the shipped connect/verify/shell code + // instead of a hand-copied ConnectConfig. + { entry: 'src/main/ssh.ts', out: 'tests/.ssh.cjs', external: ['ssh2'] }, // ESM (`.mjs`): tests/sftp-*.mjs load it with `await import()`. { entry: 'src/main/sftp.ts', out: 'tests/.sftp-svc.mjs', format: 'esm', external: ['ssh2'] }, { entry: 'src/main/commands.ts', out: 'tests/.commands-store.cjs' }, @@ -36,6 +43,25 @@ const BUNDLES = [ { entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.cjs' }, // Lock keyboard classifier: pure, so the bundle needs no electron surface. { entry: 'src/main/lockShortcuts.ts', out: 'tests/.lock-shortcuts.cjs' }, + // Reserved-accelerator table shared by the settings recorder and main's + // registration guard: pure, table-tested. + { entry: 'src/shared/reservedAccelerators.ts', out: 'tests/.reserved-accelerators.cjs' }, + // Update service: feed probe/fallback. `electron-updater` is aliased to a + // stub (the real package boots Electron), and the shell half (tray.ts) pulls + // a `?asset` import, hence LOADERS. + { entry: 'src/main/updater.ts', out: 'tests/.updater.cjs', alias: { 'electron-updater': './tests/electron-updater-stub.cjs' } }, + // IPC sender-frame guard: driven through the real registerIpc registration + // path (the stub records handlers instead of dropping them). + { + entry: 'src/main/ipc.ts', + out: 'tests/.ipc.cjs', + external: ['ssh2', '@lydell/node-pty', 'font-list', 'cpu-features'] + }, + // Session-log plain-text transformer: ANSI state machine + alt-screen folding. + { entry: 'src/main/logSanitizer.ts', out: 'tests/.log-sanitizer.cjs' }, + // Channel-name constants, so a test can name channels instead of inlining + // string literals that would silently drift from src/shared/ipc.ts. + { entry: 'src/shared/ipc.ts', out: 'tests/.ipc-channels.cjs' }, // zmodem.js stays bundled (NOT external) — the test drives a second in-process // Sentry from the same library. { entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] }, @@ -45,7 +71,7 @@ const BUNDLES = [ { entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' } ] -for (const { entry, out, format = 'cjs', external = [] } of BUNDLES) { +for (const { entry, out, format = 'cjs', external = [], alias = {} } of BUNDLES) { esbuild.buildSync({ absWorkingDir: ROOT, entryPoints: [path.join(ROOT, entry)], @@ -54,7 +80,8 @@ for (const { entry, out, format = 'cjs', external = [] } of BUNDLES) { platform: 'node', format, external, - alias: { electron: './tests/electron-stub.cjs', '@shared': './src/shared' }, + loader: LOADERS, + alias: { electron: './tests/electron-stub.cjs', '@shared': './src/shared', ...alias }, logLevel: 'warning' }) console.log(`built ${out}`) diff --git a/tests/electron-stub.cjs b/tests/electron-stub.cjs index fb10e42..bdbf83b 100644 --- a/tests/electron-stub.cjs +++ b/tests/electron-stub.cjs @@ -2,24 +2,76 @@ // (tests/ssh-session-e2e.mjs, tests/commands-store.mjs). Only what the bundled // modules touch. // -// `app.getPath('userData')` is configurable via `__setUserData` so a test can -// point the settings store at a temp dir and exercise settings-driven behavior. -let userDataPath = process.env.OT_STUB_USERDATA || '' +// Every mutable piece of state lives on `globalThis.__otElectronStub` rather +// than in this module's scope: the bundles INLINE this file (esbuild aliases +// `electron` to it), so a test requiring both `./electron-stub.cjs` and a +// bundle would otherwise get two independent copies, and registrations made by +// the bundled code would be invisible to the test. +// +// `app.getPath('userData')` defaults to `OT_STUB_USERDATA` and is also settable +// via `__setUserData`, so a test can point the settings store at a temp dir and +// exercise settings-driven behavior. +const state = (globalThis.__otElectronStub ??= { + handlers: new Map(), + userDataPath: process.env.OT_STUB_USERDATA || '', + isPackaged: false, + sessions: new Map() +}) + +/** Fetch double for the updater bundle: `net.fetch` and every session's fetch. */ +const noFetch = async () => ({ + ok: false, + status: 404, + text: async () => '', + json: async () => [] +}) +const callFetch = (url, init) => + globalThis.__otFetch ? globalThis.__otFetch(url, init) : noFetch() + +const fakeSession = (name) => { + let s = state.sessions.get(name) + if (!s) { + s = { + name, + proxyCalls: [], + setProxy: async (cfg) => { + s.proxyCalls.push(cfg) + }, + fetch: callFetch + } + state.sessions.set(name, s) + } + return s +} + module.exports = { BrowserWindow: { getAllWindows: () => [] }, app: { getPath: (name) => { - if (name === 'userData' && userDataPath) return userDataPath + if (name === 'userData' && state.userDataPath) return state.userDataPath throw new Error(`electron stub: app.getPath(${name}) is not configured`) }, + getVersion: () => '0.0.0-stub', setLoginItemSettings: () => {}, - isPackaged: false + get isPackaged() { + return state.isPackaged + } }, ipcMain: { - handle: () => {}, - on: () => {} + handle: (channel, listener) => { + state.handlers.set(channel, listener) + }, + on: (channel, listener) => { + state.handlers.set(`on:${channel}`, listener) + } + }, + session: { + fromPartition: (name) => fakeSession(name) + }, + net: { + fetch: callFetch }, globalShortcut: { register: () => true, @@ -39,6 +91,9 @@ module.exports = { shell: { openPath: async () => '' }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }) + }, safeStorage: { isEncryptionAvailable: () => false }, @@ -63,6 +118,16 @@ module.exports = { createFromPath: () => ({ isEmpty: () => true, resize: () => ({}) }) }, __setUserData: (p) => { - userDataPath = p + state.userDataPath = p + }, + __setPackaged: (v) => { + state.isPackaged = v + }, + /** channel -> listener (invoke), `on:` -> listener (send). */ + get __handlers() { + return state.handlers + }, + get __sessions() { + return state.sessions } } diff --git a/tests/electron-updater-stub.cjs b/tests/electron-updater-stub.cjs new file mode 100644 index 0000000..8b7f6d0 --- /dev/null +++ b/tests/electron-updater-stub.cjs @@ -0,0 +1,73 @@ +// Stand-in for the `electron-updater` package under plain Node (tests only). +// +// The real package loads Electron's app/browser-window machinery at require +// time, so it cannot be exercised in the offline harness. `tests/build-bundles.cjs` +// aliases `electron-updater` to this file, which means it is *INLINED* into the +// updater bundle — the bundle does not require this path at runtime, so the test +// process cannot reach the bundle's instance by requiring it either. Control +// therefore flows through a global set up by the test before it requires the +// bundle: +// +// globalThis.__otAutoUpdater = { +// checkForUpdates: () => Promise, // called by the service under test +// downloadUpdate: () => Promise, +// quitAndInstallCalls: [], // quitAndInstall(...) arguments +// feeds: [], // setFeedURL argument per call +// proxies: [], // netSession.setProxy argument per call +// live // the instance the bundle wired (see on()) +// } +// +// Every field is optional; missing hooks fall back to a resolving promise so a +// test only has to configure what it asserts on. +const { EventEmitter } = require('node:events') + +function ctl() { + return (globalThis.__otAutoUpdater ??= {}) +} + +class FakeAutoUpdater extends EventEmitter { + constructor() { + super() + this.logger = null + this.autoDownload = true + this.autoInstallOnAppQuit = false + this.netSession = { + setProxy: async (cfg) => { + ctl().proxies?.push(cfg) + } + } + } + + /** + * Whoever subscribes is the instance the app under test actually drives, so + * that one is published as `live`. This matters because the bundle INLINES + * this file: the test process holds two instances (its own require of this + * path, plus the bundle's copy), and emitting on the wrong one is a no-op. + */ + on(event, listener) { + ctl().live = this + return super.on(event, listener) + } + + setFeedURL(cfg) { + ctl().feeds?.push(cfg) + } + + checkForUpdates() { + const impl = ctl().checkForUpdates + return impl ? impl(ctl()) : Promise.resolve(null) + } + + downloadUpdate() { + const impl = ctl().downloadUpdate + return impl ? impl(ctl()) : Promise.resolve([]) + } + + quitAndInstall(...args) { + ctl().quitAndInstallCalls?.push(args) + } +} + +const autoUpdater = new FakeAutoUpdater() + +module.exports = { autoUpdater } diff --git a/tests/ipc-guard.mjs b/tests/ipc-guard.mjs new file mode 100644 index 0000000..b9fb76f --- /dev/null +++ b/tests/ipc-guard.mjs @@ -0,0 +1,226 @@ +/** + * IPC sender-frame guard self-test (ipc-guard.mjs). + * + * `installSenderGuard` (src/main/ipc.ts) is the single choke point every IPC + * channel in this app is registered through — four modules register handlers, + * so the check lives where they all pass rather than at each site. It is what + * keeps a frame that navigated away (or an injected one) from driving the main + * process through the preload bridge, which is the app's whole API + * (`createPty` included). What is pinned here: + * + * - `isTrustedRendererUrl`: in a packaged build only the bundled renderer + * file's URL is trusted; in dev only the vite dev server's ORIGIN (any path + * on it, no other port / host). Malformed input is never trusted. + * - through the REAL registration path (`registerIpc` -> the stub's ipcMain), + * a trusted invoke resolves, an untrusted one rejects with the refused + * error instead of reaching the handler, and a missing `senderFrame` + * (Electron gives `null` for a destroyed frame) is refused too. + * - untrusted `send`-style channels are dropped without calling the listener. + * - the guard is installed once, not stacked per registration. + * + * Build: node tests/build-bundles.cjs + * Run: node tests/ipc-guard.mjs (must exit 0) + */ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const userData = mkdtempSync(join(tmpdir(), 'ot-ipc-')) +process.env.OT_STUB_USERDATA = userData + +const require = createRequire(import.meta.url) +const stub = require('./electron-stub.cjs') +const { registerIpc, isTrustedRendererUrl } = require('./.ipc.cjs') +const { Ipc } = require('./.ipc-channels.cjs') + +let failed = 0 +let passed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 + else passed += 1 +} + +const DEV_URL = 'http://localhost:5173' +const withDevUrl = (value, fn) => { + const prev = process.env.ELECTRON_RENDERER_URL + if (value === undefined) delete process.env.ELECTRON_RENDERER_URL + else process.env.ELECTRON_RENDERER_URL = value + try { + return fn() + } finally { + if (prev === undefined) delete process.env.ELECTRON_RENDERER_URL + else process.env.ELECTRON_RENDERER_URL = prev + } +} + +// The URL a packaged build loads: the renderer file next to the main bundle. +// The test's bundle sits in tests/, the app's in out/main/, so this is the +// `../renderer/index.html` sibling either way. +const PACKAGED_URL = pathToFileURL(join(__dirname, '../renderer/index.html')).href + +// ---- 1. the trust predicate ------------------------------------------------- +console.log('trusted renderer URL (packaged build: the renderer file and nothing else)') +withDevUrl(undefined, () => { + ok(isTrustedRendererUrl(PACKAGED_URL), 'the bundled renderer file is trusted') + ok(!isTrustedRendererUrl(pathToFileURL(join(__dirname, '../renderer/other.html')).href), 'a sibling file in the renderer dir is not') + ok(!isTrustedRendererUrl(pathToFileURL(join(__dirname, '../package.json')).href), 'another local file is not') + ok(!isTrustedRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts'), 'an unrelated file: URL is not') + ok(!isTrustedRendererUrl('https://evil.example/index.html'), 'a remote origin is not') + ok(!isTrustedRendererUrl('http://localhost:5173/'), 'the dev server is NOT trusted in a packaged build (env ignored)') +}) + +console.log('trusted renderer URL (dev build: the dev server origin, any path)') +withDevUrl(DEV_URL, () => { + ok(isTrustedRendererUrl(`${DEV_URL}/index.html`), 'a path on the dev origin is trusted') + ok(isTrustedRendererUrl(`${DEV_URL}/`), 'the dev origin root is trusted') + ok(isTrustedRendererUrl(`${DEV_URL}/deep/nested/route?x=1#y`), 'any path/query/hash on that origin is trusted') + ok(!isTrustedRendererUrl('http://localhost:5174/index.html'), 'a different port is a different origin') + ok(!isTrustedRendererUrl('http://127.0.0.1:5173/index.html'), 'a different host spelling is a different origin') + ok(!isTrustedRendererUrl('https://localhost:5173/index.html'), 'a different scheme is a different origin') + ok(!isTrustedRendererUrl('https://evil.example/http://localhost:5173/'), 'a hostile URL embedding the dev URL is not the dev origin') + ok(!isTrustedRendererUrl(PACKAGED_URL), 'the packaged file URL is not the dev origin') +}) + +console.log('the predicate refuses everything malformed') +withDevUrl(DEV_URL, () => { + for (const raw of ['', 'not a url', '://', 'about:blank', 'javascript:alert(1)', 'data:text/html,x', 'file://']) { + ok(!isTrustedRendererUrl(raw), `refused: ${JSON.stringify(raw)}`) + } +}) + +// ---- 2. the guard, through the real registration path ----------------------- +console.log('the guard on a registered channel') +registerIpc() + +const handlers = stub.__handlers +const trustedFrame = { url: `${DEV_URL}/index.html` } +const hostileFrame = { url: 'https://evil.example/hijack.html' } +const invoke = (channel, frame, ...args) => { + const listener = handlers.get(channel) + if (!listener) throw new Error(`no handler registered for ${channel}`) + return listener({ senderFrame: frame, sender: { id: 1 } }, ...args) +} + +withDevUrl(DEV_URL, () => { + ok(typeof handlers.get(Ipc.APP_INFO) === 'function', 'APP_INFO reached the registration path') + ok(handlers.get(Ipc.APP_INFO) !== undefined, 'the stub recorded a handler (registrations are not dropped)') + + // The trusted path really executes the handler: it returns the app info + // object instead of rejecting. + const info = invoke(Ipc.APP_INFO, trustedFrame) + ok( + info && typeof info === 'object' && typeof info.platform === 'string' && info.appVersion === '0.0.0-stub', + `a trusted frame reaches the handler (got ${JSON.stringify(info)})` + ) + + // Path validation inside a handler still applies to a trusted frame: this + // handler refuses non-strings, so a compromised-but-trusted renderer cannot + // open an arbitrary path. + ok(invoke(Ipc.CWD_OPEN, trustedFrame, 'not-a-path') === false, 'handler-level validation still runs for a trusted frame') + ok(invoke(Ipc.CWD_OPEN, trustedFrame, undefined) === false, 'CWD_OPEN refuses a missing path') + ok(invoke(Ipc.CWD_OPEN, trustedFrame, 42) === false, 'CWD_OPEN refuses a non-string path') + + const refused = (channel, ...args) => { + try { + invoke(channel, hostileFrame, ...args) + return null + } catch (err) { + return err instanceof Error ? err.message : String(err) + } + } + + let msg = refused(Ipc.APP_INFO) + ok(typeof msg === 'string' && msg.includes('untrusted frame'), `an untrusted invoke is refused (${msg})`) + ok(typeof msg === 'string' && msg.includes(Ipc.APP_INFO), 'the refusal names the channel (so it is diagnosable)') + + // A hostile frame gets the same refusal on every other invoke channel, and the + // handler is never reached: CWD_OPEN would have thrown/misbehaved, PTY_CREATE + // would have spawned a shell. + for (const channel of [Ipc.CWD_OPEN, Ipc.PTY_CREATE, Ipc.CONNECTIONS_LIST, Ipc.SETTINGS_GET, Ipc.LOCK_STATE_GET]) { + if (!handlers.has(channel)) continue + const m = refused(channel) + ok(typeof m === 'string' && m.includes('untrusted frame'), `refused on ${channel}`) + } + + const missingFrame = (() => { + try { + handlers.get(Ipc.APP_INFO)({ sender: { id: 1 } }, ) + return null + } catch (err) { + return err instanceof Error ? err.message : String(err) + } + })() + ok( + typeof missingFrame === 'string' && missingFrame.includes('untrusted frame'), + 'a missing senderFrame (destroyed frame -> null) is refused' + ) + + // ---- 3. send-style channels are dropped, not rejected --------------------- + // LOG_OPEN_DIR (ipcMain.on) has an observable side effect: it creates the + // logs directory. That makes "the listener really did/did not run" + // checkable, instead of asserting on the absence of a throw. + const send = (frame, ...args) => { + const listener = handlers.get(`on:${Ipc.LOG_OPEN_DIR}`) + if (!listener) throw new Error('LOG_OPEN_DIR was not registered through ipcMain.on') + listener({ senderFrame: frame, sender: { id: 1 } }, ...args) + } + const logsDir = join(userData, 'logs') + + ok(typeof handlers.get(`on:${Ipc.LOG_OPEN_DIR}`) === 'function', 'LOG_OPEN_DIR is registered through ipcMain.on (and therefore guarded)') + + send(hostileFrame) + ok(!existsSync(logsDir), 'an untrusted send is dropped silently — the listener never ran') + + let threw = false + try { + send({ url: 'not a url' }) + send(undefined) + } catch { + threw = true + } + ok(!threw && !existsSync(logsDir), 'send on a malformed / missing frame is dropped, not thrown') + + send(trustedFrame) + ok(existsSync(logsDir), 'a trusted send reaches the listener (the logs dir was created)') +}) + +// ---- 4. installed once ------------------------------------------------------ +// `installSenderGuard` swaps `ipcMain.handle` / `ipcMain.on` for guarded +// wrappers. If it did not short-circuit on the second call, each registration +// would be wrapped again and the guard would nest — cheap here, but it also +// means a handler added after the first registerIpc could be guarded twice +// while one added before is guarded once, and the two spellings of the same +// check would drift. The wrapper identity is the observable proof. +console.log('the guard is installed once, not stacked') +withDevUrl(DEV_URL, () => { + const handleRef = stub.ipcMain.handle + const onRef = stub.ipcMain.on + registerIpc() + ok(stub.ipcMain.handle === handleRef, 'a second registerIpc does not re-wrap ipcMain.handle') + ok(stub.ipcMain.on === onRef, 'a second registerIpc does not re-wrap ipcMain.on') + + const m = (() => { + try { + handlers.get(Ipc.APP_INFO)({ senderFrame: hostileFrame, sender: { id: 1 } }) + return null + } catch (err) { + return err instanceof Error ? err.message : String(err) + } + })() + ok(typeof m === 'string' && m.includes('untrusted frame'), 'and an untrusted invoke is still refused after re-registering') + + // The duplicated refusals must not multiply: one layer, one message. + ok((m.match(/untrusted frame/g) ?? []).length === 1, 'the refusal message is not nested (exactly one guard layer)') +}) + +rmSync(userData, { recursive: true, force: true }) + +if (failed > 0) { + console.error(`\n[ipc-guard] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log(`\n[ipc-guard] ALL CHECKS PASSED (${passed} assertions)`) diff --git a/tests/log-sanitizer.mjs b/tests/log-sanitizer.mjs new file mode 100644 index 0000000..354e141 --- /dev/null +++ b/tests/log-sanitizer.mjs @@ -0,0 +1,277 @@ +/** + * Session-log sanitizer self-test (log-sanitizer.mjs). + * + * src/main/logSanitizer.ts turns raw PTY output into a readable text log + * (commands.ts drives it in logWrite/logStop, one instance per logged + * session). It is a hand-written state machine over bytes, which is exactly + * the shape that fails at chunk boundaries — the PTY hands out arbitrary + * splits, and an escape sequence is regularly cut in half between two chunks. + * What is pinned here: + * + * - every escape family the terminal emits is consumed, not printed: CSI + * (SGR), OSC terminated by BEL *and* by ST, single-character escapes, + * the two-byte charset designators, and an OSC interrupted by a new ESC + * - state survives a chunk boundary at every position of the nasty sample + * (byte-identical to the single-chunk result), including 1 byte per push + * - `\r` collapses an overwritten line (progress bars) while `\r\n` stays a + * line ending; a trailing `\r` at flush is an ending too + * - alt-screen output is dropped and reported by exactly one marker per + * suppressed span, including when the log stops mid-TUI + * - a runaway CSI resyncs as text past the 1024-byte cap instead of + * swallowing the rest of the session + * - DEL / other C0 controls are dropped, tab is preserved + * + * Build: node tests/build-bundles.cjs + * Run: node tests/log-sanitizer.mjs (must exit 0) + */ +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { LogSanitizer } = require('./.log-sanitizer.cjs') + +let failed = 0 +let passed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 + else passed += 1 +} + +/** Fresh sanitizer + "push these chunks, then flush" through one call. */ +const run = (...chunks) => { + const s = new LogSanitizer() + let out = '' + for (const c of chunks) out += s.push(c) + return { out, out2: out + s.flush() } +} + +/** Push, then flush, and require the result. */ +const feed = (...chunks) => run(...chunks).out2 +/** Push only — used when the assertion is about what has NOT been emitted yet. */ +const pushed = (...chunks) => run(...chunks).out + +const MARKER_RE = /\n──── \[[^\]]+\] ────\n/g +const markerCount = (s) => (s.match(MARKER_RE) ?? []).length +const marker = (() => { + const { out } = run('\x1b[?1049h', 'x', '\x1b[?1049l') + return out +})() + +// ---- 1. plain text and line endings ---------------------------------------- +console.log('plain text') +ok(feed('hello\nworld') === 'hello\nworld', 'an unterminated tail is flushed at the end') +ok(feed('hello\n') === 'hello\n', 'a terminated line comes out as-is') +ok(pushed('hello\nworld') === 'hello\n', 'nothing is emitted for the pending line until flush') +ok(feed('\n') === '\n', 'an empty line is preserved') +ok(feed('a\nb\nc') === 'a\nb\nc', 'multiple lines') +ok(feed('') === '', 'no input, no output') +ok(markerCount(marker) === 1, `the alt-screen marker has the expected shape (${JSON.stringify(marker)})`) + +console.log('\\r vs \\r\\n') +ok(feed('progress 10%\rprogress 100%\n') === 'progress 100%\n', '\\r collapses an overwritten progress line') +ok(feed('a\rb\rc\n') === 'c\n', 'chained \\r overwrites keep only the last write') +ok(feed('line\r\n') === 'line\n', '\\r\\n is a line ending, not an overwrite') +ok(feed('one\r\ntwo\r\n') === 'one\ntwo\n', 'several \\r\\n lines') +ok(feed('partial\r') === 'partial\n', 'a trailing \\r at flush is treated as a line ending') +ok(feed('a\r\rb\n') === 'b\n', 'a doubled \\r still collapses') +ok( + feed('prog 1\r', 'prog 2\n') === 'prog 2\n', + 'a \\r at the end of one chunk still overwrites with the next chunk' +) +ok(feed('prog 1\r', '\n') === 'prog 1\n', 'a \\r at the end of a chunk followed by \\n is an ending') + +// ---- 2. escape sequences are consumed -------------------------------------- +console.log('CSI / SGR') +ok(feed('\x1b[31mred\x1b[0m\n') === 'red\n', 'SGR color codes vanish') +ok(feed('\x1b[38;2;1;2;3mtruecolor\x1b[0m\n') === 'truecolor\n', 'truecolor SGR vanishes') +ok(feed('\x1b[1;2Hpositioned\n') === 'positioned\n', 'cursor positioning vanishes') +ok(feed('\x1b[2J\x1b[Hcleared\n') === 'cleared\n', 'screen clear / home vanish') +ok(feed('\x1b[?25lcursor\n') === 'cursor\n', 'a DEC private mode with a trailing l vanishes') + +console.log('OSC (title / hyperlink), both terminators') +ok(feed('\x1b]0;title\x07after\n') === 'after\n', 'OSC terminated by BEL is consumed') +ok(feed('\x1b]0;title\x1b\\after\n') === 'after\n', 'OSC terminated by ST (ESC \\) is consumed') +ok(feed('\x1b]8;;https://example.com\x07link\x1b]8;;\x07\n') === 'link\n', 'OSC 8 hyperlink open+close') +ok( + feed('\x1b]0;t\x1b[31mx\n') === 'x\n', + 'an OSC interrupted by a new ESC [ resumes as CSI instead of eating the sequence' +) +ok(feed('\x1b]0;t\x1b]0;u\x07ok\n') === 'ok\n', 'an OSC interrupted by a new ESC ] continues as OSC') +ok(feed('\x1b]0;t\x1bZrest\n') === 'rest\n', 'ESC + an unrelated byte ends the OSC (byte consumed)') + +console.log('single-character escapes and charset designators') +ok(feed('\x1b7saved\x1b8restored\n') === 'savedrestored\n', 'DECSC/DECRC (ESC 7 / ESC 8) vanish') +ok(feed('\x1b=app\x1b>norm\n') === 'appnorm\n', 'ESC = / ESC > vanish') +ok(feed('\x1b(Bplain\n') === 'plain\n', 'the charset designator ESC ( B is consumed whole') +ok(feed('\x1b)0line\n') === 'line\n', 'ESC ) 0 is consumed whole') +ok(feed('\x1b#8screen\n') === 'screen\n', 'the DECALN designator ESC # 8 is consumed whole') +ok(feed('\x1b%Gutf8\n') === 'utf8\n', 'the encoding designator ESC % G is consumed whole') + +console.log('control characters') +ok(feed('a\tb\n') === 'a\tb\n', 'tab is preserved') +ok(feed('a\x07b\x00c\x1fd\n') === 'abcd\n', 'BEL / NUL / other C0 controls are dropped') +// DEL (0x7F) is not a C0 control: it passes the `c >= ' '` test and is kept. +// The comment in logSanitizer.ts used to claim otherwise; this pins the real +// behaviour so the two cannot drift again. +ok(feed('a\x7fb\n') === 'a\x7fb\n', 'DEL is kept as an ordinary character (it is not a C0 control)') + +// ---- 3. chunk boundaries ---------------------------------------------------- +console.log('state survives chunk boundaries') +ok(feed('\x1b', '[31mred\n') === 'red\n', 'ESC at the end of a chunk') +ok(feed('\x1b[3', '1mred\n') === 'red\n', 'CSI split mid-parameter') +ok(feed('\x1b]', '0;title\x07ok\n') === 'ok\n', 'OSC introducer split') +ok(feed('\x1b]0;title\x1b', '\\after\n') === 'after\n', 'ESC of the ST terminator split from its backslash') +ok(feed('\x1b(', 'Bplain\n') === 'plain\n', 'charset designator split') +ok(feed('\x1b[?1049h', 'a', '\x1b[?1049l', 'b\n') === marker + 'b\n', 'alt-screen toggles split across chunks') + +// A nasty sample exercising every family, cut at every single position: the +// concatenated result must be byte-identical to the single-chunk result. +const nasty = + '\x1b]0;kimi — session\x07' + + '\x1b[38;2;79;168;255m╭──╮\x1b[0m\r\n' + + 'Welcome \x1b[1mbold\x1b[0m\r\n' + + '\x1b7' + + '\x1b[?25lhidden\x1b[?25h' + + '\x1b(Bascii\x1b)0gfx' + + '\x1b#8' + + '\x1b%Gutf8' + + '\x1b[mreset\r' + + 'overwritten\x1b[K\r\n' + + '\x1b[?1049hTUI frame A\r\nTUI frame B\x1b[?1049l' + + '\x1b]8;;https://x.example\x1b\\link\x1b]8;;\x1b\\' + + 'done \u2713\r\n' +const reference = feed(nasty) + +{ + let mismatches = 0 + for (let i = 1; i < nasty.length - 1; i++) { + const got = feed(nasty.slice(0, i), nasty.slice(i)) + if (got !== reference) { + mismatches++ + if (mismatches <= 3) { + console.log(` split ${i}: got ${JSON.stringify(got.slice(0, 90))}`) + console.log(` want ${JSON.stringify(reference.slice(0, 90))}`) + } + } + } + ok(mismatches === 0, `every single split point is byte-identical to the whole-chunk result (${mismatches} mismatches)`) +} + +{ + let got = '' + const s = new LogSanitizer() + for (const ch of nasty) got += s.push(ch) + got += s.flush() + ok(got === reference, 'a 1-byte-per-push feed is byte-identical') +} + +{ + const third = Math.floor(nasty.length / 3) + ok( + feed(nasty.slice(0, third), nasty.slice(third, third * 2), nasty.slice(third * 2)) === reference, + 'a three-way split is byte-identical' + ) +} + +{ + // A random 4-way split (fixed seed via a simple LCG so a failure reproduces). + let seed = 12345 + const rand = (n) => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff + return seed % n + } + let allEqual = true + for (let trial = 0; trial < 50; trial++) { + const cuts = [1 + rand(nasty.length - 1), 1 + rand(nasty.length - 1), 1 + rand(nasty.length - 1)].sort((a, b) => a - b) + const chunks = [nasty.slice(0, cuts[0]), nasty.slice(cuts[0], cuts[1]), nasty.slice(cuts[1], cuts[2]), nasty.slice(cuts[2])] + if (feed(...chunks) !== reference) allEqual = false + } + ok(allEqual, '50 pseudo-random 4-way splits are all byte-identical') +} + +// ---- 4. alt screen --------------------------------------------------------- +console.log('alt screen (TUI frames) is dropped, one marker per span') +ok(feed('\x1b[?1049hframe\r\nframe\x1b[?1049l') === marker, 'a TUI span yields exactly the marker') +ok(markerCount(feed('\x1b[?1049hframe\x1b[?1049l')) === 1, 'one span, one marker') +ok( + markerCount(feed('\x1b[?1049ha\x1b[?1049lb\x1b[?1049hc\x1b[?1049l')) === 2, + 'two spans, two markers' +) +ok(feed('\x1b[?1049h\x1b[?1049l') === '', 'an alt-screen toggle with no output emits nothing') +ok(pushed('\x1b[?1049hframe') === '', 'TUI output is suppressed while the alt screen is active') +ok(feed('before\n\x1b[?1049hframe\x1b[?1049lafter\n') === 'before\n' + marker + 'after\n', 'text around a TUI span survives') + +console.log('alt-screen toggles recognised: 1049 / 1047 / 47') +for (const n of ['1049', '1047', '47']) { + ok( + feed(`\x1b[?${n}htui\x1b[?${n}l`) === marker, + `${n}h/${n}l is an alt-screen toggle` + ) +} +ok(feed('\x1b[?1048hnotalt\n') === 'notalt\n', '1048 (save cursor) is NOT an alt-screen toggle') +ok(feed('\x1b[?25hnotalt\n') === 'notalt\n', 'a private mode with no toggle is not an alt-screen toggle') +ok( + feed('\x1b[?1049hbody\x1b[?1049l\n') === marker + '\n', + 'once the alt screen closes, later output is normal again' +) + +console.log('stopping the log mid-TUI still reports the suppressed span') +ok(feed('normal\n\x1b[?1049hframe') === 'normal\n' + marker, 'flush inside the alt screen emits the marker') +ok(feed('normal\n\x1b[?1049h') === 'normal\n', 'flush inside an empty alt screen emits nothing') +{ + // Two spans, the second still open at flush: one marker when it closed, one + // for the span the flush interrupted. + const out = feed('\x1b[?1049ha\x1b[?1049l\x1b[?1049hb') + ok(markerCount(out) === 2, `an open span at flush gets its own marker (${markerCount(out)})`) +} + +console.log('a TUI span does not consume the pending normal-buffer line') +{ + // Documented boundary: text committed to the normal buffer before the TUI + // opened is still the pending line and is emitted after the span closes. + const out = feed('abc\x1b[?1049hdef\x1b[?1049l\n') + ok(out === marker + 'abc\n', `pending normal-buffer line survives a TUI span (${JSON.stringify(out)})`) +} + +// ---- 5. runaway sequence cap ------------------------------------------------ +console.log('a runaway CSI resyncs instead of swallowing the session') +{ + // The cap counts the bytes held in `seq` (1024). The byte that trips the cap + // is consumed by the resync itself, so the first 1025 parameter bytes are + // swallowed and everything after them spills as plain text. + const params = '1'.repeat(1100) + const out = feed('\x1b[' + params, 'text\n') + ok(out.endsWith('text\n'), 'output after the runaway sequence is still logged') + ok(out === params.slice(1025) + 'text\n', `exactly the bytes past the 1024 cap become text (${out.length} bytes)`) + ok(!out.includes('\x1b'), 'the introducer itself is not leaked into the log') +} +{ + // A runaway OSC has no cap (it is terminated by BEL/ST only), so it must stay + // swallowed rather than leak the sequence body into the log. + const out = feed('\x1b]0;' + 'x'.repeat(5000) + '\x07after\n') + ok(out === 'after\n', 'a long but terminated OSC is fully swallowed') +} + +// ---- 6. flush is idempotent ------------------------------------------------- +console.log('flush') +{ + const s = new LogSanitizer() + s.push('pending') + ok(s.flush() === 'pending', 'the first flush emits the pending line') + ok(s.flush() === '', 'a second flush emits nothing') + ok(s.push('more').length === 0, 'the sanitizer keeps working after a flush') + ok(s.flush() === 'more', 'and flushes the new pending line') +} +{ + const s = new LogSanitizer() + s.push('\x1b[?1049hframe') + const first = s.flush() + ok(markerCount(first) === 1, 'flush reports the open TUI span once') + ok(s.flush() === '', 'the marker is not repeated by a second flush') +} + +if (failed > 0) { + console.error(`\n[log-sanitizer] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log(`\n[log-sanitizer] ALL CHECKS PASSED (${passed} assertions)`) diff --git a/tests/reserved-accelerators.mjs b/tests/reserved-accelerators.mjs new file mode 100644 index 0000000..8ec1fb8 --- /dev/null +++ b/tests/reserved-accelerators.mjs @@ -0,0 +1,119 @@ +/** + * Reserved-accelerator self-test (reserved-accelerators.mjs). + * + * src/shared/reservedAccelerators.ts is the single table two guards read: + * - the settings recorder refuses to SAVE such a chord (SettingsTabs.tsx); + * - `applyGlobalShortcut` refuses to REGISTER one (main/globalShortcuts.ts). + * They used to be two independent tables with two spellings of the same rule, + * and only the renderer's had a test. What is pinned here: + * - the in-app chords: Ctrl+=/-/0 (font size) and Ctrl+PgUp/PgDn (tab cycle), + * under ANY extra modifiers — the in-app handlers ignore Shift/Alt, so a + * global registration of Ctrl+Shift+= would shadow them; + * - Ctrl+L, the panic lock, in every spelling a user or an older build can + * produce ('ctrl+l', 'Ctrl+L', 'Control+L', 'CommandOrControl+L'); + * - everything else stays registrable, so the guard cannot grow into a + * blanket refusal. + * + * Build: node tests/build-bundles.cjs + * Run: node tests/reserved-accelerators.mjs (must exit 0) + */ +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { isReservedAccelerator, acceleratorParts } = require('./.reserved-accelerators.cjs') + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 +} + +const reserved = [ + // --- the panic lock, every spelling --------------------------------------- + 'Ctrl+L', + 'ctrl+l', + 'CTRL+L', + 'Control+L', + 'control+l', + 'CommandOrControl+L', + 'CmdOrCtrl+L', + 'CommandOrCtrl+L', + 'Ctrl + L', // Electron tolerates surrounding whitespace + // --- font size (Ctrl = / - / 0) under extra modifiers --------------------- + 'Control+=', + 'Control+-', + 'Control+0', + 'Control+Shift+=', + 'Control+Shift+-', + 'Control+Shift+0', + 'Control+Alt+=', + 'Control+Alt+Shift+0', + 'ctrl+0', + // --- tab cycling (Ctrl+PgUp/PgDn) ---------------------------------------- + 'Control+PageUp', + 'Control+PageDown', + 'Control+Shift+PageUp', + 'Control+Alt+PageDown', + 'ctrl+pageup' +] + +const allowed = [ + // Not the panic chord: extra/missing modifiers change the meaning on purpose. + 'Alt+L', + 'Shift+L', + 'Super+L', + 'Control+Shift+L', + 'Control+Alt+L', + 'Control+Meta+L', + // The font/tab keys WITHOUT Control belong to whatever is focused. + '=', + '-', + '0', + 'Shift+=', + 'Alt+=', + 'PageUp', + 'PageDown', + 'Shift+PageUp', + // Other Control chords are free (they do not collide with an in-app binding). + 'Control+R', + 'Control+Shift+I', + 'Control+1', + 'Control+PageHome', + 'Control+F5', + 'Alt+Control+P', + // Standalone function keys. + 'F5', + 'F12', + 'Control+F12' +] + +console.log('reserved (must be refused by both guards)') +for (const a of reserved) ok(isReservedAccelerator(a), `reserved: ${JSON.stringify(a)}`) + +console.log('allowed (must stay registrable)') +for (const a of allowed) ok(!isReservedAccelerator(a), `allowed: ${JSON.stringify(a)}`) + +console.log('modifier aliases fold to one canonical form') +ok(acceleratorParts('Ctrl+L').join('+') === 'control+l', "'Ctrl' folds to 'control'") +ok(acceleratorParts('CommandOrControl+L').join('+') === 'control+l', "'CommandOrControl' folds to 'control' (Windows-only app)") +ok(acceleratorParts('CmdOrCtrl+L').join('+') === 'control+l', "'CmdOrCtrl' folds to 'control'") +ok(acceleratorParts('Ctrl+Shift+P').join('+') === 'control+shift+p', 'Shift is folded to lower case and kept') +ok(acceleratorParts('Super+L')[0] === 'super', 'Super is preserved (not an alias of Control)') + +console.log('degenerate input does not throw and grants nothing') +for (const a of ['', ' ', '+', 'Control+', '+L', 'nonsense']) { + let threw = false + let verdict + try { + verdict = isReservedAccelerator(a) + } catch { + threw = true + } + ok(!threw && verdict === false, `junk input refused without throwing: ${JSON.stringify(a)}`) +} + +if (failed > 0) { + console.error(`\n[reserved-accelerators] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log(`\n[reserved-accelerators] ALL CHECKS PASSED (${reserved.length + allowed.length + 4 + 6} assertions)`) diff --git a/tests/sftp-timeout.mjs b/tests/sftp-timeout.mjs new file mode 100644 index 0000000..5b66d9f --- /dev/null +++ b/tests/sftp-timeout.mjs @@ -0,0 +1,456 @@ +/** + * SFTP timeout self-test (sftp-timeout.mjs). + * + * A half-dead SFTP channel — the peer is gone but no FIN was ever delivered, + * which mobile / NAT'd links produce constantly — accepts a request and then + * never calls back. ssh2 has no socket timeout of its own, so before this the + * operation (and the spinner behind it) waited forever. `src/main/sftp.ts` now + * bounds every operation, with two budgets because one number cannot serve + * both: metadata round trips are milliseconds on a working link, while a 256KB + * transfer chunk is legitimately seconds on a slow one. + * + * The test drives the real `withSftp` path with a fake ssh2 SFTP wrapper, so the + * assertions are about the service's behavior: which budget applies, that a + * timeout is transport-classified (channel evicted + ONE retry on a fresh + * channel), that a late reply cannot resurrect an abandoned operation, and that + * a slow-but-progressing transfer is never mistaken for a dead one. + * + * Build: node tests/build-bundles.cjs + * Run: node tests/sftp-timeout.mjs (must exit 0) + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const sftp = await import('./.sftp-svc.mjs') + +let failed = 0 +let passed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 + else passed += 1 +} + +// ---- harness ---------------------------------------------------------------- +sftp.setSftpTimeouts({ op: 60, transfer: 200, open: 60 }) +// Local-path admission is exercised by its own test; this harness picks its own +// temp paths and has no dialog to grant from. +const root = mkdtempSync(join(tmpdir(), 'ot-sftp-timeout-')) +sftp.setLocalPathPolicy({ + readSource: (p) => (typeof p === 'string' && p !== '' ? p : null), + readDirectory: (d) => (typeof d === 'string' && d !== '' ? d : null), + writeTarget: (dir, name) => + typeof dir === 'string' && dir !== '' && typeof name === 'string' && name !== '' + ? join(dir, name) + : null +}) + +/** + * A fake ssh2 SFTPWrapper whose per-call behavior is scripted. + * `behaviour(op, args)` returns `'silent'` (never calls back — the dead-channel + * case), `'error'` (calls back with an error), or `'ok'` (calls back with + * `result`). Every call is recorded so the test can assert on what was opened. + */ +const defaultStat = () => ({ + isDirectory: () => false, + size: 10, + mtime: 1_700_000_000, + mode: 0o100644, + uid: 1000, + gid: 1000 +}) + +let calls +let behaviour +let openedChannels +const makeWrapper = () => { + const wrapper = { + lstat: (p, cb) => dispatch('lstat', [p], cb, defaultStat()), + readdir: (p, cb) => dispatch('readdir', [p], cb, ['a.txt', 'b.txt']), + mkdir: (p, cb) => dispatch('mkdir', [p], cb, undefined, true), + rename: (a, b, cb) => dispatch('rename', [a, b], cb, undefined, true), + unlink: (p, cb) => dispatch('unlink', [p], cb, undefined, true), + rmdir: (p, cb) => dispatch('rmdir', [p], cb, undefined, true), + stat: (p, cb) => dispatch('stat', [p], cb, defaultStat()), + open: (p, flags, cb) => dispatch('open', [p, flags], cb, Buffer.from('handle')), + close: (h, cb) => dispatch('close', [h], cb, undefined, true), + read: (h, buf, off, len, pos, cb) => { + const verdict = behaviour('read', [h, off, len, pos]) + calls.push({ op: 'read', args: [off, len, pos] }) + if (verdict === 'silent') return + if (verdict === 'error') return cb(new Error('read failed')) + const answer = () => { + // Two chunks, then EOF, so a healthy download terminates. + if (pos >= 512) return cb(null, { bytesRead: 0, buffer: buf }) + buf.fill(0x41, off, off + 256) + cb(null, { bytesRead: 256, buffer: buf }) + } + // 'slow': answer after the metadata budget but inside the transfer one — + // a slow link, which must NOT be treated as a dead channel. + if (verdict === 'slow') setTimeout(answer, 120) + else answer() + }, + write: (h, buf, off, len, pos, cb) => dispatch('write', [h, len, pos], cb, undefined, true), + end: () => calls.push({ op: 'end' }), + on: () => wrapper + } + const dispatch = (op, args, cb, result, voidResult = false) => { + calls.push({ op, args }) + const verdict = behaviour(op, args) + if (verdict === 'silent') return + if (verdict === 'error') return cb(new Error(`${op} failed`)) + cb(null, voidResult ? undefined : result) + } + return wrapper +} + +const reset = (impl) => { + calls = [] + behaviour = impl ?? (() => 'ok') + openedChannels = [] +} +sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + openedChannels.push(w) + cb(null, w) + } +})) + +const expectReject = async (promise, label) => { + try { + await promise + return { rejected: false, message: '' } + } catch (err) { + return { rejected: true, message: err instanceof Error ? err.message : String(err) } + } +} + +// ---- 1. a silent channel is bounded, not waited on forever ----------------- +console.log('a silent (half-dead) channel rejects instead of hanging') +{ + reset(() => 'silent') + sftp.closeSftp('s1') + const started = Date.now() + const r = await expectReject(sftp.listRemote('s1', '/home'), 'listRemote') + const elapsed = Date.now() - started + ok(r.rejected, 'listRemote rejects') + ok(elapsed < 1500, `it rejected at the op budget, not later (${elapsed}ms)`) + ok(r.message.length > 0, `the error carries a message for the user (${r.message})`) + ok(!/^Failed/.test(r.message), 'the message is the app\'s own, not a raw ssh2 string') +} + +{ + // The retry is the point: a timeout is TRANSPORT-classified, so the possibly + // dead channel is evicted and the operation is retried once on a fresh one. + reset(() => 'silent') + sftp.closeSftp('s2') + await expectReject(sftp.listRemote('s2', '/home'), 'listRemote') + ok( + openedChannels.length === 2, + `a timeout evicts the channel and retries exactly once on a fresh one (${openedChannels.length} channels opened)` + ) +} + +{ + // A second timeout on the retry must surface, not loop. + let opened = 0 + reset(() => 'silent') + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + opened++ + const w = makeWrapper() + openedChannels.push(w) + cb(null, w) + } + })) + sftp.closeSftp('s3') + const r = await expectReject(sftp.listRemote('s3', '/home'), 'listRemote') + ok(r.rejected, 'a second consecutive timeout still rejects (no retry loop)') + ok(opened === 2, `exactly two channel opens for one operation (${opened})`) + // Restore the shared provider for later sections. + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + openedChannels.push(w) + cb(null, w) + } + })) +} + +// ---- 2. metadata operations each have a bound ------------------------------ +console.log('every metadata operation is bounded') +{ + const metadataOps = [ + ['listRemote', () => sftp.listRemote('m', '/home'), 'readdir'], + ['mkdirRemote', () => sftp.mkdirRemote('m', '/home', 'dir'), 'mkdir'], + ['renameRemote', () => sftp.renameRemote('m', '/a', '/b'), 'rename'], + ['deleteRemote', () => sftp.deleteRemote('m', ['/a']), 'unlink'] + ] + for (const [label, run, firstOp] of metadataOps) { + reset((op) => (op === firstOp || (label === 'listRemote' && op === 'readdir') ? 'silent' : 'ok')) + sftp.closeSftp('m') + const started = Date.now() + const r = await expectReject(run(), label) + const elapsed = Date.now() - started + ok(r.rejected, `${label} rejects on a silent channel`) + ok(elapsed < 1500, `${label} rejected at the budget (${elapsed}ms)`) + } +} + +console.log('a server-side error is NOT retried (it means the channel is alive)') +{ + reset(() => 'error') + sftp.closeSftp('alive') + const r = await expectReject(sftp.listRemote('alive', '/home'), 'listRemote') + ok(r.rejected, 'a server-side failure rejects') + ok(openedChannels.length === 1, `an error reply does not evict the channel (${openedChannels.length} open, expected 1)`) + ok(calls.filter((c) => c.op === 'readdir').length === 1, 'and the operation was not retried') +} + +// ---- 3. the transfer budget is wider than the metadata one ----------------- +console.log('a slow but progressing download is not killed by the metadata budget') +{ + const dir = mkdtempSync(join(root, 'dl-slow-')) + // Each chunk answers after the metadata budget (60ms) but inside the transfer + // budget (200ms): a slow link, not a dead one. + reset((op) => (op === 'read' ? 'slow' : 'ok')) + sftp.closeSftp('dl') + const events = [] + await sftp.downloadRemote('dl', ['/remote/big.bin'], dir, (ch, payload) => + events.push(payload) + ) + const done = await waitFor(() => events.some((e) => e.state === 'done' || e.state === 'error'), 3000) + const final = events.at(-1) + ok(done, 'the transfer settled') + ok(final?.state === 'done', `a progressing transfer completes despite slow chunks (got '${final?.state}': ${final?.error ?? ''})`) + rmSync(dir, { recursive: true, force: true }) +} + +console.log('a download whose channel goes silent IS bounded and reported') +{ + const dir = mkdtempSync(join(root, 'dl-dead-')) + reset(() => 'silent') + sftp.closeSftp('dl-dead') + const events = [] + await sftp.downloadRemote('dl-dead', ['/remote/big.bin'], dir, (ch, payload) => events.push(payload)) + const settled = await waitFor(() => events.some((e) => e.state === 'error'), 3000) + ok(settled, 'the transfer reported a terminal error instead of hanging') + const err = events.find((e) => e.state === 'error') + ok(typeof err?.error === 'string' && err.error.length > 0, `the error is user-visible (${err?.error})`) + rmSync(dir, { recursive: true, force: true }) +} + +console.log('an upload whose channel goes silent IS bounded and reported') +{ + const dir = mkdtempSync(join(root, 'ul-dead-')) + const src = join(dir, 'file.bin') + writeFileSync(src, Buffer.alloc(600 * 1024, 0x42)) // >2 chunks, so writes happen + reset((op) => (op === 'open' ? 'silent' : 'ok')) + sftp.closeSftp('ul-dead') + const events = [] + await sftp.uploadRemote('ul-dead', [src], '/remote', (ch, payload) => events.push(payload)) + const settled = await waitFor(() => events.some((e) => e.state === 'error'), 3000) + ok(settled, 'the upload reported a terminal error') + const err = events.find((e) => e.state === 'error') + ok(typeof err?.error === 'string' && err.error.length > 0, `the error is user-visible (${err?.error})`) + rmSync(dir, { recursive: true, force: true }) +} + +console.log('a silent write chunk (open succeeded) is bounded too') +{ + const dir = mkdtempSync(join(root, 'ul-silent-write-')) + const src = join(dir, 'file.bin') + writeFileSync(src, Buffer.alloc(600 * 1024, 0x42)) + reset((op) => (op === 'write' ? 'silent' : 'ok')) + sftp.closeSftp('ul-silent-write') + const events = [] + const started = Date.now() + await sftp.uploadRemote('ul-silent-write', [src], '/remote', (ch, payload) => events.push(payload)) + const settled = await waitFor(() => events.some((e) => e.state === 'error'), 5000) + const elapsed = Date.now() - started + ok(settled, 'the upload reported a terminal error') + ok(elapsed < 4000, `it gave up on the wider transfer budget rather than waiting forever (${elapsed}ms)`) + rmSync(dir, { recursive: true, force: true }) +} + +// ---- 4. a late reply cannot resurrect an abandoned operation --------------- +console.log('a reply that arrives after the timeout is ignored, not applied') +{ + let late + let settle + reset(() => 'ok') + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + // readdir answers only when the test releases it — well after the timeout. + w.readdir = (p, cb) => { + calls.push({ op: 'readdir', args: [p] }) + late = () => cb(null, ['too-late.txt']) + } + openedChannels.push(w) + cb(null, w) + } + })) + sftp.closeSftp('late') + const r = await expectReject(sftp.listRemote('late', '/home'), 'listRemote') + ok(r.rejected, 'the operation timed out') + ok(typeof late === 'function', 'the fake held the reply') + let threw = false + try { + late() // the abandoned callback fires now + } catch { + threw = true + } + ok(!threw, 'releasing the late reply does not throw (the race is already settled)') + await new Promise((r) => setTimeout(r, 20)) + ok(true, 'the process stayed alive after a late reply (no unhandled rejection)') +} + +console.log('a rejection after the timeout is swallowed, not surfaced as a crash') +{ + let late + reset(() => 'ok') + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + w.mkdir = (p, cb) => { + calls.push({ op: 'mkdir', args: [p] }) + late = () => cb(new Error('too late')) + } + openedChannels.push(w) + cb(null, w) + } + })) + process.on('unhandledRejection', onUnhandled) + let unhandled = 0 + function onUnhandled() { + unhandled++ + } + sftp.closeSftp('late2') + await expectReject(sftp.mkdirRemote('late2', '/home', 'x'), 'mkdirRemote') + late() + await new Promise((r) => setTimeout(r, 30)) + process.off('unhandledRejection', onUnhandled) + ok(unhandled === 0, `no unhandled rejection from the abandoned promise (${unhandled})`) + // Restore the standard provider. + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + openedChannels.push(w) + cb(null, w) + } + })) +} + +// ---- 5. the subsystem open is bounded too ---------------------------------- +console.log('a subsystem open that never answers is bounded (and retried once)') +{ + let opens = 0 + reset(() => 'ok') + sftp.registerSftpClientProvider(() => ({ + // Never calls back: the half-dead client that accepts the request and dies. + sftp: () => { + opens++ + } + })) + sftp.closeSftp('open-dead') + const started = Date.now() + const r = await expectReject(sftp.listRemote('open-dead', '/home'), 'listRemote') + const elapsed = Date.now() - started + ok(r.rejected, 'the operation rejects') + ok(opens === 2, `the dead open was retried exactly once (${opens} attempts)`) + ok(elapsed < 1500, `bounded by the open budget (${elapsed}ms)`) + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + openedChannels.push(w) + cb(null, w) + } + })) +} + +console.log('a synchronous throw from client.sftp() is a rejection, not a crash') +{ + reset(() => 'ok') + sftp.registerSftpClientProvider(() => ({ + sftp: () => { + throw new Error('socket already gone') + } + })) + sftp.closeSftp('sync-throw') + const r = await expectReject(sftp.listRemote('sync-throw', '/home'), 'listRemote') + ok(r.rejected, 'the synchronous throw became a rejection') + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + openedChannels.push(w) + cb(null, w) + } + })) +} + +console.log('a missing session is refused without opening anything') +{ + reset(() => 'ok') + sftp.registerSftpClientProvider(() => undefined) + sftp.closeSftp('gone') + const r = await expectReject(sftp.listRemote('gone', '/home'), 'listRemote') + ok(r.rejected, 'the operation rejects') + ok(openedChannels.length === 0, 'no channel was opened for a missing session') + sftp.registerSftpClientProvider(() => ({ + sftp: (cb) => { + const w = makeWrapper() + openedChannels.push(w) + cb(null, w) + } + })) +} + +// ---- 6. the budgets are the documented ones -------------------------------- +console.log('default budgets are sane relative to each other') +{ + // The injected harness values are deliberately tiny; resetting them proves the + // setter restores what later runs (and the app) expect, without depending on + // internal constants. + sftp.setSftpTimeouts({ op: 30_000, transfer: 60_000, open: 10_000 }) + reset(() => 'silent') + sftp.closeSftp('budget') + const started = Date.now() + const probe = sftp.listRemote('budget', '/home') + const settledEarly = await Promise.race([ + probe.then(() => true, () => true), + new Promise((r) => setTimeout(() => r(false), 1500)) + ]) + ok(!settledEarly, 'with the production metadata budget, a 1.5s wait does not time out (the budget is generous, not hair-trigger)') + // Don't wait out the real 30s: abandon it and restore the harness budget. + void probe.catch(() => undefined) + sftp.setSftpTimeouts({ op: 60, transfer: 200, open: 60 }) + const elapsed = Date.now() - started + ok(elapsed < 3000, `the check itself was quick (${elapsed}ms)`) +} + +// ---- helpers ---------------------------------------------------------------- +function waitFor(pred, timeoutMs) { + return new Promise((resolve) => { + const started = Date.now() + const tick = () => { + if (pred()) return resolve(true) + if (Date.now() - started > timeoutMs) return resolve(false) + setTimeout(tick, 10) + } + tick() + }) +} + +rmSync(root, { recursive: true, force: true }) + +if (failed > 0) { + console.error(`\n[sftp-timeout] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log(`\n[sftp-timeout] ALL CHECKS PASSED (${passed} assertions)`) +process.exit(0) diff --git a/tests/ssh-loopback.mjs b/tests/ssh-loopback.mjs index b630774..1e2641d 100644 --- a/tests/ssh-loopback.mjs +++ b/tests/ssh-loopback.mjs @@ -1,24 +1,61 @@ /** * SSH loopback verification (M2). Pure Node ESM, no Electron. * - * Spins up an in-process ssh2.Server (127.0.0.1, random port), then connects - * with a plain ssh2.Client using the exact same connect parameters the main - * process ssh service uses (host/port/username/password/keepalive/hostVerifier), - * and confirms the full data plane round-trip: + * Spins up an in-process ssh2.Server (127.0.0.1, random port) and drives the + * SHIPPED connect path — `connectSsh` from src/main/ssh.ts, loaded through + * tests/.ssh.cjs (build-bundles.cjs) — rather than a hand-copied ConnectConfig. + * ssh.ts is deliberately Electron-free (`SshServiceDeps` injects the store, the + * broadcast and the host-key prompt), so the real module can be exercised here + * with no Chromium and no stubs. What that buys: the connect parameters, the + * host-key verifier, the async prompt handshake and the auth gates are all the + * code the app runs, not a copy that can silently drift from it. * - * ready -> shell -> banner "LOOPBACK-OK" -> write data -> echo -> close + * Covered: + * - happy path: connect -> auth -> shell -> banner "LOOPBACK-OK" -> write -> + * echo -> resize -> close, with the pinned key accepted without a prompt + * - TOFU: an unknown key asks the renderer (promptHostKey), and accepting it + * pins the key through knownHosts.accept before the handshake resumes + * - a rejected key aborts the connect with a user-facing reason + * - an unreadable store fails CLOSED without offering an accept + * - a store whose check() throws is treated the same way (never 'new') + * - a prompt nobody answers times out to a refusal + * - an unreachable port is bounded by the connect timeout + * - the auth gate: a connect-time password must NOT authenticate a + * privateKey bookmark, a passphrase must NOT leak into password auth * - * Run: `node tests/ssh-loopback.mjs` (must exit 0) + * Build: node tests/build-bundles.cjs + * Run: node tests/ssh-loopback.mjs (must exit 0) */ import pkg from 'ssh2' -const { Server, Client, utils } = pkg +const { Server, utils } = pkg import { createHash } from 'crypto' +import { createServer as createNetServer } from 'node:net' +import { createRequire } from 'module' + +const require_ = createRequire(import.meta.url) +const { connectSsh, resolveHostKey } = require_('./.ssh.cjs') function fingerprintOf(key) { return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') } +/** + * `utils.generateKeyPairSync().public` is the OpenSSH TEXT form + * (`ssh-ed25519 AAAA…`), while ssh2's `hostVerifier` receives the raw wire + * blob — the base64 payload of that text, decoded. Converting here keeps the + * fingerprint assertions about the key the client was actually offered. + */ +const wireKeyOf = (publicKey) => Buffer.from(String(publicKey).trim().split(/\s+/)[1], 'base64') + +let failed = 0 +let passed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 + else passed += 1 +} + const fail = (msg) => { console.error(`FAIL: ${msg}`) process.exit(1) @@ -43,6 +80,7 @@ function newHostKey() { const serverKey = newHostKey() let serverPort = 0 let seenWindowChange = { cols: 0, rows: 0 } +let serverHostKey = null const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { client.on('authentication', (ctx) => { @@ -76,7 +114,10 @@ const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { }) }) - client.on('error', (err) => console.error('[server] client error', err.message)) + client.on('error', (err) => { + // Rejected keys and refused auths are expected; not a harness failure. + void err + }) }) await new Promise((resolve, reject) => { @@ -88,91 +129,341 @@ await new Promise((resolve, reject) => { }) console.log(`[loopback] ssh server listening on 127.0.0.1:${serverPort}`) -// ---- 2. Connect with the same params the main ssh service uses -------------- -const client = new Client() -let bannerSeen = false -let echoed = false -let output = '' -let verifyCalls = 0 -let resolveDone -const done = new Promise((r) => (resolveDone = r)) -const timer = setTimeout(() => { - fail(`timed out (client connected: ${client._stream ? 'yes' : 'no'})`) -}, 10000) +// ---- 2. Sanity: the bundle really is the shipped module -------------------- +{ + ok( + /^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprintOf(wireKeyOf(serverKey.public))), + 'the harness fingerprint matches the OpenSSH SHA256 form (43 chars, no padding)' + ) + ok(typeof connectSsh === 'function', 'connectSsh was loaded from the real src/main/ssh.ts bundle') + ok(typeof resolveHostKey === 'function', 'resolveHostKey was loaded from the same bundle') +} -client.on('ready', () => { - console.log('[loopback] client ready') - client.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, stream) => { - if (err) return fail(`shell error: ${err.message}`) - console.log('[loopback] shell open') - let sentInput = false - let sentExit = false +// ---- 3. deps --------------------------------------------------------------- +/** Records everything the service asks the outside world for. */ +const makeDeps = (over = {}) => { + const seen = { + prompts: [], + accepted: [], + touched: [], + broadcasts: [] + } + return { + seen, + deps: { + connections: { + getSecret: () => over.secret, + touch: (id) => seen.touched.push(id) + }, + knownHosts: { + check: over.check ?? (() => ({ status: 'match' })), + accept: (host, port, key, fingerprint) => seen.accepted.push({ host, port, fingerprint }) + }, + broadcast: (channel, ...args) => seen.broadcasts.push({ channel, args }), + promptHostKey: (prompt) => seen.prompts.push(prompt), + timeoutMs: over.timeoutMs + } + } +} - stream.on('data', (d) => { - const chunk = d.toString('utf8') - output += chunk - if (output.includes('LOOPBACK-OK') && !sentInput) { - bannerSeen = true - console.log('[loopback] banner received') - // exercise resize while the session is live - stream.setWindow(40, 120, 0, 0) - sentInput = true - stream.write('hello loopback\n') - } - if (output.includes('hello loopback') && echoed === false) { - echoed = true - console.log('[loopback] echo received') - } - if (bannerSeen && echoed && !sentExit) { - sentExit = true - stream.write('exit\n') - } - }) - - stream.on('close', () => { - clearTimeout(timer) - console.log('[loopback] stream closed') - client.end() - resolveDone() - }) - }) -}) - -client.on('error', (err) => fail(`client error: ${err.message}`)) - -// hostVerifier mirrors ssh.ts (SHA256 fingerprint; loopback accepts the key) -client.connect({ +const connection = (over = {}) => ({ + id: 'loopback', + name: 'loopback', host: '127.0.0.1', port: serverPort, username: 'test', - password: 'test', - keepaliveInterval: 0, - hostVerifier: (hostKey, verify) => { - verifyCalls++ - const fp = fingerprintOf(hostKey) - console.log(`[loopback] hostVerifier called (${verifyCalls}), fp=${fp}`) - verify(true) - } + auth: 'password', + askPasswordAtConnect: false, + askPassphraseAtConnect: false, + keepaliveIntervalSec: 0, + createdAt: Date.now(), + savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }, + ...over }) -// ---- 3. Assertions ---------------------------------------------------------- -await done -srv.close() - -const checks = [ - ['client reached ready', bannerSeen], - ['banner LOOPBACK-OK received', bannerSeen], - ['typed data echoed back', echoed], - ['host key verified exactly once', verifyCalls === 1], - ['resize propagated to server (40x120)', seenWindowChange.cols === 120 && seenWindowChange.rows === 40] -] - -let ok = true -for (const [label, pass] of checks) { - console.log(`[loopback] ${pass ? 'PASS' : 'FAIL'}: ${label}`) - if (!pass) ok = false +/** Run a connect and report whether it resolved. */ +const tryConnect = async (conn, secretOverride, over) => { + const { seen, deps } = makeDeps({ secret: 'test', ...over }) + try { + const handle = await connectSsh(conn, secretOverride, deps) + return { handle, seen } + } catch (err) { + return { error: err instanceof Error ? err.message : String(err), seen } + } } -if (!ok) fail('one or more checks failed') -console.log('[loopback] ALL CHECKS PASSED') -process.exit(0) \ No newline at end of file +// ---- 4. happy path: pinned key, no prompt ---------------------------------- +console.log('happy path: connect, auth, shell, data plane, resize') +{ + const { handle, error, seen } = await tryConnect(connection(), undefined) + if (error) fail(`connect failed: ${error}`) + ok(handle?.id !== undefined, 'the service resolved with a session id') + ok(handle.client !== undefined && typeof handle.stream?.write === 'function', 'the handle carries the client and the shell stream') + ok(handle.exitCode === 0, 'a fresh session starts with exit code 0') + ok(seen.prompts.length === 0, 'a pinned (status match) key is accepted without prompting the user') + ok(seen.touched.includes('loopback'), 'the bookmark is touched (lastConnectedAt) on a successful connect') + ok(seen.accepted.length === 0, 'an already-pinned key is not re-pinned') + + const output = await new Promise((resolve, reject) => { + let text = '' + let sentInput = false + const timer = setTimeout(() => reject(new Error(`timed out, saw: ${JSON.stringify(text)}`)), 8000) + handle.stream.on('data', (d) => { + text += d.toString('utf8') + if (text.includes('LOOPBACK-OK') && !sentInput) { + sentInput = true + // exercise resize while the session is live + handle.stream.setWindow(40, 120, 0, 0) + handle.stream.write('hello loopback\n') + } + if (text.includes('hello loopback') && text.includes('LOOPBACK-OK')) { + clearTimeout(timer) + resolve(text) + } + }) + handle.stream.on('error', (e) => { + clearTimeout(timer) + reject(e) + }) + }) + ok(output.includes('LOOPBACK-OK'), 'the server banner arrived ("LOOPBACK-OK")') + ok(output.includes('hello loopback'), 'typed data was echoed back through the real shell') + + // Resize reached the server (the server only records the last window-change). + const resized = await new Promise((resolve) => { + const started = Date.now() + const tick = () => { + if (seenWindowChange.cols === 120 && seenWindowChange.rows === 40) return resolve(true) + if (Date.now() - started > 3000) return resolve(false) + setTimeout(tick, 20) + } + tick() + }) + ok(resized, `resize propagated to the server (40x120, saw ${JSON.stringify(seenWindowChange)})`) + + await new Promise((resolve) => { + handle.stream.on('close', resolve) + handle.stream.write('exit\n') + setTimeout(resolve, 3000) + }) + try { + handle.client.end() + } catch { + /* already gone */ + } +} + +// ---- 5. TOFU: unknown key is prompted, accepted, then pinned --------------- +console.log('TOFU: an unknown host key is prompted and pinned on accept') +{ + const check = () => ({ status: 'new' }) + const { seen, deps } = makeDeps({ secret: 'test', check }) + const pending = connectSsh(connection(), undefined, deps) + + // The handshake is paused inside hostVerifier until we answer. + const prompt = await waitFor(() => seen.prompts[0], 5000) + ok(prompt !== undefined, 'the renderer was asked to decide on the new key') + ok(prompt?.host === '127.0.0.1' && prompt?.port === serverPort, 'the prompt names the host and port') + ok(prompt?.reason === 'new', "the prompt reason is 'new' for an unknown key") + ok(prompt?.fingerprint === fingerprintOf(wireKeyOf(serverKey.public)), 'the prompt carries the SHA256 fingerprint of the key actually offered') + ok(typeof prompt?.promptId === 'string' && prompt.promptId.length > 0, 'the prompt carries an id to answer with') + + // Nothing may be pinned before the user decides. + ok(seen.accepted.length === 0, 'the key is not pinned while the decision is outstanding') + + resolveHostKey(prompt.promptId, 'accept') + const handle = await pending + ok(handle?.id !== undefined, 'the connect resumed and succeeded after the accept') + ok(seen.accepted.length === 1, 'the accepted key was pinned exactly once') + ok(seen.accepted[0].fingerprint === fingerprintOf(wireKeyOf(serverKey.public)), 'the pinned fingerprint is the one shown to the user') + ok(seen.accepted[0].host === '127.0.0.1' && seen.accepted[0].port === serverPort, 'the key is pinned for the right host:port') + try { + handle.client.end() + } catch { + /* already gone */ + } +} + +console.log('an answer that arrives after the handshake failed is ignored (not pinned)') +{ + // The prompt is answered, but the transport dies first: pinning then would + // record trust for a connection that never completed. + const check = () => ({ status: 'new' }) + const { seen, deps } = makeDeps({ secret: 'test', check }) + const conn = connection({ port: 1 }) // nothing listens there + const pending = connectSsh(conn, undefined, deps) + // Wait for the transport to fail (or for the prompt, whichever comes first). + const settled = await Promise.race([ + pending.then(() => 'resolved', () => 'rejected'), + new Promise((r) => setTimeout(() => r('pending'), 3000)) + ]) + ok(settled === 'rejected' || settled === 'pending', `the dead-port connect did not succeed (${settled})`) + const prompt = seen.prompts[0] + if (prompt) { + resolveHostKey(prompt.promptId, 'accept') + await new Promise((r) => setTimeout(r, 100)) + } + ok(seen.accepted.length === 0, 'no fingerprint was pinned for a connect that never completed') + await pending.catch(() => undefined) +} + +// ---- 6. reject / unreadable / throwing store ------------------------------- +console.log('a rejected key aborts the connect with a user-facing reason') +{ + const check = () => ({ status: 'new' }) + const { seen, deps } = makeDeps({ secret: 'test', check }) + const pending = connectSsh(connection(), undefined, deps) + const prompt = await waitFor(() => seen.prompts[0], 5000) + ok(prompt !== undefined, 'the user was prompted') + resolveHostKey(prompt.promptId, 'reject') + const r = await pending.then(() => null, (e) => e.message) + ok(typeof r === 'string', 'the connect was refused') + ok(r.includes('127.0.0.1') && r.includes(String(serverPort)), `the refusal names the host:port (${r})`) + ok(seen.accepted.length === 0, 'nothing was pinned for a rejected key') +} + +console.log('an unreadable store fails CLOSED without offering an accept') +{ + const check = () => ({ status: 'unreadable' }) + const { seen } = await tryConnect(connection(), undefined, { check }) + ok(seen.prompts.length === 0, 'the user is NOT offered an accept when the store cannot be read') + ok(seen.accepted.length === 0, 'nothing is pinned into a store we failed to load') +} + +console.log('a store whose check() throws is treated as unreadable, never as new') +{ + const check = () => { + throw new Error('boom') + } + const { error, seen } = await tryConnect(connection(), undefined, { check }) + ok(typeof error === 'string', 'the connect failed') + ok(seen.prompts.length === 0, 'a throwing store does NOT become a prompt (falling back to "new" would rewrite the store)') + ok(seen.accepted.length === 0, 'and nothing is pinned') +} + +console.log('an accept that fails to persist refuses the connection') +{ + const check = () => ({ status: 'new' }) + const { seen, deps } = makeDeps({ secret: 'test', check }) + deps.knownHosts.accept = () => { + throw new Error('disk full') + } + const pending = connectSsh(connection(), undefined, deps) + const prompt = await waitFor(() => seen.prompts[0], 5000) + resolveHostKey(prompt.promptId, 'accept') + const r = await pending.then(() => null, (e) => e.message) + ok(typeof r === 'string', 'the connect was refused rather than proceeding unpinned') + ok(r.includes('127.0.0.1'), `the refusal names the host (${r})`) +} + +// ---- 7. timeouts ----------------------------------------------------------- +console.log('a prompt nobody answers times out into a refusal') +{ + const check = () => ({ status: 'new' }) + const started = Date.now() + const { error, seen } = await tryConnect(connection(), undefined, { + check, + timeoutMs: { prompt: 120, connect: 5000 } + }) + const elapsed = Date.now() - started + ok(seen.prompts.length === 1, 'the user was asked') + ok(typeof error === 'string', 'the unanswered prompt became a refusal') + ok(elapsed < 3000, `the prompt budget decided it, not the connect budget (${elapsed}ms)`) +} + +console.log('an unreachable port is bounded by the connect timeout') +{ + // A TCP server that accepts the connection and then says nothing: the SSH + // handshake never starts, which is the shape a dropped firewall produces. + const sockets = new Set() + const blackhole = createNetServer((socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + socket.on('error', () => undefined) + }) + await new Promise((r) => blackhole.listen(0, '127.0.0.1', r)) + const port = blackhole.address().port + const started = Date.now() + const { error } = await tryConnect(connection({ port }), undefined, { + timeoutMs: { prompt: 1000, connect: 250 } + }) + const elapsed = Date.now() - started + ok(typeof error === 'string', 'the stalled handshake was refused') + ok(error.includes('127.0.0.1') && error.includes(String(port)), `the refusal names the host:port (${error})`) + ok(elapsed < 3000, `it gave up at the connect budget (${elapsed}ms)`) + // `close()` only calls back once every accepted socket is gone, and the + // abandoned client left one behind — drop them explicitly. + for (const socket of sockets) socket.destroy() + await new Promise((r) => blackhole.close(r)) +} + +// ---- 8. auth gates --------------------------------------------------------- +console.log('the auth gates hold') +{ + // A stored password on a privateKey bookmark must not be offered. + const { error } = await tryConnect(connection({ auth: 'privateKey' }), undefined) + ok(typeof error === 'string', 'a key-auth bookmark with only a stored password cannot authenticate') + + // A connect-time typed password must not upgrade a key-auth bookmark either. + const { error: e2 } = await tryConnect(connection({ auth: 'privateKey' }), { password: 'test' }) + ok(typeof e2 === 'string', 'a typed password does not authenticate a key-auth bookmark (the gate above)') + + // A typed password DOES authenticate a password bookmark (secretOverride path). + const { handle, seen } = await tryConnect(connection(), { password: 'test' }) + ok(handle?.id !== undefined, 'a typed connect-time password authenticates a password bookmark') + ok(seen.touched.includes('loopback'), 'and the session is a normal successful connect') + try { + handle.client.end() + } catch { + /* already gone */ + } + + // Wrong password is refused by the server, and reported as a connect failure. + const { error: e3 } = await tryConnect(connection(), undefined, { secret: 'wrong' }) + ok(typeof e3 === 'string' && e3.includes('127.0.0.1'), `a wrong password is reported as a connect failure (${e3})`) +} + +console.log('an unparseable private key is refused at connect, not thrown into the void') +{ + const { error } = await tryConnect(connection({ auth: 'privateKey' }), undefined, { + secret: 'not a key' + }) + ok(typeof error === 'string', 'the connect was refused') + ok(error.includes('127.0.0.1'), `the refusal names the host (${error})`) +} + +// ---- 9. broadcasting -------------------------------------------------------- +console.log('the renderer prompt goes out over the injected broadcast surface') +{ + const check = () => ({ status: 'new' }) + const { seen, deps } = makeDeps({ secret: 'test', check }) + const pending = connectSsh(connection(), undefined, deps) + const prompt = await waitFor(() => seen.prompts[0], 5000) + ok(prompt !== undefined, 'promptHostKey was called through deps (the app wires it to broadcast)') + resolveHostKey(prompt.promptId, 'accept') + await pending.catch(() => undefined) + ok(seen.broadcasts.length === 0, 'the service itself does not broadcast (pty.ts owns the session events)') +} + +// ---- teardown --------------------------------------------------------------- +srv.close() + +if (failed > 0) { + console.error(`\n[loopback] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log(`\n[loopback] ALL CHECKS PASSED (${passed} assertions)`) +process.exit(0) + +function waitFor(pred, timeoutMs) { + return new Promise((resolve) => { + const started = Date.now() + const tick = () => { + const value = pred() + if (value !== undefined) return resolve(value) + if (Date.now() - started > timeoutMs) return resolve(undefined) + setTimeout(tick, 10) + } + tick() + }) +} diff --git a/tests/updater-fallback.mjs b/tests/updater-fallback.mjs new file mode 100644 index 0000000..84387df --- /dev/null +++ b/tests/updater-fallback.mjs @@ -0,0 +1,537 @@ +/** + * Update-service self-test (updater-fallback.mjs). + * + * src/main/updater.ts owns the two-feed strategy that keeps update checks + * working for users behind (and without) a proxy. Everything in it that used to + * be untestable — electron-updater and electron's session/net — is stubbed: + * + * - `electron-updater` is aliased to tests/electron-updater-stub.cjs, driven + * through `globalThis.__otAutoUpdater` + * - electron's `session.fromPartition(...).fetch` goes through + * `globalThis.__otFetch`, and each session records its `setProxy` calls + * + * What is pinned here: + * - the GitHub probe gates the feed: reachable -> GitHub first; unreachable + * (thrown, non-OK, or TIMED OUT) -> straight to Gitea, and the updater is + * never pointed at GitHub + * - the probe timeout really fires (a fetch that never resolves is abandoned + * at the budget, not waited on) + * - a check that throws on GitHub falls back to Gitea, and BOTH error + * messages reach the user when Gitea fails too + * - the overall check budget rejects a check that never settles, and the + * state lands on 'error' — never stuck on 'checking' + * - the feed choice is per attempt: a transient GitHub failure does not pin + * the next check to Gitea + * - proxy modes: Gitea forces `direct`, GitHub uses `system` (the whole point + * of the two-feed split) + * - dev builds never check (state 'dev'), and a packaged build's changelog + * fetch falls back through the three sources + * + * Build: node tests/build-bundles.cjs + * Run: node tests/updater-fallback.mjs (must exit 0) + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const require = createRequire(import.meta.url) + +// ---- stub control ----------------------------------------------------------- +// Must be installed BEFORE the bundle is required (the stub reads globals). +const ctl = { + checkForUpdates: undefined, + downloadUpdate: undefined, + quitAndInstallCalls: [], + feeds: [], + proxies: [] +} +globalThis.__otAutoUpdater = ctl + +let fetchImpl = undefined +globalThis.__otFetch = (url, init) => { + if (!fetchImpl) { + return Promise.resolve({ ok: false, status: 404, text: async () => '', json: async () => [] }) + } + return fetchImpl(url, init) +} + +const stub = require('./electron-stub.cjs') +stub.__setPackaged(false) + +const updater = require('./.updater.cjs') +const { Ipc } = require('./.ipc-channels.cjs') + +let failed = 0 +let passed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 + else passed += 1 +} + +// Shrink every budget so timeout paths run in milliseconds, not minutes. +updater.setUpdateTimeouts({ probe: 60, check: 120, fetch: 60 }) + +/** + * The service's own timers (`withTimeout`, `AbortSignal.timeout`) are unref'd on + * purpose — a pending update check must never keep the app alive. In a test + * process that means Node can decide to exit while an awaited check is still + * pending, which shows up as "unsettled top-level await". This ref'd interval + * keeps the loop turning for the duration of the run. + */ +const keepAlive = setInterval(() => {}, 1000) + +// The channels are registered by registerUpdateIpc(); the stub records them. +updater.registerUpdateIpc() +const handlers = stub.__handlers +const call = (channel, ...args) => handlers.get(channel)({ senderFrame: { url: 'x' } }, ...args) + +// The module-level `state` is shared by every section below, so the birth state +// is asserted here — before any check has had a chance to move it. +const birthState = await call(Ipc.UPDATE_STATE_GET) + +const reset = () => { + ctl.feeds.length = 0 + ctl.proxies.length = 0 + ctl.quitAndInstallCalls.length = 0 + ctl.checkForUpdates = undefined + fetchImpl = undefined + stub.__sessions.clear() +} +const okResponse = (body = '') => ({ + ok: true, + status: 200, + text: async () => body, + json: async () => JSON.parse(body || '[]') +}) +const errResponse = (status = 500) => ({ + ok: false, + status, + text: async () => '', + json: async () => [] +}) +/** + * A fetch that never settles on its own — the half-dead channel the timeouts + * exist for. It holds the event loop open with a ref'd timer because + * `AbortSignal.timeout`'s internal timer is unref'd: without it Node would exit + * before the abort fires and the test would look like a hang. + */ +const neverSettles = (url, init) => + new Promise((_, reject) => { + const keepAlive = setTimeout(() => reject(new Error('harness: fetch never settled')), 30_000) + init?.signal?.addEventListener( + 'abort', + () => { + clearTimeout(keepAlive) + reject(new Error('aborted')) + }, + { once: true } + ) + }) + +const GitHubProbeHost = 'api.github.com' +const isProbe = (url) => String(url).includes(GitHubProbeHost) +let probeCalls = 0 +const feedFor = (provider, feeds) => feeds.find((f) => f.provider === provider) +const lastFeed = () => ctl.feeds[ctl.feeds.length - 1] +/** + * Which feed the updater was last pointed at, in the terms the service uses: + * electron-updater's provider name is 'github' for the GitHub feed and + * 'generic' for the domestic Gitea package channel. + */ +const lastFeedName = () => (lastFeed()?.provider === 'github' ? 'github' : 'gitea') +const proxyFor = (name) => stub.__sessions.get(name)?.proxyCalls ?? [] + +// ---- 1. initial state + dev build ------------------------------------------ +console.log('a freshly started service') +{ + ok(birthState.status === 'idle', `the service starts idle (got '${birthState.status}')`) + ok(birthState.currentVersion === '0.0.0-stub', 'the state always carries the running version') + ok(birthState.version === undefined, 'no version is claimed before a check') + ok(birthState.error === undefined, 'no error is claimed before a check') + ok(birthState.percent === undefined, 'no download percent before a download') +} + +console.log('dev build (not packaged)') +{ + reset() + stub.__setPackaged(false) + let checked = false + ctl.checkForUpdates = async () => { + checked = true + return null + } + const state = await call(Ipc.UPDATE_CHECK) + ok(state.status === 'dev', `a dev build reports 'dev' (got '${state.status}')`) + ok(!checked, 'the updater is never asked to check in a dev build') + ok(ctl.feeds.length === 0, 'no feed is configured in a dev build') + await call(Ipc.UPDATE_DOWNLOAD) + ok(true, 'download in a dev build is a no-op, not a crash') +} + +// ---- 2. probe reachable -> GitHub first ------------------------------------ +console.log('probe succeeds: GitHub is the feed') +{ + reset() + stub.__setPackaged(true) + probeCalls = 0 + fetchImpl = (url) => { + if (isProbe(url)) { + probeCalls++ + return Promise.resolve(okResponse('{"tag_name":"v1"}')) + } + return Promise.resolve(okResponse('[]')) + } + let seen = '' + ctl.checkForUpdates = async () => { + seen = lastFeedName() + return null + } + const state = await call(Ipc.UPDATE_CHECK) + ok(probeCalls === 1, 'the probe ran exactly once') + ok(seen === 'github', `the check ran against the GitHub feed (got '${seen}')`) + ok(feedFor('github', ctl.feeds) !== undefined, 'the GitHub feed was configured') + ok(feedFor('github', ctl.feeds).owner === 'billowliu2', 'the GitHub feed carries the repo owner') + ok(proxyFor('openterminal-github-probe').some((p) => p.mode === 'system'), 'the probe used the system proxy') + ok(state.status === 'checking' || state.status === 'error' || state.status === 'idle', `state is a known value (${state.status})`) +} + +// ---- 3. probe fails -> straight to Gitea ----------------------------------- +console.log('probe failures fall through to Gitea without touching GitHub') +const probeFailures = [ + ['non-OK response (404 from a blocked/mirrored host)', () => Promise.resolve(errResponse(404))], + ['a thrown network error', () => Promise.reject(new Error('ENOTFOUND api.github.com'))], + ['a fetch that never settles (times out at the budget)', neverSettles] +] +for (const [label, impl] of probeFailures) { + reset() + stub.__setPackaged(true) + probeCalls = 0 + fetchImpl = (url, init) => { + if (isProbe(url)) { + probeCalls++ + return impl(url, init) + } + return Promise.resolve(okResponse('[]')) + } + let seen = '' + ctl.checkForUpdates = async () => { + seen = lastFeedName() + return null + } + const started = Date.now() + await call(Ipc.UPDATE_CHECK) + const elapsed = Date.now() - started + ok(probeCalls === 1, `${label}: the probe was attempted once`) + ok(seen === 'gitea', `${label}: the check went straight to Gitea (got '${seen}')`) + ok(feedFor('github', ctl.feeds) === undefined, `${label}: the GitHub feed was never configured`) + ok(feedFor('generic', ctl.feeds) !== undefined, `${label}: the Gitea feed is the generic provider`) + if (label.includes('times out')) { + ok(elapsed < 2000, `${label}: the abandoned probe was refused at the budget, not waited on (${elapsed}ms)`) + } else { + ok(elapsed < 2000, `${label}: resolved promptly (${elapsed}ms)`) + } +} + +console.log('proxy modes: Gitea is forced direct, GitHub uses the system proxy') +{ + reset() + stub.__setPackaged(true) + fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]'))) + ctl.checkForUpdates = async () => { + // netSession.setProxy is what useFeed() calls on the updater itself. + return null + } + await call(Ipc.UPDATE_CHECK) + ok( + ctl.proxies.some((p) => p.mode === 'system'), + 'the GitHub attempt set the updater session to the system proxy' + ) + + reset() + fetchImpl = () => Promise.resolve(errResponse(503)) + ctl.checkForUpdates = async () => null + await call(Ipc.UPDATE_CHECK) + ok( + ctl.proxies.some((p) => p.mode === 'direct'), + 'the Gitea attempt set the updater session to direct (a system proxy breaks it)' + ) +} + +// ---- 4. GitHub check fails -> Gitea fallback, both errors reported --------- +console.log('a GitHub check failure falls back to Gitea') +{ + reset() + stub.__setPackaged(true) + fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]'))) + const attempted = [] + ctl.checkForUpdates = async () => { + attempted.push(lastFeedName()) + if (attempted.length === 1) throw new Error('github exploded') + return null + } + const state = await call(Ipc.UPDATE_CHECK) + ok(attempted.join(',') === 'github,gitea', `both feeds were tried in order (${attempted.join(',')})`) + ok(feedFor('generic', ctl.feeds) !== undefined, 'the Gitea feed was configured for the fallback') + ok(state.status !== 'error', `the fallback succeeded, so no error state (got '${state.status}')`) +} + +console.log('both feeds failing reports BOTH reasons to the user') +{ + reset() + stub.__setPackaged(true) + fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]'))) + const attempted = [] + ctl.checkForUpdates = async () => { + attempted.push(lastFeedName()) + throw new Error(attempted.length === 1 ? 'github exploded' : 'gitea exploded') + } + const state = await call(Ipc.UPDATE_CHECK) + ok(attempted.join(',') === 'github,gitea', 'both feeds were attempted') + ok(state.status === 'error', `the state is 'error' (got '${state.status}')`) + ok(typeof state.error === 'string' && state.error.includes('github exploded'), 'the GitHub reason is reported') + ok(typeof state.error === 'string' && state.error.includes('gitea exploded'), 'the Gitea reason is reported') +} + +console.log('Gitea-only failure reports its own reason') +{ + reset() + stub.__setPackaged(true) + fetchImpl = () => Promise.resolve(errResponse(503)) + ctl.checkForUpdates = async () => { + throw new Error('gitea exploded') + } + const state = await call(Ipc.UPDATE_CHECK) + ok(state.status === 'error', 'the state is error') + ok(state.error === 'gitea exploded', `the Gitea reason is the error (got ${JSON.stringify(state.error)})`) +} + +// ---- 5. the overall check budget ------------------------------------------- +console.log('a check that never settles is bounded by the overall budget') +{ + reset() + stub.__setPackaged(true) + fetchImpl = () => Promise.resolve(errResponse(503)) // probe fails -> Gitea directly + ctl.checkForUpdates = () => new Promise(() => {}) // never settles + const started = Date.now() + const state = await call(Ipc.UPDATE_CHECK) + const elapsed = Date.now() - started + ok(elapsed < 3000, `it gave up at the budget instead of hanging (${elapsed}ms)`) + ok(state.status === 'error', `the state is 'error', never stuck on 'checking' (got '${state.status}')`) + ok(typeof state.error === 'string' && state.error.length > 0, 'an error message is set for the UI') +} + +console.log('a GitHub check that never settles still lands on an error, not on "checking"') +{ + reset() + stub.__setPackaged(true) + fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]'))) + // The probe succeeds, so the first (GitHub) attempt is the one that hangs. + // electron-updater de-dupes concurrent checks, so the Gitea fallback shares + // the abandoned promise — it must still be bounded, not left hanging. + let calls = 0 + ctl.checkForUpdates = () => { + calls++ + return new Promise(() => {}) + } + const state = await call(Ipc.UPDATE_CHECK) + ok(calls === 2, `both attempts ran against the same in-flight promise (${calls})`) + ok(state.status === 'error', `the state resolved to 'error' (got '${state.status}')`) +} + +// ---- 6. the feed choice is per attempt ------------------------------------- +console.log('the feed choice is not pinned by a transient failure') +{ + reset() + stub.__setPackaged(true) + let probeFails = false + fetchImpl = (url) => + isProbe(url) + ? Promise.resolve(probeFails ? errResponse(500) : okResponse('{}')) + : Promise.resolve(okResponse('[]')) + const attempts = [] + ctl.checkForUpdates = async () => { + attempts.push(lastFeedName()) + return null + } + + await call(Ipc.UPDATE_CHECK) + ok(attempts.at(-1) === 'github', 'the first check used GitHub') + + probeFails = true + await call(Ipc.UPDATE_CHECK) + ok(attempts.at(-1) === 'gitea', 'with the probe now failing, the next check uses Gitea') + + probeFails = false + await call(Ipc.UPDATE_CHECK) + ok(attempts.at(-1) === 'github', 'once the probe recovers the next check goes back to GitHub (no pinning)') +} + +// ---- 7. event wiring + state ------------------------------------------------- +console.log('updater events drive the state the renderer reads') +{ + reset() + const userData = mkdtempSync(join(tmpdir(), 'ot-updater-')) + stub.__setUserData(userData) + stub.__setPackaged(true) + + // configureAutoUpdater wires the event listeners and (unless the user turned + // startup checks off) schedules a check 5s after launch — unref'd, so it does + // not hold the test open. + updater.configureAutoUpdater() + + // `on()` publishes the instance the bundle actually subscribed, which is NOT + // the one this file required (the bundle inlines its own copy of the stub). + const live = ctl.live + ok(live !== undefined, 'configureAutoUpdater wired the updater events') + ok(live.logger === console, 'the updater logs through console') + ok(live.autoDownload === false, 'auto-download is left to the user (the UI offers the button)') + ok(live.autoInstallOnAppQuit === true, 'a downloaded update installs on app quit') + + const before = await call(Ipc.UPDATE_STATE_GET) + ok(before !== undefined && typeof before.currentVersion === 'string', 'UPDATE_STATE_GET returns the current state') + ok(before.currentVersion === '0.0.0-stub', 'the state always carries the running version') + // `feed` is only reported alongside an availability: it names the feed the + // check that FOUND the update used, and there is nothing to name before one. + ok(before.feed === undefined, 'no feed is named before an update is found') + ok( + ctl.feeds.at(-1)?.provider === 'generic', + 'a packaged launch configures the domestic feed first (the safe default)' + ) + + live.emit('checking-for-update') + { + const s = await call(Ipc.UPDATE_STATE_GET) + ok(s.status === 'checking', "'checking-for-update' sets the checking state") + ok(s.error === undefined, 'the previous error is cleared when a new check starts') + } + + live.emit('update-available', { version: '9.9.9' }) + { + const s = await call(Ipc.UPDATE_STATE_GET) + ok(s.status === 'available' && s.version === '9.9.9', `'update-available' carries the version (got ${JSON.stringify(s)})`) + ok(s.feed === 'gitea', "the state names the feed the check actually used") + ok(s.percent === undefined, 'the percent is cleared for a fresh availability') + } + + live.emit('download-progress', { percent: 42.7 }) + ok((await call(Ipc.UPDATE_STATE_GET)).percent === 43, 'the download percent is rounded for the UI') + + live.emit('update-downloaded', { version: '9.9.9' }) + { + const s = await call(Ipc.UPDATE_STATE_GET) + ok(s.status === 'downloaded', "'update-downloaded' sets the downloaded state") + ok(s.percent === undefined, 'the percent is cleared once downloaded') + } + + live.emit('update-not-available') + ok((await call(Ipc.UPDATE_STATE_GET)).status === 'latest', "'update-not-available' sets the latest state") + + live.emit('error', new Error('boom')) + { + const s = await call(Ipc.UPDATE_STATE_GET) + ok(s.status === 'error' && s.error === 'boom', "the updater's 'error' event reaches the state") + } + + // A non-Error rejection still has to produce a readable message. + live.emit('error', 'a string reason') + ok((await call(Ipc.UPDATE_STATE_GET)).error === 'a string reason', 'a non-Error error value is stringified') + + rmSync(userData, { recursive: true, force: true }) + stub.__setUserData('') +} + +// ---- 8. changelog sources --------------------------------------------------- +console.log('changelog: the update channel wins, then the releases APIs') +{ + reset() + stub.__setPackaged(true) + fetchImpl = (url) => { + const u = String(url) + if (u.endsWith('release-notes.md')) return Promise.resolve(okResponse('# v9 notes\nline two')) + if (u.endsWith('latest.yml')) return Promise.resolve(okResponse("version: 9.9.9\nreleaseDate: '2026-01-02T03:04:05Z'\n")) + return Promise.resolve(errResponse(404)) + } + const notes = await call(Ipc.UPDATE_CHANGELOG) + ok(Array.isArray(notes) && notes.length === 1, `the channel's release-notes.md is used (${notes.length} entry)`) + ok(notes[0].version === 'v9.9.9', `the version comes from latest.yml (got '${notes[0].version}')`) + ok(notes[0].date === '2026-01-02', `the date is truncated to the day (got '${notes[0].date}')`) + ok(notes[0].body.includes('# v9 notes'), 'the body is the release-notes.md text') +} + +console.log('changelog: an empty/stalled channel falls through to the releases API') +{ + reset() + stub.__setPackaged(true) + const releases = JSON.stringify([ + { tag_name: 'v2.0.0', published_at: '2026-02-03T00:00:00Z', body: 'notes 2' }, + { tag_name: 'v1.9.0', published_at: '2026-01-04T00:00:00Z', body: 'notes 1' }, + { name: 'named-entry', published_at: '2026-01-05T00:00:00Z', body: 'named' }, + { published_at: '2026-01-06T00:00:00Z', body: 'dropped: no version at all' } + ]) + fetchImpl = (url) => { + const u = String(url) + if (u.endsWith('release-notes.md')) return Promise.resolve(okResponse('')) // empty -> fall through + if (u.endsWith('latest.yml')) return Promise.resolve(errResponse(404)) + if (u.includes('git.codingplan.site') && u.includes('releases')) return Promise.resolve(errResponse(404)) + if (u.includes('api.github.com') && u.includes('releases')) return Promise.resolve(okResponse(releases)) + return Promise.resolve(errResponse(404)) + } + const notes = await call(Ipc.UPDATE_CHANGELOG) + ok(notes.length === 3, `entries without any version are dropped (${notes.length} kept)`) + ok(notes[0].version === 'v2.0.0' && notes[0].date === '2026-02-03', 'the newest release is first') + ok(notes.some((n) => n.version === 'named-entry'), 'a release with only a name falls back to it') + ok(notes.every((n) => n.version !== ''), 'every returned entry has a version') +} + +console.log('changelog: every source failing yields an empty list, not a rejection') +{ + reset() + stub.__setPackaged(true) + fetchImpl = () => Promise.reject(new Error('offline')) + const notes = await call(Ipc.UPDATE_CHANGELOG) + ok(Array.isArray(notes) && notes.length === 0, 'the About tab gets [] instead of a thrown error') +} + +console.log('changelog: a stalled channel is abandoned at the fetch budget') +{ + reset() + stub.__setPackaged(true) + let aborted = 0 + fetchImpl = (url, init) => { + aborted++ + return neverSettles(url, init) + } + const started = Date.now() + const notes = await call(Ipc.UPDATE_CHANGELOG) + const elapsed = Date.now() - started + ok(Array.isArray(notes) && notes.length === 0, 'the stalled channel produced no notes') + ok(elapsed < 3000, `it gave up at the budget (${elapsed}ms, ${aborted} fetch attempts)`) +} + +// ---- 9. install ------------------------------------------------------------- +console.log('install') +{ + reset() + stub.__setPackaged(true) + await call(Ipc.UPDATE_INSTALL) + ok(ctl.quitAndInstallCalls.length === 1, 'a packaged build calls quitAndInstall once') + ok(ctl.quitAndInstallCalls[0][1] === true, 'and asks for a relaunch (isForceRunAfter)') + + reset() + stub.__setPackaged(false) + await call(Ipc.UPDATE_INSTALL) + ok(ctl.quitAndInstallCalls.length === 0, 'a dev build does not call quitAndInstall') +} + +stub.__setPackaged(false) +clearInterval(keepAlive) + +if (failed > 0) { + console.error(`\n[updater-fallback] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log(`\n[updater-fallback] ALL CHECKS PASSED (${passed} assertions)`)