diff --git a/launcher/src/core/config.ts b/launcher/src/core/config.ts index dccdcc7..ce746b0 100644 --- a/launcher/src/core/config.ts +++ b/launcher/src/core/config.ts @@ -77,6 +77,182 @@ const FOREIGN_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//; /** http / https 协议头。 */ const HTTP_SCHEME_PATTERN = /^https?:\/\//i; +/** + * 服务端地址长度上限。 + * + * 抽成常量是为了让 `normalizeBaseUrl()` 与 `describeBaseUrlProblem()` 用同一个数: + * 两处各写一遍 `512` 迟早会分叉,表现成「提示说没超长、保存却失败」这种最难查的错位。 + */ +const MAX_BASE_URL_LENGTH = 512; + +/** 全角 ASCII 可见字符区间起点 U+FF01。 */ +const FULLWIDTH_ASCII_START = 0xff01; + +/** 全角 ASCII 可见字符区间终点 U+FF5E。 */ +const FULLWIDTH_ASCII_END = 0xff5e; + +/** 全角空格 U+3000(与半角空的码位差不是 0xFEE0,必须单独映射)。 */ +const IDEOGRAPHIC_SPACE = 0x3000; + +/** 全角 ↔ 半角 的码位差(U+FF01 → U+0021 等)。 */ +const FULLWIDTH_OFFSET = 0xfee0; + +/** + * 零宽 / 不可见字符:清洗掉,**而不是**拒绝。 + * + * 为什么必须单独洗(本次线上故障的踩坑点): + * `String.prototype.trim()` 按 ECMAScript 的 WhiteSpace + LineTerminator 定义裁剪, + * 而 U+200B(ZWSP)**不在**这两个集合里 —— 也就是说 `'http://h\u200B'.trim()` + * 原样返回带零宽空格的串,`new URL()` 解析失败,用户拿到的只是一句 + * 「参数格式不合法」,而他看到的地址和正确地址**长得一模一样**,完全无法自查。 + * 同理:U+200C / U+200D(ZWNJ / ZWJ)、U+FEFF(BOM,从网页/文档复制时最常见)、 + * U+00A0(不间断空格,从 Word/微信复制时最常见)。 + * + * 覆盖 U+200B–U+200F 与 U+2060–U+2064 是为了把方向标记(LRM/RLM)这类 + * 「肉眼完全看不见、但会让 URL 解析失败」的字符一并处理掉。 + */ +const INVISIBLE_CHAR_PATTERN = /[\u00a0\u200b-\u200f\u2060-\u2064\ufeff]/g; + +/** + * 清洗后**依然**不可能是 URL 合法字符的字符(空白 / 控制 / 格式 / 私用区 / 符号)。 + * + * 只在给出「具体是哪一位、什么字符」时使用:命中即报位置,比一句 + * 「地址格式无法解析」有用得多(用户能直接定位到自己多敲了什么)。 + * 注意这里**不含**各语种字母与数字(`\p{L}` / `\p{N}`),所以中文域名不会被误伤。 + */ +const ILLEGAL_URL_CHAR_PATTERN = /[\s\p{Cc}\p{Cf}\p{Co}\p{So}]/u; + +/** + * 解析失败后用于「找最可疑字符」的宽松白名单:可打印 ASCII + 各语种字母数字与组合标记。 + * + * 只在 `new URL()` 已经失败之后才用,因此不会误伤 `http://例子。测试` 这类 + * 解析器自己认的 IDN 写法(它能解析成功,根本走不到这一步)。 + */ +const NON_URL_CHAR_PATTERN = /[^\x21-\x7e\p{L}\p{N}\p{M}]/u; + +/** 命中「可疑字符」时的定位结果。 */ +interface CharHit { + /** 1 起的码点序号(用户自己数得出来的位置) */ + readonly index: number; + /** 命中的字符 */ + readonly char: string; +} + +/** + * 在字符串里找第一个命中 `pattern` 的字符,并给出**1 起的码点序号**。 + * + * 按码点迭代(`for…of`)而不是按下标,是为了让 emoji 之类的代理对只占一位 —— + * 否则报出来的位置会比用户数到的偏后。 + * @param text 待扫描文本 + * @param pattern 非全局正则(不带 `g`,避免 `lastIndex` 残留导致漏判) + * @returns 命中信息;没命中返回 null + */ +function findSuspiciousChar(text: string, pattern: RegExp): CharHit | null { + let index = 0; + for (const char of text) { + index += 1; + if (pattern.test(char)) return { index, char }; + } + return null; +} + +/** + * 把字符渲染成「用户能看懂 + 能复制去搜」的形态。 + * 例:`「空格」(U+0020)` / `「:」(U+FF1A)` + * @param char 单个字符 + * @returns 描述文本 + */ +function describeChar(char: string): string { + const codePoint = char.codePointAt(0) ?? 0; + const hex = codePoint.toString(16).toUpperCase().padStart(4, '0'); + // 空格直接打在引号里看不见,换成中文名;其余字符原样展示(用户能认出来自己敲了什么) + const label = char === ' ' ? '空格' : char; + return `「${label}」(U+${hex})`; +} + +/** + * 清洗服务端地址输入:全角转半角 + 去掉零宽/不可见字符 + trim。 + * + * 三条设计约束(都是硬要求): + * 1. **幂等**:`sanitize(sanitize(x)) === sanitize(x)`。全角转完就是 ASCII、不可见字符转完就没了, + * 所以二次调用必然无变化 —— 这条保证了「保存一次 → 再读出来归一化」不会反复漂移; + * 2. **不误伤合法输入**:只做「全角→半角」映射与「删不可见字符」, + * 绝不剥离非 ASCII 的其他字符,因此中文域名(IDN)、路径里的中文、`%` 编码全部原样保留; + * 3. **不放过真正的控制字符**:U+0000–U+001F / U+007F 能藏恶意载荷(比如把 + * `\r\n` 塞进地址),必须继续由 `normalizeBaseUrl()` **拒绝**,不能洗白。 + * @param raw 用户原始输入 + * @returns 清洗后的地址(可能为空串) + */ +export function sanitizeBaseUrlInput(raw: string): string { + let halfwidth = ''; + for (const char of raw) { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint >= FULLWIDTH_ASCII_START && codePoint <= FULLWIDTH_ASCII_END) { + // 全角可见字符 → 对应半角(全角冒号 U+FF1A 就是最常见的那个坑) + halfwidth += String.fromCharCode(codePoint - FULLWIDTH_OFFSET); + } else if (codePoint === IDEOGRAPHIC_SPACE) { + // 全角空格 → 半角空格(交给后面的 trim 处理首尾) + halfwidth += ' '; + } else { + halfwidth += char; + } + } + return halfwidth.replace(INVISIBLE_CHAR_PATTERN, '').trim(); +} + +/** + * 给出服务端地址不合格的**具体**原因(面向用户,用于 `INVALID_ARGUMENT` 的 details)。 + * + * 为什么需要它:`ERROR_INFO.INVALID_ARGUMENT` 只有一句「参数格式不合法|按提示格式输入」, + * 而地址出错时用户看到的地址往往和正确地址**完全一样**(多了一个全角冒号或零宽空格), + * 现场表现为「连续点注册 5 次都失败、每次都只有同一句看不懂的提示」。 + * 这里按「能指出位置就指出位置」的原则分级给原因,风格对齐服务端 `describePasswordProblem`。 + * + * 判定顺序与 `normalizeBaseUrl()` **严格一致**(同一个输入不能出现「提示说没问题、保存却失败」): + * 空 → 超长 → 控制字符 → 清洗 → 清洗后为空 → 非法字符 → 协议 → 内嵌凭据 → 解析。 + * + * 位置说明:控制字符按**用户原始输入**的码点序号报(用户可对照自己敲的内容数), + * 其余分支按**清洗后**的地址报(零宽字符已被删掉,不会占位)。 + * @param raw 用户原始输入 + * @returns 具体原因;`null` 表示地址合格(清洗后可直接保存) + */ +export function describeBaseUrlProblem(raw: unknown): string | null { + if (typeof raw !== 'string' || raw.length === 0) return '地址不能为空'; + if (raw.length > MAX_BASE_URL_LENGTH) return `地址过长(${raw.length} 字符,上限 ${MAX_BASE_URL_LENGTH})`; + + // 真正的控制字符:必须拒绝(不能像零宽字符那样洗掉 —— 它能藏注入载荷) + const control = findSuspiciousChar(raw, CONTROL_CHAR_PATTERN); + if (control) return `地址含不可见控制字符(第 ${control.index} 位),请重新手输`; + + const cleaned = sanitizeBaseUrlInput(raw); + if (cleaned.length === 0) return '地址清洗后为空(只含不可见字符),请重新手输'; + if (cleaned.length > MAX_BASE_URL_LENGTH) return `地址过长(${cleaned.length} 字符,上限 ${MAX_BASE_URL_LENGTH})`; + + // 空白(含中间空格)/ 残留不可见字符 / emoji / 私用区:一定是敲错了,直接指位置 + const illegal = findSuspiciousChar(cleaned, ILLEGAL_URL_CHAR_PATTERN); + if (illegal) return `地址第 ${illegal.index} 位是非法字符${describeChar(illegal.char)},请重新手输`; + + // 协议判定与 normalizeBaseUrl 同一套规则:别的 `xxx://` 一律拒绝,不「补协议」。 + // 注意这里**不**预先裁掉末尾斜杠(理由见 normalizeBaseUrl 里的注释:`http://` 会被裁成 `http:`) + if (!HTTP_SCHEME_PATTERN.test(cleaned) && FOREIGN_SCHEME_PATTERN.test(cleaned)) { + return '只支持 http:// 或 https://'; + } + const candidate = HTTP_SCHEME_PATTERN.test(cleaned) ? cleaned : `http://${cleaned}`; + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + // 解析失败:再放宽一点找「最可疑的字符」(如中文句号「。」占位成了端口的一部分) + const odd = findSuspiciousChar(cleaned, NON_URL_CHAR_PATTERN); + if (odd) return `地址第 ${odd.index} 位是非法字符${describeChar(odd.char)},请重新手输`; + return '地址格式无法解析,正确示例:http://127.0.0.1:3100'; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '只支持 http:// 或 https://'; + if (parsed.username.length > 0 || parsed.password.length > 0) return '地址不能包含用户名或密码'; + return null; +} + /** * 取(必要时生成并持久化)本机稳定 deviceId。 * @@ -123,7 +299,13 @@ export function regenerateDeviceId( } /** - * 归一化服务端地址:trim、去掉末尾 `/`、只允许 http/https。 + * 归一化服务端地址:清洗(全角→半角、去零宽)→ trim、去掉末尾 `/`、只允许 http/https。 + * + * 第一步的清洗是**自动修复**而不是校验失败:用户从微信/文档/浏览器地址栏复制地址时, + * 很容易带上全角冒号(U+FF1A)或零宽空格(U+200B)。这类输入视觉上与正确地址无差别, + * 若直接判非法,用户会陷入「照抄一遍还是失败」的死循环(现场连续 5 次)。因此这里先 + * 无声地把它们改成正确形态,**只有清洗后仍不合法才返回空串**(失败原因见 + * `describeBaseUrlProblem()`)。 * * 协议判定顺序很重要(这是需求池 R-P2-11 里最容易写错的一处): * 1. 已经写了 `http(s)://` → 直接用; @@ -136,11 +318,12 @@ export function regenerateDeviceId( */ export function normalizeBaseUrl(raw: unknown): string { if (typeof raw !== 'string') return ''; - const trimmed = raw.trim(); - if (trimmed.length === 0 || trimmed.length > 512) return ''; - if (CONTROL_CHAR_PATTERN.test(trimmed)) return ''; + // 先清洗(全角→半角、删零宽/不可见字符、trim),再走原有的白名单校验 + const cleaned = sanitizeBaseUrlInput(raw); + if (cleaned.length === 0 || cleaned.length > MAX_BASE_URL_LENGTH) return ''; + if (CONTROL_CHAR_PATTERN.test(cleaned)) return ''; - let candidate = trimmed.replace(/\/+$/, ''); + let candidate = cleaned; if (!HTTP_SCHEME_PATTERN.test(candidate)) { // 带 `//` 的非 http(s) 协议:拒绝(见上方第 2 条) if (FOREIGN_SCHEME_PATTERN.test(candidate)) return ''; @@ -151,6 +334,10 @@ export function normalizeBaseUrl(raw: unknown): string { const parsed = new URL(candidate); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; if (parsed.username.length > 0 || parsed.password.length > 0) return ''; + // 末尾斜杠在**解析后**统一裁掉(路径与 origin 两处都裁)。 + // 不能提前用 `replace(/\/+$/, '')` 裁:`http://` 会被裁成 `http:`,从而丢掉 + // 「这是个协议头而不是主机名」的事实,再被补成 `http://http:` —— + // 解析成功、origin 变成 `http://http`,一个用户从没输入过的主机名。 return parsed.origin + (parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, '')); } catch { return ''; diff --git a/launcher/src/core/errors.ts b/launcher/src/core/errors.ts index 14e1928..7e7c1e2 100644 --- a/launcher/src/core/errors.ts +++ b/launcher/src/core/errors.ts @@ -120,6 +120,51 @@ export interface UserErrorInfo { readonly message: string; /** 处置建议 */ readonly hint: string; + /** + * 具体原因(可选):比 `message` 更细,能指出「该改哪一处」。 + * + * 由主进程在 `AppError.details.reason` 里给出(如 + * 「地址第 3 位是非法字符「:」(U+FF1A),请重新手输」)。 + * 是否下发给 UI 由 `extractUserReason()` 把关 —— 只有**人话**才下发, + * 机器码(`timeout` / `fetch_failed:TypeError`)一律丢弃。 + */ + readonly reason?: string; +} + +/** `reason` 的长度上限(防止把一大段内部文本当原因塞给 UI)。 */ +const USER_REASON_MAX_LENGTH = 200; + +/** + * 判断一段文本是否「人话」(含汉字)。 + * + * 这是 `details.reason` 能否展示给用户的唯一判据,起因是两种 reason 在同一个字段里混用: + * · 面向用户的中文说明(`'需为白名单内分辨率,例如 1920x1080'`)→ 应当展示; + * · 机器码 / 原始异常名(`'timeout'`、`'fetch_failed:TypeError'`、`'skipped_self'`) + * → **绝不能**展示(架构 §9.2 第 1 条:禁止回显内部细节)。 + * 中文说明必然含汉字,机器码必然不含,所以「是否含汉字」就是这条边界的可靠判据, + * 且新增中文 reason 时无需回来登记白名单。 + */ +const REASON_LOOKS_HUMAN_PATTERN = /[\u4e00-\u9fff]/; + +/** + * 从任意错误里提取「可展示的具体原因」。 + * + * 只认 `error.details.reason` 这一处(主进程抛 `AppError` 时的约定位置), + * 且必须同时满足:是字符串、非空、不过长、含汉字。任一不满足即返回 undefined, + * 调用方据此退回「只说三段式」的旧行为。 + * @param error 任意错误(AppError / 响应体 / 裸值) + * @returns 可展示的原因;不可展示时返回 undefined + */ +export function extractUserReason(error: unknown): string | undefined { + if (!error || typeof error !== 'object') return undefined; + const details = (error as { details?: unknown }).details; + if (!details || typeof details !== 'object') return undefined; + const reason = (details as { reason?: unknown }).reason; + if (typeof reason !== 'string') return undefined; + const trimmed = reason.trim(); + if (trimmed.length === 0 || trimmed.length > USER_REASON_MAX_LENGTH) return undefined; + if (!REASON_LOOKS_HUMAN_PATTERN.test(trimmed)) return undefined; + return trimmed; } /** 全部错误码数组(顺序与映射表一致)。 */ @@ -208,28 +253,34 @@ export function extractErrorCode(value: unknown): ErrorCode { } /** - * 把任意错误转成三段式用户可读信息。 + * 把任意错误转成三段式用户可读信息(有可展示的具体原因时一并带上)。 * * **注意**:绝不返回底层 `e.message`(可能含内部路径或 `/api/auth/login -> 401` - * 这类无意义内容),只返回映射表里的固定文案。 + * 这类无意义内容),只返回映射表里的固定文案;`reason` 也只放行含汉字的人话 + * (见 `extractUserReason`)。 * @param error 任意错误(Error / 响应体 / 错误码 / 裸值) - * @returns 三段式错误信息 + * @returns 三段式错误信息(可能带 reason) */ export function toUserError(error: unknown): UserErrorInfo { const code = extractErrorCode(error); const info = ERROR_INFO[code]; - return { code, message: info.message, hint: info.hint }; + const reason = extractUserReason(error); + // 没有可展示原因时**不**带这个字段:保持老形状,避免调用方按 key 存在与否做判断时误判 + if (reason === undefined) return { code, message: info.message, hint: info.hint }; + return { code, message: info.message, hint: info.hint, reason }; } /** * 把错误格式化为单行字符串(便于状态栏/Toast 直接展示)。 * 例:`尝试过于频繁,请 15 分钟后再试(错误码 RATE_LIMITED)|等待后重试` + * 有具体原因时插在文案后面:`参数格式不合法:地址不能为空(错误码 INVALID_ARGUMENT)|按提示格式输入` * @param error 任意错误 * @returns 三段式单行文本 */ export function formatUserError(error: unknown): string { const userError = toUserError(error); - return `${userError.message}(错误码 ${userError.code})|${userError.hint}`; + const message = userError.reason ? `${userError.message}:${userError.reason}` : userError.message; + return `${message}(错误码 ${userError.code})|${userError.hint}`; } /** @@ -248,6 +299,7 @@ export default { isAppError, isErrorCode, extractErrorCode, + extractUserReason, toErrorCode, errorInfoOf, toUserError, diff --git a/launcher/src/main.ts b/launcher/src/main.ts index 13e9f15..b9aceda 100644 --- a/launcher/src/main.ts +++ b/launcher/src/main.ts @@ -30,6 +30,7 @@ import { getPlatformAdapter, detectPlatform } from './platform/index'; import { createLogger } from './core/logger'; import { DATA_DIR_NAME, defaultPaths } from './core/paths'; import { + describeBaseUrlProblem, ensureDeviceId, regenerateDeviceId as regenerateDeviceIdInSettings, resolveBaseUrl, @@ -727,7 +728,14 @@ function registerIpc(): void { wrapIpc('config:getServer', () => ({ serverUrl: getServerUrl(), effective: resolveBaseUrl() })); wrapIpc('config:setServer', (url: unknown) => { const normalized = setBaseUrl(url); - if (!normalized) throw new AppError('INVALID_ARGUMENT', { field: 'serverUrl' }); + if (!normalized) { + // 失败必须带上**具体**原因(哪一位是什么字符),否则用户看到的地址和正确地址 + // 长得一模一样,只得到一句「参数格式不合法」—— 现场就是这样连续失败 5 次。 + // 注意:清洗成功的输入走上面那条分支(静默自动修复),不会来这里。 + const reason = describeBaseUrlProblem(url) ?? '地址无法识别,正确示例:http://127.0.0.1:3100'; + logger.warn('服务端地址未通过校验', { field: 'serverUrl', reason }); + throw new AppError('INVALID_ARGUMENT', { field: 'serverUrl', reason }); + } logger.info('服务端地址已更新', { baseUrl: normalized }); return { serverUrl: normalized }; }); diff --git a/launcher/src/renderer.js b/launcher/src/renderer.js index 371875d..ad6af7f 100644 --- a/launcher/src/renderer.js +++ b/launcher/src/renderer.js @@ -53,15 +53,21 @@ function toast(text, kind) { /** * 把主进程返回的 `{ok:false, error}` 渲染成三段式文案。 - * @param {unknown} error 错误体({code,message,hint}) - * @returns {string} 「人话(错误码 X)|处置建议」 + * + * 有具体原因(`reason`)时插在文案后面:地址类错误只有一句「参数格式不合法」时 + * 用户完全无法自查(他看到的地址和正确地址一模一样),所以 `reason` 是这类错误 + * 唯一有用的信息,必须展示出来。 + * @param {unknown} error 错误体({code,message,hint,reason?}) + * @returns {string} 「人话:具体原因(错误码 X)|处置建议」 */ function formatError(error) { const payload = error && typeof error === 'object' ? error : {}; const message = typeof payload.message === 'string' && payload.message ? payload.message : '发生了未预期的错误'; const code = typeof payload.code === 'string' && payload.code ? payload.code : 'UNKNOWN'; const hint = typeof payload.hint === 'string' ? payload.hint : '复制错误码到群里反馈,或导出日志排查'; - return `${message}(错误码 ${code})|${hint}`; + const reason = typeof payload.reason === 'string' ? payload.reason : ''; + const detail = reason ? `${message}:${reason}` : message; + return `${detail}(错误码 ${code})|${hint}`; } /** @@ -81,17 +87,22 @@ function unwrap(result) { /** * 把本地异常还原成三段式错误体(不读 `message`)。 + * + * `reason` 只在主进程给出「人话级具体原因」时存在(机器码已被主进程过滤掉), + * 这里原样搬运,不含任何拼接。 * @param {unknown} error 本地异常 - * @returns {{code: string, message: string, hint: string}} + * @returns {{code: string, message: string, hint: string, reason?: string}} */ function parseLocalError(error) { const payload = error && typeof error === 'object' && error.userError ? error.userError : null; if (payload && typeof payload === 'object') { - return { + const parsed = { code: typeof payload.code === 'string' && payload.code ? payload.code : 'UNKNOWN', message: typeof payload.message === 'string' && payload.message ? payload.message : '操作未完成', hint: typeof payload.hint === 'string' ? payload.hint : '复制错误码到群里反馈,或导出日志排查', }; + if (typeof payload.reason === 'string' && payload.reason) parsed.reason = payload.reason; + return parsed; } return { code: 'UNKNOWN', @@ -248,11 +259,27 @@ function renderAgentStatus(status) { } } +/** + * 保存服务器地址(登录/注册前的前置步骤)。 + * + * 关键行为:地址这一关没过时**必须单独报「服务器地址保存失败」并中止后续请求**, + * 不能直接把错误抛给「注册失败」前缀 —— 地址不合法时请求一个字节都没发出去, + * 报「注册失败」会让用户以为是账号/密码问题而反复重试(现场连续失败 5 次就是这么来的)。 + * 成功后把清洗过的地址回填输入框:用户能直接看到「被改成了什么」(全角冒号→半角等)。 + * @returns {Promise} true 表示地址已可用 + */ async function applyServer() { - const url = document.getElementById('serverUrl').value.trim(); - if (!url) return; - const data = unwrap(await api.setServer(url)); - document.getElementById('serverUrl').value = data.serverUrl; + const input = document.getElementById('serverUrl'); + const url = input.value.trim(); + if (!url) return true; + try { + const data = unwrap(await api.setServer(url)); + input.value = data.serverUrl; + return true; + } catch (error) { + showError(loginStatus, parseLocalError(error), '服务器地址保存失败:'); + return false; + } } document.getElementById('loginBtn').addEventListener('click', async () => { @@ -264,7 +291,7 @@ document.getElementById('loginBtn').addEventListener('click', async () => { } loginStatus.textContent = '登录中…'; try { - await applyServer(); + if (!(await applyServer())) return; const status = unwrap(await api.login(username, password)); loginView.classList.add('hidden'); mainView.classList.remove('hidden'); @@ -287,7 +314,7 @@ document.getElementById('registerBtn').addEventListener('click', async () => { } loginStatus.textContent = '注册中…'; try { - await applyServer(); + if (!(await applyServer())) return; unwrap(await api.register(username, password)); loginStatus.textContent = '注册成功,请登录'; toast('注册成功'); diff --git a/launcher/test/config.test.ts b/launcher/test/config.test.ts index 29fc655..37e9c3a 100644 --- a/launcher/test/config.test.ts +++ b/launcher/test/config.test.ts @@ -6,12 +6,14 @@ import { describe, it, expect } from 'vitest'; import { DEFAULT_BASE_URL, createConfigStore, + describeBaseUrlProblem, ensureDeviceId, getConfigSnapshot, isDeviceId, normalizeBaseUrl, regenerateDeviceId, resolveBaseUrl, + sanitizeBaseUrlInput, setBaseUrl, } from '../src/core/config'; import { createSettingsIo } from '../src/settings'; @@ -195,3 +197,165 @@ describe('regenerateDeviceId(与 ensureDeviceId 职责分离)', () => { expect(store.load().deviceId).toBe(next); }); }); + +// ============================================================ 地址清洗(全角 / 零宽字符) +// +// 现场故障(2026-10-27):用户在登录页填的地址里混进了全角冒号,`config:setServer` +// 连续失败 5 次,界面只有「参数格式不合法」,用户无法自查 —— 他看到的地址和正确 +// 地址长得一模一样。下面这组用例锁死「自动清洗 + 具体原因」两条修复路径。 + +describe('sanitizeBaseUrlInput / normalizeBaseUrl 自动清洗(现场缺陷)', () => { + it('全角冒号 / 全角斜杠 / 全角字母数字 → 清洗成半角后**成功**', () => { + expect(normalizeBaseUrl('http://127.0.0.1:3100')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('http://127.0.0.1:3100')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('http://127.0.0.1:3100')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('http://127.0.0.1:3100')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('http://192.168.1.10:8787/smb')).toBe('http://192.168.1.10:8787/smb'); + }); + + it('零宽空格 / ZWNJ / ZWJ / BOM / 不间断空格 / 全角空格 → 清洗后**成功**(trim 去不掉它们)', () => { + // 这一条是本次踩坑点的直接回归:U+200B 不在 ECMAScript 的 WhiteSpace 集合里, + // `String.trim()` 对它无效,只有显式删除才能救回来。 + expect(normalizeBaseUrl('http://127.0.0.1:3100\u200B')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('http://127.0.0.1:3100\u200C\u200D')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('\uFEFFhttp://127.0.0.1:3100')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('http://127.0.0.1\u00A0:3100')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('http://127.0.0.1:3100\u3000')).toBe('http://127.0.0.1:3100'); + expect(normalizeBaseUrl('\u200B \uFEFF http://127.0.0.1:3100 \u200B')).toBe('http://127.0.0.1:3100'); + }); + + it('清洗是幂等的(保存一次、再读出来归一化不会漂移)', () => { + const inputs = [ + 'http://127.0.0.1:3100', + '\uFEFFhttps://api.example.com:8443/base\u200B', + '192.168.1.10:8787', + 'http://127.0.0.1:3100', + 'https://api.example.com:8443/base', + ]; + for (const input of inputs) { + const once = normalizeBaseUrl(input); + expect(once, `输入 ${JSON.stringify(input)} 应可清洗成功`).not.toBe(''); + expect(normalizeBaseUrl(once), `二次归一化必须不变:${once}`).toBe(once); + expect(sanitizeBaseUrlInput(sanitizeBaseUrlInput(input))).toBe(sanitizeBaseUrlInput(input)); + } + }); + + it('合法地址一个字符都不改(端口 / 路径 / https)', () => { + const legal = [ + 'http://127.0.0.1:3100', + 'https://api.example.com:8443/base', + 'https://api.example.com', + 'http://192.168.1.10:3100/api/v1', + ]; + for (const ok of legal) { + expect(sanitizeBaseUrlInput(ok), ok).toBe(ok); + expect(normalizeBaseUrl(ok), ok).toBe(ok); + } + }); + + it('中文域名等非 ASCII 合法字符不被误伤(只做全角→半角,不做剥离)', () => { + // 清洗层原样保留;是否转 punycode 由 URL 解析器决定(那是既有行为,不改) + expect(sanitizeBaseUrlInput('http://例子.测试:3100')).toBe('http://例子.测试:3100'); + expect(normalizeBaseUrl('http://例子.测试:3100')).toBe('http://xn--fsqu00a.xn--0zwm56d:3100'); + }); + + it('真正的控制字符必须继续**拒绝**(不能被清洗放过:它能藏注入载荷)', () => { + for (const bad of ['http://h\u0000st', 'http://h\u001Fst', 'http://h\u007Fst']) { + expect(normalizeBaseUrl(bad), JSON.stringify(bad)).toBe(''); + expect(describeBaseUrlProblem(bad), JSON.stringify(bad)).toContain('不可见控制字符'); + } + }); + + it('清洗只作用于地址:setBaseUrl 落盘的是清洗后的值', () => { + const { store, io } = memoryStore({}); + expect(setBaseUrl('http://127.0.0.1:3100\u200B', store)).toBe('http://127.0.0.1:3100'); + expect(io.load().serverUrl).toBe('http://127.0.0.1:3100'); + }); +}); + +describe('describeBaseUrlProblem(分级给出具体原因,用户能自查)', () => { + it('合格地址返回 null(不误报、不影响自动清洗)', () => { + expect(describeBaseUrlProblem('http://127.0.0.1:3100')).toBeNull(); + expect(describeBaseUrlProblem('https://api.example.com:8443/base')).toBeNull(); + // 清洗能救回来的输入也必须是 null:自动修复是静默的,不报错 + expect(describeBaseUrlProblem('http://127.0.0.1:3100')).toBeNull(); + expect(describeBaseUrlProblem('http://127.0.0.1:3100\u200B')).toBeNull(); + }); + + it('空 / 非字符串 / 只含不可见字符', () => { + expect(describeBaseUrlProblem('')).toBe('地址不能为空'); + expect(describeBaseUrlProblem(undefined)).toBe('地址不能为空'); + expect(describeBaseUrlProblem(42)).toBe('地址不能为空'); + expect(describeBaseUrlProblem('\u200B\uFEFF')).toContain('不可见字符'); + }); + + it('超长:原因里带上实际字符数与上限', () => { + const long = `https://h/${'a'.repeat(600)}`; + const reason = describeBaseUrlProblem(long); + expect(reason).toContain('地址过长'); + expect(reason).toContain(`${long.length} 字符`); + expect(reason).toContain('512'); + }); + + it('控制字符:指出第几位', () => { + const reason = describeBaseUrlProblem('http://h\u0000st'); + expect(reason).toContain('不可见控制字符'); + expect(reason).toContain('第 9 位'); + }); + + it('清洗后仍非法:指出位置 + 字符 + 码点(U+XXXX)', () => { + // 中间空格:肉眼几乎看不出来,必须精确到位置 + const space = describeBaseUrlProblem('http://127.0.0.1 :3100'); + expect(space).toContain('第 17 位'); + expect(space).toContain('空格'); + expect(space).toContain('U+0020'); + + // 中文句号混在端口里:解析失败,但能指出是它 + const fullStop = describeBaseUrlProblem('http://127.0.0.1:3100。'); + expect(fullStop).toContain('第 22 位'); + expect(fullStop).toContain('U+3002'); + }); + + it('协议不支持 / 内嵌凭据 / 无法解析', () => { + expect(describeBaseUrlProblem('ftp://host')).toBe('只支持 http:// 或 https://'); + expect(describeBaseUrlProblem('ws://host')).toContain('只支持 http:// 或 https://'); + expect(describeBaseUrlProblem('http://u:p@host')).toBe('地址不能包含用户名或密码'); + expect(describeBaseUrlProblem('http://')).toContain('无法解析'); + expect(describeBaseUrlProblem('http://')).toContain('http://127.0.0.1:3100'); + }); + + it('只有协议头没有主机(http:// / https://)必须拒绝,不得补成 http://http', () => { + // 回归:修复前 `http://` 会被裁成 `http:`,再补成 `http://http:`,解析成 origin + // `http://http` —— 一个用户从没输入过的主机名,比报错更难排查。 + expect(normalizeBaseUrl('http://')).toBe(''); + expect(normalizeBaseUrl('https://')).toBe(''); + expect(normalizeBaseUrl('http:///')).toBe(''); + }); + + it('与 normalizeBaseUrl 口径一致(不能出现「提示说没问题、保存却失败」)', () => { + const corpus = [ + 'http://127.0.0.1:3100', + 'http://127.0.0.1:3100', + 'http://127.0.0.1:3100\u200B', + 'https://api.example.com:8443/base', + 'localhost:8787', + '192.168.1.10:8787', + 'ftp://h', + 'http://u:p@h', + 'http://127.0.0.1 :3100', + 'http://', + '', + '\u200B', + 'http://例子.测试:3100', + ]; + for (const input of corpus) { + const reason = describeBaseUrlProblem(input); + const normalized = normalizeBaseUrl(input); + if (reason === null) { + expect(normalized, `${JSON.stringify(input)} 说没问题却存不下来`).not.toBe(''); + } else { + expect(normalized, `${JSON.stringify(input)} 报了原因却存下来了`).toBe(''); + } + } + }); +}); diff --git a/launcher/test/errors.test.ts b/launcher/test/errors.test.ts index 0fa1e56..ab6b189 100644 --- a/launcher/test/errors.test.ts +++ b/launcher/test/errors.test.ts @@ -135,6 +135,49 @@ describe('toUserError / formatUserError', () => { }); }); +// ============================================================ details.reason 的下发边界 +// +// 背景:`INVALID_ARGUMENT` 原本只有一句「参数格式不合法」,地址出错时用户无法自查 +// (全角冒号 / 零宽空格在屏幕上完全看不见)。修复方式是把主进程算好的**具体原因** +// 经 `details.reason` 下发到 UI —— 但同一个字段里还混着机器码(`timeout`、 +// `fetch_failed:TypeError`),必须只在 reason 是「人话」时才放行。 + +describe('具体原因(details.reason)的下发与过滤', () => { + it('含汉字的人话原因会下发(用户才知道该改哪一位)', () => { + const error = new AppError('INVALID_ARGUMENT', { + field: 'serverUrl', + reason: '地址第 3 位是非法字符「:」(U+FF1A),请重新手输', + }); + const userError = toUserError(error); + expect(userError.reason).toContain('U+FF1A'); + expect(userError.message).toBe(ERROR_INFO.INVALID_ARGUMENT.message); + expect(formatUserError(error)).toContain('地址第 3 位是非法字符'); + }); + + it('机器码 / 英文原始原因**不**下发(§9.2 第 1 条:禁止回显内部细节)', () => { + for (const machineReason of ['timeout', 'fetch_failed:TypeError', 'skipped_self']) { + const userError = toUserError(new AppError('NETWORK_UNREACHABLE', { reason: machineReason })); + expect(userError.reason, machineReason).toBeUndefined(); + expect('reason' in userError, machineReason).toBe(false); + } + }); + + it('没有 details 时保持老三段式,不凭空造 reason', () => { + const userError = toUserError(new AppError('TIMEOUT')); + expect(userError).toEqual({ + code: 'TIMEOUT', + message: ERROR_INFO.TIMEOUT.message, + hint: ERROR_INFO.TIMEOUT.hint, + }); + expect(formatUserError(new AppError('TIMEOUT'))).not.toContain(':'); + }); + + it('过长的 reason 不整段塞给 UI', () => { + const longReason = `地址${'很'.repeat(300)}长`; + expect(toUserError(new AppError('INVALID_ARGUMENT', { reason: longReason })).reason).toBeUndefined(); + }); +}); + describe('logger 脱敏(架构 §9.3 红线)', () => { it('4 类敏感输入一律脱敏', () => { const payload = { diff --git a/launcher/test/ipc.test.ts b/launcher/test/ipc.test.ts index a96dfa6..4e30679 100644 --- a/launcher/test/ipc.test.ts +++ b/launcher/test/ipc.test.ts @@ -97,6 +97,34 @@ describe('makeSafeHandler', () => { dir.rmSync(tmp, { recursive: true, force: true }); }); + it('AppError 的具体原因(details.reason)能穿到 renderer(地址类错误的自查依据)', async () => { + // 端到端复现线上故障路径:config:setServer 抛 INVALID_ARGUMENT + reason, + // renderer 必须能拿到「哪一位、什么字符」,否则用户只能看到「参数格式不合法」。 + const handler = makeSafeHandler(() => { + throw new AppError('INVALID_ARGUMENT', { + field: 'serverUrl', + reason: '地址第 17 位是非法字符「空格」(U+0020),请重新手输', + }); + }); + const result = await handler(); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('INVALID_ARGUMENT'); + expect(result.error.reason).toContain('U+0020'); + expect(result.error.reason).toContain('第 17 位'); + }); + + it('机器码 reason 不会穿到 renderer(过滤发生在边界上)', async () => { + const handler = makeSafeHandler(() => { + throw new AppError('NETWORK_UNREACHABLE', { reason: 'fetch_failed:TypeError' }); + }); + const result = await handler(); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.reason).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain('TypeError'); + }); + it('logger 自身抛错不影响错误响应', async () => { const brokenLogger = { module: 'x',