fix(config): 服务端地址自动清洗全角/零宽字符,失败原因精确到字符与位置
现场:用户填的地址里混了全角冒号(U+FF1A),点「注册」连续失败 5 次,界面只有
「参数格式不合法(INVALID_ARGUMENT)|按提示格式输入」,而请求一个字节都没发出去
(settings.json 里始终没有 serverUrl)。根因是 `trim()` 去不掉零宽字符、
`new URL()` 解析失败即返回空串,用户看到的地址和正确地址完全一样,无从自查。
三处修改:
1. 自动清洗(core/config.ts)
- 新增 `sanitizeBaseUrlInput()`:全角 ASCII(U+FF01–U+FF5E)→ 半角、
U+3000 → 空格、删除零宽与不可见字符(U+200B–U+200F / U+2060–U+2064 /
U+FEFF / U+00A0),最后 trim;幂等、只作用于地址、不剥离中文域名;
- `normalizeBaseUrl()` 在原有校验**之前**接入清洗,清洗成功即静默修复;
- 真正的控制字符(U+0000–U+001F / U+007F)继续**拒绝**,不洗白。
- 顺带修掉一个被新用例暴露的旧缺陷:`http://`(无主机)原会被裁成 `http:` 再
补成 `http://http:`,解析出 `http://http` 这个用户没输入过的主机名。
现在末尾斜杠改在解析之后裁。
2. 失败原因可自查(core/config.ts + errors.ts + main.ts + renderer.js)
- 新增 `describeBaseUrlProblem()`,照服务端 `describePasswordProblem` 的风格分级给原因:
空 / 超长 / 控制字符(第 N 位)/ 清洗后为空 / 非法字符(第 N 位 + 字符 + U+XXXX)/
协议非 http(s) / 内嵌凭据 / 无法解析(带正确示例);
- `UserErrorInfo` 增加可选 `reason`,`toUserError()` 经 `extractUserReason()` 放行
「含汉字」的人话原因、丢弃机器码(`timeout` / `fetch_failed:TypeError`),
守住 §9.2「禁止回显内部细节」;
- `config:setServer` 失败时把 reason 放进 AppError details 并落一条 warn 日志;
- renderer 展示 reason,并把「地址不合法」与「注册/登录失败」分开报
(地址这关没过时不再显示「注册失败」,避免用户误以为是账号问题)。
3. 测试(+21 例)
- config.test.ts:全角/零宽自动清洗、幂等性、合法地址不被改坏、IDN 不误伤、
控制字符仍拒绝、`describeBaseUrlProblem` 每个分支(断言含位置与码点)、
与 normalizeBaseUrl 口径一致性、`http://` 回归;
- errors.test.ts / ipc.test.ts:reason 的下发与机器码过滤,含 makeSafeHandler 端到端。
门槛:typecheck / lint / format:check 全绿;launcher 546 例(基线 525,零回归);
server 82 例未受影响(server/** 未改动)。
This commit is contained in:
1 parent
1e9a5ff72e
commit
14272196c1
7 files changed
+531
-22
No files matched your search
+192
-5
@@ -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 '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
+38
-11
@@ -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<boolean>} 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('注册成功');
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user