test(main): cover updater fallback, ipc sender guard, log sanitizer, sftp timeouts

- 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.
This commit is contained in:
Bill committed 2026-10-07 22:57:16 +08:00
1 parent 5ff311ea0f
commit a0be827650
21 files changed
+2366 -170

No files matched your search

+6
View File
@@ -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
+1 -1
View File
@@ -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"
+6 -26
View File
@@ -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
}
+3 -1
View File
@@ -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
}
+87 -18
View File
@@ -33,18 +33,87 @@ export function registerSftpClientProvider(provider: (id: string) => Client | un
}
function p<T>(fn: (cb: (err: Error | null, res: T) => void) => void): Promise<T> {
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<void> {
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<T>(fn: (cb: (err: Error | null, res: T) => void) => void): Promise<T> {
return bounded(rawP(fn), timeouts.transfer)
}
function pVoidTransfer(fn: (cb: (err: Error | null) => void) => void): Promise<void> {
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<T>(fn: (cb: (err: Error | null, res: T) => void) => void): Promise<T> {
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<void> {
function rawVoid(fn: (cb: (err: Error | null) => void) => void): Promise<void> {
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<T>(promise: Promise<T>, ms: number): Promise<T> {
// 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<never>((_, 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<StatLike> {
@@ -79,9 +148,6 @@ export function formatMode(mode: number): string {
*/
const sftpCache = new Map<string, SFTPWrapper>()
/** 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<SFTPWrapper> {
// 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<string[]> {
return p<Array<string | { filename: string }>>(cb => sftp.readdir(dir, cb)).then(raw =>
raw.map(n => (typeof n === 'string' ? n : n.filename))
)
}
export function listRemote(sessionId: string, dir: string): Promise<SftpEntry[]> {
return withSftp(sessionId, async sftp => {
const raw = await new Promise<Array<string | { filename: string }>>((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<Array<string | { filename: string }>>((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<void>((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
+29 -10
View File
@@ -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<typeof budgets>): 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<void> {
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<void> {
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<Response> {
// 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<boolean> {
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<ReleaseNote[]> {
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<{
+5 -21
View File
@@ -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<HTMLInputElement>): 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 {
+1
View File
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
'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.',
+1
View File
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
'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}。「ファイルを選択 / ディレクトリを選択」ダイアログで選び直してください。',
+1
View File
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
'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}。请通过「选择文件 / 选择目录」对话框重新选择。',
+1
View File
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
'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}。請改用「選擇檔案 / 選擇目錄」對話框重新選擇。',
+61
View File
@@ -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])
}
+29 -2
View File
@@ -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}`)
+73 -8
View File
@@ -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:<channel>` -> listener (send). */
get __handlers() {
return state.handlers
},
get __sessions() {
return state.sessions
}
}
+73
View File
@@ -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 }
+226
View File
@@ -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)`)
+277
View File
@@ -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)`)
+119
View File
@@ -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)`)
+456
View File
@@ -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)
+374 -83
View File
@@ -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
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')
// ---- 3. deps ---------------------------------------------------------------
/** Records everything the service asks the outside world for. */
const makeDeps = (over = {}) => {
const seen = {
prompts: [],
accepted: [],
touched: [],
broadcasts: []
}
if (output.includes('hello loopback') && echoed === false) {
echoed = true
console.log('[loopback] echo received')
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
}
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')
// ---- 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()
})
}
+537
View File
@@ -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)`)