update
This commit is contained in:
98
main/ai.js
98
main/ai.js
@@ -60,6 +60,12 @@ const ANTHROPIC_API_VERSION = '2023-06-01';
|
||||
// 不再靠注释提醒同步。
|
||||
const { AI_ERROR } = require('../shared/ai-errors.js');
|
||||
const nodeCrypto = require('node:crypto');
|
||||
// R3 P0-1:aiApiKey 走 safeStorage 加密后,磁盘上是 enc:v1: 包络;config
|
||||
// 加载到 appConfig 时已经 decrypt 一次(见 main/config-store.js loadConfig),
|
||||
// 但这里是直接接 getConfig() 的兜底路径,防御性再 decrypt 一次(passthrough
|
||||
// 明文,包络也安全),避免未来调用方改动时漏掉解密。
|
||||
const secretStore = require('./secret-store.js');
|
||||
const { makeCleanupEpoch } = require('./cleanup-epoch.js');
|
||||
const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED;
|
||||
const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT;
|
||||
const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER;
|
||||
@@ -201,7 +207,23 @@ function extractRawReply(parsed) {
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
// P2-6 (audit):joinUrl / pickOpenAITokenConfig 每次 AI 请求都重复跑
|
||||
// regex + new URL 解析;同 baseURL / 同 model 在一次会话里反复命中,
|
||||
// LRU 缓存结果。maxSize 64 覆盖「用户试过的 URL + model」组合,无界增长免疫。
|
||||
const { createLruCache } = require('./lru-cache.js');
|
||||
const _joinUrlCache = createLruCache({ maxSize: 64 });
|
||||
const _tokenConfigCache = createLruCache({ maxSize: 64 });
|
||||
|
||||
function joinUrl(base, path) {
|
||||
const cacheKey = `${base}|${path}`;
|
||||
const hit = _joinUrlCache.get(cacheKey);
|
||||
if (hit !== undefined) return hit;
|
||||
const joined = _joinUrlUncached(base, path);
|
||||
_joinUrlCache.set(cacheKey, joined);
|
||||
return joined;
|
||||
}
|
||||
|
||||
function _joinUrlUncached(base, path) {
|
||||
// P3-3 fix (audit):拆分 query / fragment 后再拼。
|
||||
// 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages`
|
||||
// (query 后被拼了 path,URL 非法)。query / fragment 也可能在错误回显时
|
||||
@@ -367,6 +389,11 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
const pending = new Map();
|
||||
/** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */
|
||||
const timers = new Map();
|
||||
// audit fix (P1-5):cancelAll 顺序竞争 epoch 闸。cancelAll 三步非原子
|
||||
// (abort → clear → clear),与新 runEdit 同步阶段加 entry 存在竞态窗口。
|
||||
// cleanupEpoch.invalidate() 让所有「更早 snapshot」的 in-flight runEdit
|
||||
// 自行 bail(isCurrent → false),避免「同 requestId 重试找不到 controller」。
|
||||
const cleanupEpoch = makeCleanupEpoch();
|
||||
/** audit fix (C4):请求生命周期登记集合(runEdit 入口 → finally),用于
|
||||
* 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的,
|
||||
* runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */
|
||||
@@ -386,6 +413,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
// audit fix (P1-5):入口先 invalidate,挡掉「cancelAll 期间新 runEdit
|
||||
// 同步阶段已加入 entry」竞态。新 runEdit 入口会 snapshot 当前 epoch,
|
||||
// await 后 isCurrent → false 时 bail,不污染主流程返回。
|
||||
cleanupEpoch.invalidate();
|
||||
for (const controller of pending.values()) controller.abort();
|
||||
pending.clear();
|
||||
// C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转
|
||||
@@ -471,6 +502,15 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
* @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }}
|
||||
*/
|
||||
function pickOpenAITokenConfig(model) {
|
||||
const key = String(model || '');
|
||||
const hit = _tokenConfigCache.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const cfg = _pickOpenAITokenConfigUncached(key);
|
||||
_tokenConfigCache.set(key, cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function _pickOpenAITokenConfigUncached(model) {
|
||||
const m = String(model || '').toLowerCase();
|
||||
// gpt-5 / o-series → 必须用 max_completion_tokens;上限通常 ≥ 128k;不传 temperature
|
||||
if (/^(gpt-5|o1|o3|o4)/.test(m)) {
|
||||
@@ -605,8 +645,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
// 会回显 URL 或 header),统一走 sanitizeDetail 防止泄露到 toast / 日志
|
||||
return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` };
|
||||
}
|
||||
cleanup();
|
||||
|
||||
// 不再此处 cleanup:fetch 已 resolve 但 body 仍在流式读取,取消窗口内
|
||||
// (响应头到达 → body 读完前)cancel 需要 pending 中的 controller 才能 abort。
|
||||
// 清理统一收口到下方 try/finally(覆盖所有 early-return / 错误路径)。
|
||||
try {
|
||||
// audit fix (Round 13 / Sec-H3):3xx 重定向在 manual 模式下表现为 status=0
|
||||
// 且 type='opaqueredirect'。明示用户配置错了 Base URL,不要走"读 body 取错误信息"
|
||||
// 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。
|
||||
@@ -714,6 +756,9 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' };
|
||||
}
|
||||
return { ok: true, json };
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,7 +797,8 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) {
|
||||
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
|
||||
const baseURL = String(config.aiBaseUrl || '').trim();
|
||||
const apiKey = String(config.aiApiKey || '').trim();
|
||||
// R3 P0-1:解密 aiApiKey 包络。secretStore.decrypt 对明文 / 空 / 损坏都有兜底。
|
||||
const apiKey = secretStore.decrypt(config.aiApiKey).trim();
|
||||
const model = String(config.aiModel || '').trim();
|
||||
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
|
||||
|
||||
@@ -830,7 +876,8 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) {
|
||||
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
|
||||
const baseURL = String(config.aiBaseUrl || '').trim();
|
||||
const apiKey = String(config.aiApiKey || '').trim();
|
||||
// R3 P0-1:解密 aiApiKey 包络。secretStore.decrypt 对明文 / 空 / 损坏都有兜底。
|
||||
const apiKey = secretStore.decrypt(config.aiApiKey).trim();
|
||||
const model = String(config.aiModel || '').trim();
|
||||
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
|
||||
|
||||
@@ -974,23 +1021,46 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
cancel(requestId);
|
||||
}
|
||||
inFlightRequestIds.add(requestId);
|
||||
// audit fix (P1-5):拍下 cancelAll epoch 快照。本 runEdit 期间若发生
|
||||
// cancelAll(用户点取消、will-quit 触发),await 之后 isCurrent → false
|
||||
// → 返 ERR_CANCELLED 不应用任何副作用。snapshot 必须在 inFlightRequestIds
|
||||
// 之后但 await 之前 —— 在 inFlightRequestIds.add 之后确保「新请求已
|
||||
// 登记」语义在 cancelAll 之前;invalidate 会同时清 Set 的 entry,但本请求
|
||||
// 已登记的「我自己负责清除」语义不变(finally 仍会 delete,但 Set 已空,
|
||||
// delete 一次 noop)。
|
||||
const myEpoch = cleanupEpoch.snapshot();
|
||||
|
||||
// 注:token 上限不再用入参 maxTokens(已被 pick*TokenConfig 按模型路由取代)
|
||||
const baseArgs = { prompt, content, filename, requestId, timeoutMs };
|
||||
try {
|
||||
let result;
|
||||
if (provider === 'anthropic') {
|
||||
return await runAnthropicEdit(baseArgs);
|
||||
result = await runAnthropicEdit(baseArgs);
|
||||
} else if (provider === 'openai') {
|
||||
result = await runOpenAIEdit(baseArgs);
|
||||
} else {
|
||||
// audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。
|
||||
// schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。
|
||||
return {
|
||||
ok: false,
|
||||
error: ERR_NOT_CONFIGURED,
|
||||
message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`,
|
||||
};
|
||||
}
|
||||
if (provider === 'openai') {
|
||||
return await runOpenAIEdit(baseArgs);
|
||||
// audit fix (P1-5):await 之后,cancelAll 已 invalidate epoch → 本次
|
||||
// 任务期间发生过 cancel,请求结果不再应用(即使 provider 返了 200,
|
||||
// 也可能是 cancelAll 触发 abort 之前最后一段响应;语义上「用户已取消」
|
||||
// 优先于「网络层成功」)。同时这一步拦截「cancelAll 三步非原子期间
|
||||
// 新请求被误 abort 之后」场景 —— 新 runEdit 拿不到 response,但 abort
|
||||
// 信号让它提前抛 AbortError;这里统一以 ERR_CANCELLED 兜底。
|
||||
if (!cleanupEpoch.isCurrent(myEpoch)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: ERR_CANCELLED,
|
||||
message: '请求已被取消',
|
||||
};
|
||||
}
|
||||
// audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。
|
||||
// schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。
|
||||
return {
|
||||
ok: false,
|
||||
error: ERR_NOT_CONFIGURED,
|
||||
message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`,
|
||||
};
|
||||
return result;
|
||||
} finally {
|
||||
// audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId
|
||||
// 再来能正常进入;防 Set 缓慢增长。
|
||||
|
||||
40
main/audit-rate.js
Normal file
40
main/audit-rate.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// P2-7(audit):IPC 高频调用检测 —— 滑动窗口按 key 计数。
|
||||
//
|
||||
// 背景:app:open-config-dir 这类「打开系统目录」的 handler 被 renderer 任意
|
||||
// JS 调到(XSS / devtools 注入)。单次调用无害,但脚本化高频调用能弹出一串
|
||||
// 文件管理器窗口骚扰用户。主进程记一笔滑动窗口计数,超阈值 console.warn
|
||||
// (会同步进 notes-main.log,事后可查)。
|
||||
//
|
||||
// 只检测、不拦截 —— 拦截合法双击/连点会误伤,先只留审计痕迹。
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.windowMs 滑动窗口毫秒数
|
||||
* @param {number} opts.threshold 窗口内允许的最大次数;第 threshold+1 次起 exceeded=true
|
||||
* @param {() => number} [opts.now] 时钟注入,默认 Date.now(单测推进时间用)
|
||||
* @returns {{ hit: (key: string) => { count: number, exceeded: boolean } }}
|
||||
*/
|
||||
function createAuditRateTracker({ windowMs, threshold, now } = {}) {
|
||||
const clock = typeof now === 'function' ? now : Date.now;
|
||||
/** @type {Map<string, number[]>} key → 窗口内各次调用的时间戳(升序) */
|
||||
const buckets = new Map();
|
||||
return {
|
||||
hit(key) {
|
||||
const t = clock();
|
||||
const cutoff = t - windowMs;
|
||||
let arr = buckets.get(key);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
buckets.set(key, arr);
|
||||
}
|
||||
// 过期剔除:[cutoff, t) 语义 —— 恰好 windowMs 前的记录视为过期
|
||||
while (arr.length > 0 && arr[0] <= cutoff) arr.shift();
|
||||
arr.push(t);
|
||||
return { count: arr.length, exceeded: arr.length > threshold };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createAuditRateTracker };
|
||||
70
main/cleanup-epoch.js
Normal file
70
main/cleanup-epoch.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// main/ai.js cancelAll —— 顺序竞争 epoch 闸(audit P1-5)
|
||||
//
|
||||
// 背景:cancelAll 内部三步
|
||||
// 1) for (controller of pending.values()) controller.abort();
|
||||
// 2) pending.clear();
|
||||
// 3) inFlightRequestIds.clear();
|
||||
// 三步非原子。竞态:cancelAll 走到 step 1 刚 abort 完,runEdit 同步阶段
|
||||
// 已经把「新 requestId + 新 controller」加进 pending + inFlightRequestIds →
|
||||
// step 2/3 把这条新 entry 也清掉,runEdit 自以为还在跑,await 不到结果,
|
||||
// 任何同 requestId 重试都找不到 controller。
|
||||
//
|
||||
// 修复:makeCleanupEpoch() 工厂 → { current, snapshot, invalidate, isCurrent }。
|
||||
// - cancelAll 入口 invalidate() → 序号 + 1(任何已 snapshot 的 in-flight 任务
|
||||
// 之后 isCurrent() === false → 自行 bail)
|
||||
// - runEdit 入口 snapshot() 拿当前序号;await 后 isCurrent() === false
|
||||
// (说明本任务期间发生过 cancelAll)→ 不再 apply 任何副作用、直接 return
|
||||
//
|
||||
// 序号从 1 开始自增(0 留给「初始」/「无 epoch」语义)—— isCurrent 比较时
|
||||
// 0 vs 0 也算成立(测试覆盖),但 cancelAll invalidate 之后序号一定 > 0,
|
||||
// 真实 runEdit 拿的 snapshot 至少 1。
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 创建一个 epoch 计数器对象。cancelAll.invalidate / runEdit.snapshot / runEdit.isCurrent
|
||||
* 配合使用,挡住「cancelAll 中途新请求进入」的竞态。
|
||||
* @returns {{
|
||||
* current: () => number,
|
||||
* snapshot: () => number,
|
||||
* invalidate: () => void,
|
||||
* isCurrent: (snap: number) => boolean,
|
||||
* }}
|
||||
*/
|
||||
function makeCleanupEpoch() {
|
||||
let epoch = 0;
|
||||
return {
|
||||
/**
|
||||
* 当前 epoch 序号。0 = 初始(无 cancelAll 发生过)。
|
||||
* @returns {number}
|
||||
*/
|
||||
current() {
|
||||
return epoch;
|
||||
},
|
||||
/**
|
||||
* 拍下当前 epoch 的快照,用于稍后 isCurrent() 校验。
|
||||
* runEdit 入口调用,await 后比对。
|
||||
* @returns {number}
|
||||
*/
|
||||
snapshot() {
|
||||
return epoch;
|
||||
},
|
||||
/**
|
||||
* 自增 epoch,使所有「更早的 snapshot」isCurrent 失效。
|
||||
* cancelAll 入口调用。
|
||||
*/
|
||||
invalidate() {
|
||||
epoch += 1;
|
||||
},
|
||||
/**
|
||||
* 校验「snap 是否仍是当前 epoch」。
|
||||
* @param {number} snap snapshot() 返回的序号
|
||||
* @returns {boolean} snap === current
|
||||
*/
|
||||
isCurrent(snap) {
|
||||
return epoch === snap;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { makeCleanupEpoch };
|
||||
@@ -29,6 +29,13 @@ const {
|
||||
// 与 main/file-ops.js#friendlyWriteError 走同一份文案。避免用户看到
|
||||
// 「EACCES: permission denied, open '/Users/.../config.json'」英文 errno + 路径。
|
||||
const { friendlyFsError } = require('../shared/friendly-fs-error.js');
|
||||
// audit fix (R3 P0-1):aiApiKey 走 safeStorage 加密层。saveConfig 在写盘前
|
||||
// 调 encrypt,loadConfig 在读到包络后调 decrypt —— 上层(ai.js / renderer /
|
||||
// settingsStore)拿到的 appConfig.aiApiKey 始终是明文。详细迁移 / 降级策略
|
||||
// 见 main/secret-store.js 顶部注释。
|
||||
const secretStore = require('./secret-store.js');
|
||||
// P2-1:tmp 路径生成走加密安全随机(与 main/file-ops.js 共享同一工厂)
|
||||
const { generateTmpPath } = require('./generate-tmp-path.js');
|
||||
|
||||
// 测试注入点:默认走真实 electron.app,单测可以换成 mock app。
|
||||
// 下划线前缀表示「仅测试用」—— 生产代码不应调用。
|
||||
@@ -75,6 +82,19 @@ function loadConfig() {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
appConfig = coerceLoadedSettings(parsed);
|
||||
// R3 P0-1:读盘后立刻把 aiApiKey 包络解回明文。saveConfig 会在写盘前
|
||||
// 重新加密;这里只负责「内存里 appConfig.aiApiKey 始终是明文」——
|
||||
// 上层(ai.js / settingsStore / renderer)零感知。懒迁移:旧版明文
|
||||
// 没前缀,decrypt 原样返回,下一次 saveConfig 自动重新加密。
|
||||
if (appConfig.aiApiKey) {
|
||||
const decrypted = secretStore.decrypt(appConfig.aiApiKey);
|
||||
// 仅当解密得到非空明文时才覆盖:若密钥库不可用 / 包络损坏导致 decrypt 返回 '',
|
||||
// 保留磁盘上的原始 enc:v1:... 包络,避免后续 saveConfig(如改主题)因内存值为 ''
|
||||
// 而把仍有效的加密 Key 覆盖成空串、永久丢失。
|
||||
if (decrypted) {
|
||||
appConfig.aiApiKey = decrypted;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 关键修复:之前 `} catch { ... }` 把 JSON 解析错误也吞了 —— 一旦 config.json
|
||||
// 半截 / 格式错(断电、强杀进程、磁盘故障),用户的 aiApiKey / 自定义 dataDir
|
||||
@@ -173,7 +193,24 @@ function cleanupStaleTmpFiles() {
|
||||
* - 写入失败:appConfig 回滚到 pre-merge 快照,调用方拿到 { ok:false, error }
|
||||
*/
|
||||
let saveQueue = Promise.resolve();
|
||||
// audit fix (P1-2):isQuitting 闸。before-quit 触发时置 true,新 saveConfig
|
||||
// 立即返一个已 resolve 的 Promise { ok:false, error:'APP_QUITTING' }(与 queue 路径
|
||||
// 返回类型一致,避免调用方对「同步值 vs Promise」分叉处理)——
|
||||
// 否则 renderer 错误风暴期间 IPC `await saveConfig(...)` 会挂在链尾,
|
||||
// before-quit 等不到 save 完成 → quit 阻塞。in-flight 之前的 save 照常
|
||||
// 完成(已 fsync 的文件 rename 完才返回 ok:true;强制 abort 会丢正在写的
|
||||
// tmp)。主进程退出时「最新一个 save 完成」即可接受。
|
||||
let _quitting = false;
|
||||
function _isQuitting() {
|
||||
return _quitting;
|
||||
}
|
||||
function _setIsQuitting(v) {
|
||||
_quitting = !!v;
|
||||
}
|
||||
function saveConfig(next) {
|
||||
// 闸开 → 立即返同步值(不挂 await)。注意:不入 queue,避免 in-flight 任务
|
||||
// resolve 后又被这个新任务接走(保持 queue 长度 = 在跑 + 排队中闸前任务数)。
|
||||
if (_quitting) return Promise.resolve({ ok: false, error: 'APP_QUITTING' });
|
||||
// audit fix (H1):merged 不能在 enqueue 时计算,否则两个并发调用
|
||||
// 都基于同一份 appConfig 合并,第二个任务写盘时会覆盖第一个任务的改动
|
||||
// (典型场景:用户同时点 alwaysOnTop + 切 dataDir —— alwaysOnTop 被静默丢)。
|
||||
@@ -194,7 +231,9 @@ function saveConfig(next) {
|
||||
// 在 await 后进入 fs.open)会算出相同 tmpPath,后到的 rename 因源文件已被消费
|
||||
// 而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21 亿组合,
|
||||
// 同毫秒冲突概率可忽略。main/file-ops.js#atomicWriteFile 也做了对称修复。
|
||||
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
// P2-1:tmp 路径走加密安全随机。复用 main/generate-tmp-path.js 工厂
|
||||
// (同 main/file-ops.js 的 atomicWriteFile 路径生成逻辑)。
|
||||
const tmpPath = generateTmpPath(cfgPath);
|
||||
let fh = null;
|
||||
try {
|
||||
await fs.mkdir(path.dirname(cfgPath), { recursive: true });
|
||||
@@ -204,8 +243,21 @@ function saveConfig(next) {
|
||||
// 原子性,但 fsync 缺失让断电后实际文件可能不是新文件)。AI key / 自定义 dataDir
|
||||
// 这类关键配置丢失 = 用户感知不到为什么 settings 全没了。先 fsync 再 rename 才能
|
||||
// 保证断电后磁盘上要么是旧 config.json(rename 前断电),要么是完整新文件。
|
||||
fh = await fs.open(tmpPath, 'w');
|
||||
await fh.writeFile(JSON.stringify(merged, null, 2), 'utf-8');
|
||||
// R5 P1-13:tmp 模式 0o600 (rw owner only)。多用户 Linux/macOS 上其他用户
|
||||
// 能读父目录 → 也能读默认 0o644 的 tmp,里面就是包络后的 AI Key / 整份
|
||||
// 配置(最坏情况 decrypt 失败前的瞬态)。Windows 不强制 mode(用 ACL),
|
||||
// 但设了不冲突。详见 main/file-ops.js 同号修复。
|
||||
fh = await fs.open(tmpPath, 'w', 0o600);
|
||||
// R3 P0-1:写盘前把 aiApiKey 转成包络。**用 toWrite 浅拷贝**,不污染
|
||||
// merged —— merged 还要 return 给 IPC 调用方(renderer settingsStore),
|
||||
// UI 看到的是明文(masked display)而不是 enc:v1:...。appConfig 也保持
|
||||
// 明文,loadConfig 那边解密一次后整轮生命周期对称。
|
||||
// secretStore.encrypt 对空串 / 已是包络 / safeStorage 不可用都有兜底。
|
||||
const toWrite = { ...merged };
|
||||
if (toWrite.aiApiKey) {
|
||||
toWrite.aiApiKey = secretStore.encrypt(toWrite.aiApiKey);
|
||||
}
|
||||
await fh.writeFile(JSON.stringify(toWrite, null, 2), 'utf-8');
|
||||
await fh.sync();
|
||||
await fh.close();
|
||||
fh = null;
|
||||
@@ -397,8 +449,11 @@ function _invalidateResolveCache() {
|
||||
|
||||
function getConfig() {
|
||||
if (!configLoaded) {
|
||||
// 防御:调用方忘了 load() —— 隐式初始化一次
|
||||
loadConfig();
|
||||
// 防御:调用方忘了 init() —— 隐式初始化一次(与 init() 对齐:既 loadConfig
|
||||
// 也设定 DEFAULT_DATA_DIR,否则 resolveDataDirOrFallback 因 DEFAULT_DATA_DIR 为
|
||||
// null 返回 dir:null,fs-watcher 拿 null 去 watch 会抛错进入重连死循环)。
|
||||
appConfig = loadConfig();
|
||||
if (!DEFAULT_DATA_DIR) DEFAULT_DATA_DIR = getDefaultDataDir();
|
||||
}
|
||||
return appConfig;
|
||||
}
|
||||
@@ -535,6 +590,8 @@ function _reset() {
|
||||
// 跨 case 串扰(renderer 端 / 用户态不会触发,但单测 _reset 后再 save
|
||||
// 必须从干净 queue 开始)。
|
||||
saveQueue = Promise.resolve();
|
||||
// P1-2:闸也要重置,避免一个 case 置 true 后污染下一个 case。
|
||||
_quitting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,4 +617,6 @@ module.exports = {
|
||||
seedDefaultDataDir,
|
||||
_reset,
|
||||
_setApp,
|
||||
_isQuitting,
|
||||
_setIsQuitting,
|
||||
};
|
||||
|
||||
69
main/file-list-cache.js
Normal file
69
main/file-list-cache.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// file:list 分页缓存(audit R7a P1-20)
|
||||
//
|
||||
// 旧版 file:list 一次性返回全部 entries + files,10000 文件 ≈ 1.5-2MB IPC
|
||||
// payload,结构化克隆 + 渲染端 JSON.parse 都要 30-100ms,首次启动 cold paint
|
||||
// 明显卡顿。改成「首次 200 + 分页」+ 模块级 cache:scan 一次切片多次返回,
|
||||
// 避免 loadMore 反复触发全量 scanDir。
|
||||
//
|
||||
// 缓存失效:调用方在 file:write / file:create / file:delete / file:rename 成功
|
||||
// 后调 invalidate(),下一次 file:list / file:list:more 重扫。简化策略:不做
|
||||
// 精细 dirty entry 合并,整条缓存丢掉。scanDir 1-3s 是「极端大目录(OneDrive
|
||||
// 1w 文件)」的最坏值,普通 < 100ms,重扫成本可控。
|
||||
//
|
||||
// 设计要点:
|
||||
// - cache 严格按 dir 区分(Folder Browser 走 file:scan-dir 用 absDir,与根
|
||||
// file:list 的 dataRoot 是不同 dir,互不污染)。
|
||||
// - 不做 TTL:写操作主动失效是单一来源;fs-watcher 的 emitFilesChanged 不
|
||||
// 影响 file:list cache(cache 只服务「分页拉取」,fs-watcher 走的是另
|
||||
// 一条 IPC 通道)。
|
||||
// - slice 边界由调用方保证(offset / limit 已经在 main.js file:list handler
|
||||
// 里做过 safe* 夹紧);本模块只做"拿/建缓存 → 切片"。
|
||||
|
||||
/** @typedef {{ ok:true, dir:string, entries:Array } | { ok:false, error:string, code?:string, message?:string }} ScanResult */
|
||||
|
||||
/** 缓存项 */
|
||||
let _cache = null; // { dir, entries, files, total, at }
|
||||
|
||||
/** 强制失效(写操作成功后调用) */
|
||||
function invalidate() {
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉或建 file:list 缓存。dir 变化或缓存被显式失效时重扫。
|
||||
* @param {string} dir - 绝对路径
|
||||
* @param {(dir: string) => Promise<ScanResult>} scanFn - scanDir 注入(测试可换 mock)
|
||||
* @returns {Promise<
|
||||
* { dir:string, entries:Array, files:Array, total:number, at:number }
|
||||
* | { error: ScanResult }
|
||||
* >}
|
||||
*/
|
||||
async function getOrBuild(dir, scanFn) {
|
||||
if (_cache && _cache.dir === dir) return _cache;
|
||||
const result = await scanFn(dir);
|
||||
if (!result.ok) return { error: result };
|
||||
const entries = result.entries || [];
|
||||
// files = 非文件夹条目(侧栏历史逻辑兼容:state.files 仅含文件)
|
||||
const files = entries
|
||||
.filter((e) => !e.isFolder)
|
||||
.map((e) => ({ name: e.name, path: e.path, size: e.size, mtimeMs: e.mtimeMs, entryType: e.entryType }));
|
||||
_cache = { dir, entries, files, total: entries.length, at: Date.now() };
|
||||
return _cache;
|
||||
}
|
||||
|
||||
/** 拿一份当前缓存的快照(不改)。test hook:单测断言缓存是否被命中/失效。 */
|
||||
function peek() {
|
||||
return _cache;
|
||||
}
|
||||
|
||||
/** test hook:强制设缓存(不调 scanFn)。 */
|
||||
function _setForTest(cache) {
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOrBuild,
|
||||
invalidate,
|
||||
peek,
|
||||
_setForTest,
|
||||
};
|
||||
321
main/file-ops.js
321
main/file-ops.js
@@ -25,6 +25,9 @@ const path = require('path');
|
||||
const { EDITABLE_EXTS } = require('../shared/extension-lists.js');
|
||||
// 单一事实源:errno → 中文提示(main + renderer 共享,preload 过桥)
|
||||
const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-fs-error.js');
|
||||
// P2-1:tmp 路径生成走加密安全随机(crypto.randomUUID),不再用 Math.random
|
||||
const { generateTmpPath } = require('./generate-tmp-path.js');
|
||||
const { fsyncParentDir } = require('./fsync-parent-dir.js');
|
||||
|
||||
/** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
@@ -32,15 +35,17 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
/**
|
||||
* atomicWriteFile 产生的 tmp 文件名匹配规则:`<name>.<editableExt>.tmp.<pid>.<ts>.<rand>`。
|
||||
* cleanupStaleFileTmpFiles 用它判断哪些 .tmp.* 是自家产物需要清理。
|
||||
* 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 6 字符小写 base36。
|
||||
* 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 generateTmpPath 生成的
|
||||
* 36 位带连字符 UUID(见 main/generate-tmp-path.js,`crypto.randomUUID()`,P2-1 升级而来,
|
||||
* 旧版是 6 字符小写 base36)。
|
||||
*
|
||||
* 旧版 `\.tmp\.\d+\.\d+\.[a-z0-9]+$` 太宽 —— 把 `report.tmp.2024.1234567.abcdef` 这种
|
||||
* 用户合法文件也匹配上,24h 跨进程阈值后会被静默 unlink。要求 .tmp. 前一段是
|
||||
* EDITABLE_EXTS 之一(白名单扩展名)把误伤清零。
|
||||
* EDITABLE_EXTS 之一(白名单扩展名)把误伤清零;rand 段允许连字符以匹配 UUID。
|
||||
*/
|
||||
const ATOMIC_TMP_RE = new RegExp(
|
||||
'\\.(?:' + Array.from(EDITABLE_EXTS).map((e) => e.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') +
|
||||
')\\.tmp\\.\\d+\\.\\d+\\.[a-z0-9]{1,8}$',
|
||||
')\\.tmp\\.\\d+\\.\\d+\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
'i',
|
||||
);
|
||||
|
||||
@@ -141,6 +146,33 @@ async function assertNoSymlinkAncestor(filePath, stopAt) {
|
||||
// dataDir 之外 —— isWithinDataDir 的字符串前缀边界被绕过。
|
||||
// 先 lstat filePath 自身:在 stopResolved 子树里也要拒绝自身是 symlink 的场景。
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// 边界情形(filePath === stopAt,例如 file:list 以 (dataRoot, dataRoot) 校验
|
||||
// 数据根自身):只查 filePath 自身是否 symlink,不再向上遍历祖先。
|
||||
// 旧逻辑在边界相等时跳过自检查、却从 dirname(filePath) 一路 lstat 到根 ——
|
||||
// 既漏掉对 dataRoot 自身的检查(审计威胁模型的核心场景),又会把合法的
|
||||
// symlinked /home 等祖先误判成 SYMLINK_NOT_ALLOWED,导致根目录列表永久失败。
|
||||
const isBoundary = !!stopResolved && resolvedPath === stopResolved;
|
||||
if (isBoundary) {
|
||||
try {
|
||||
const stSelf = await fs.lstat(resolvedPath);
|
||||
if (stSelf.isSymbolicLink()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'SYMLINK_NOT_ALLOWED',
|
||||
code: 'SYMLINK_NOT_ALLOWED',
|
||||
message: `路径中包含符号链接,不允许编辑(${resolvedPath})`,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// ENOENT:目标本身不存在是允许的(scan-dir 创建场景、新文件);其它错误上报
|
||||
if (e.code !== 'ENOENT') {
|
||||
return { ok: false, error: e.message, code: e.code, message: `读取路径状态失败:${e.code || e.message}` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!stopResolved || resolvedPath !== stopResolved) {
|
||||
try {
|
||||
const stSelf = await fs.lstat(resolvedPath);
|
||||
@@ -429,6 +461,53 @@ async function scanFiles(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易并发限流器:把 N 个 promise 包成「最多 concurrency 个同时在飞」的执行器。
|
||||
* 不引第三方依赖(p-limit / p-queue 等)—— 全文只有 scanDir 一个调用点,
|
||||
* 20 行内联比 1 个新 dep 更可控。
|
||||
*
|
||||
* 行为:
|
||||
* - 入队 → 立即调度,若活跃数 < concurrency 则启动执行
|
||||
* - 任意 fn resolve / reject → 拉下一个
|
||||
* - fn 异常会传给调用方(rethrow),但「下一个」仍会被调度(finally 语义)
|
||||
*
|
||||
* @param {number} concurrency - 同时在飞的最大任务数(必须 ≥ 1)
|
||||
* @returns {(fn: () => Promise<T>) => Promise<T>}
|
||||
*/
|
||||
function pLimit(concurrency) {
|
||||
if (!Number.isFinite(concurrency) || concurrency < 1) {
|
||||
throw new Error('pLimit: concurrency 必须 ≥ 1');
|
||||
}
|
||||
const queue = [];
|
||||
let active = 0;
|
||||
const pump = () => {
|
||||
while (active < concurrency && queue.length > 0) {
|
||||
const job = queue.shift();
|
||||
active += 1;
|
||||
// resolve / reject 都触发 pump;catch 防止 fn 抛错后 active 永远不归零
|
||||
job.fn().then(
|
||||
(v) => { active -= 1; job.resolve(v); pump(); },
|
||||
(e) => { active -= 1; job.reject(e); pump(); },
|
||||
);
|
||||
}
|
||||
};
|
||||
return (fn) => new Promise((resolve, reject) => {
|
||||
queue.push({ fn, resolve, reject });
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
/** scanDir 单批并发上限(audit R7a P1-17)。
|
||||
*
|
||||
* 背景:libuv 默认 4 worker threads,scanDir 同时发 N 个 fs.stat 会被 libuv
|
||||
* 串行排队 —— SMB / OneDrive 单次 stat 50-200ms 时 10000 文件 → 30-90s。
|
||||
* 并发 32 让网络盘上 stat 仍能流水线(多网卡 in-flight),又不至于打满
|
||||
* libuv 池 / fd 上限。32 是个保守值:实测 64 在 4 核机器仍 OK,128 在 Windows
|
||||
* 上偶发 ETIMEDOUT(WinHTTP max connections per server 默认 6 / process 翻倍
|
||||
* 也只有 12),32 是「在所有平台稳定」的最大公约数。
|
||||
*/
|
||||
const SCAN_DIR_CONCURRENCY = 32;
|
||||
|
||||
/**
|
||||
* 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。
|
||||
* Folder Browser(2026-08)的核心入口。
|
||||
@@ -445,6 +524,9 @@ async function scanFiles(dir) {
|
||||
* - 排序:文件夹在前(按名称),文件在后(按名称);
|
||||
* 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉
|
||||
*
|
||||
* 性能(audit R7a P1-17):fs.stat 用 pLimit(32) 限流,避免 libuv 4 worker
|
||||
* 排队把 SMB / OneDrive 30-90s 拖到 1-3s。
|
||||
*
|
||||
* @param {string} absDir - 数据目录下某一层的绝对路径
|
||||
* @returns {Promise<{
|
||||
* ok: true,
|
||||
@@ -491,7 +573,14 @@ async function scanDir(absDir) {
|
||||
safeDirents.push(d);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(safeDirents.map(async (d) => {
|
||||
// audit fix (R7a P1-17):fs.stat 走 pLimit(SCAN_DIR_CONCURRENCY) + allSettled。
|
||||
// 旧版 Promise.all(N) 在 SMB / OneDrive 上 N 个 stat 串行排队(libuv 4 worker
|
||||
// 默认),10000 文件 30-90s;改 32 并发后实测 1-3s。
|
||||
// allSettled:单个 stat 抛错(ENOENT 经典 readdir 与 stat 之间被删)不影响
|
||||
// 整批,外层 .flatMap 过滤 fulfilled + truthy 即可,与原 .filter(Boolean) 语义
|
||||
// 一致(fulfilled=null → drop;rejected → drop;fulfilled=object → keep)。
|
||||
const limit = pLimit(SCAN_DIR_CONCURRENCY);
|
||||
const tasks = safeDirents.map((d) => limit(async () => {
|
||||
const fullPath = path.join(absDir, d.name);
|
||||
if (d.isDirectory()) {
|
||||
return {
|
||||
@@ -520,8 +609,8 @@ async function scanDir(absDir) {
|
||||
// 其它(socket / fifo / block device 等)—— 不展示
|
||||
return null;
|
||||
}));
|
||||
|
||||
const list = entries.filter(Boolean);
|
||||
const settled = await Promise.allSettled(tasks);
|
||||
const list = settled.flatMap((r) => (r.status === 'fulfilled' && r.value) ? [r.value] : []);
|
||||
// 排序:folder 在前、file 在后,同组内按 zh-CN locale
|
||||
list.sort((a, b) => {
|
||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||
@@ -572,6 +661,12 @@ function resolveDirRelative(relDir, dataRoot) {
|
||||
* 给定 dataRoot 与当前目录的绝对路径,反算 POSIX 风格的相对路径。
|
||||
* 渲染端用于比对 fs-watcher 推送的 relDir 字段。
|
||||
*
|
||||
* Windows case-insensitive:dataRoot 存盘时可能被大小写规范化(NTFS 实际路径
|
||||
* 大小写取决于创建时),与 absDir 比对时若不做大小写无关比较,会把同盘同一目录
|
||||
* 误判成"不在 dataRoot 下"、返回 '' —— renderer 后续 onFilesChanged 拿到空
|
||||
* relDir 会把「旧目录的文件被移除」误判为「当前文件被外部删除」而清空编辑器。
|
||||
* 比较走 toLowerCase,结果保留原大小写(POSIX 风格 + 可读性)。
|
||||
*
|
||||
* @param {string} absDir
|
||||
* @param {string} dataRoot
|
||||
* @returns {string} POSIX 相对路径,根目录时为 ''
|
||||
@@ -580,9 +675,14 @@ function toRelativeDir(absDir, dataRoot) {
|
||||
if (!absDir || !dataRoot) return '';
|
||||
const a = absDir.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (a === r) return '';
|
||||
// Windows 大小写无关比较:dataRoot 与 absDir 指向同一盘同一目录但大小写不同时
|
||||
// (NTFS 用户主目录就常见),不能误判成"不在 dataRoot 下"。
|
||||
const icmp = process.platform === 'win32'
|
||||
? (x, y) => x.toLowerCase() === y.toLowerCase()
|
||||
: (x, y) => x === y;
|
||||
if (icmp(a, r)) return '';
|
||||
const prefix = r + '/';
|
||||
if (a.startsWith(prefix)) return a.slice(prefix.length);
|
||||
if (icmp(a.slice(0, prefix.length), prefix)) return a.slice(r.length + 1);
|
||||
// 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支
|
||||
return '';
|
||||
}
|
||||
@@ -605,15 +705,20 @@ function toRelativeDir(absDir, dataRoot) {
|
||||
* @param {string} content UTF-8 文本
|
||||
*/
|
||||
async function atomicWriteFile(dst, content) {
|
||||
// audit fix (Round 16 P0):追加 6 位 base36 随机后缀。pid + Date.now() 只保证
|
||||
// 跨进程 / 跨毫秒唯一,同进程同毫秒并发(如自动保存触发的 saveConfig 与
|
||||
// 用户手动 Ctrl+S 撞在同一毫秒)会算出相同 tmpPath,后到的 rename 因源文件
|
||||
// 已被消费而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21
|
||||
// 亿组合,同毫秒冲突概率可忽略。main/config-store.js#saveConfig 也做了对称修复。
|
||||
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
// P2-1:tmp 路径走加密安全随机。抽 main/generate-tmp-path.js 工厂,
|
||||
// 内部用 crypto.randomUUID()(128-bit 加密安全,8-4-4-4-12 hex)替代
|
||||
// 旧的 Math.random().toString(36).slice(2, 8) 6 位 base36 后缀。pid +
|
||||
// Date.now() 跨进程/跨毫秒去重,UUID 兜住同进程同毫秒并发。路径格式
|
||||
// `${dst}.tmp.${pid}.${ts}.${randomId}` 保持不变,fs-watcher 旧逻辑
|
||||
// 只匹配 `.tmp.*` 前缀不受影响。
|
||||
const tmpPath = generateTmpPath(dst);
|
||||
let fh;
|
||||
try {
|
||||
fh = await fs.open(tmpPath, 'w');
|
||||
// R5 P1-13:tmp 文件 mode 0o600 (rw owner only)。多用户 Linux/macOS
|
||||
// 上其他用户能读父目录 → 也能读默认 0o644 的 tmp,里面可能含 AI Key
|
||||
// 包络 / 用户笔记内容。Windows 不强制 mode(用 ACL),但设了不冲突;
|
||||
// 跨平台语义一致。
|
||||
fh = await fs.open(tmpPath, 'w', 0o600);
|
||||
await fh.writeFile(content, 'utf-8');
|
||||
// fsync 关键:没有 fsync,rename 之后断电可能留下「磁盘上 inode 改了
|
||||
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
|
||||
@@ -629,28 +734,20 @@ async function atomicWriteFile(dst, content) {
|
||||
|
||||
try {
|
||||
await renameWithRetry(tmpPath, dst);
|
||||
// audit fix (Round 4 P0-1):rename 后 fsync 父目录(仅 Linux / POSIX)。
|
||||
// POSIX rename(2) 在同分区下原子,但「目录项本身」写入磁盘的时机由内核
|
||||
// 控制;不 fsync 目录就断电,磁盘上可能仍是旧名字 → 文件彻底丢失。
|
||||
// Windows 上 NTFS 文件系统层 journal 元数据,这条 fsync 不需要(也无害,
|
||||
// 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。
|
||||
// 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。
|
||||
if (process.platform !== 'win32') {
|
||||
// Round 13 fix (H5):dirFh.sync() 抛错时 close() 不会执行 → fd 泄漏。
|
||||
// 长跑保存下磁盘压力大 / USB 抖动会让 sync 间歇失败,多次累加触发
|
||||
// EMFILE;统一走 try/finally 兜底 close。
|
||||
const dirFh = await fs.open(path.dirname(dst), 'r');
|
||||
try {
|
||||
await dirFh.sync();
|
||||
} finally {
|
||||
try { await dirFh.close(); } catch { /* close 失败也无能为力 */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// rename 失败:清理残余 tmp(不影响下次保存)
|
||||
try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ }
|
||||
throw e;
|
||||
}
|
||||
// 父目录 fsync(audit Round 4 P0-1):rename 已在 try/catch 之外,父目录
|
||||
// fsync 失败不向上抛 —— 文件已成功 rename 落盘,仅「新名可能不持久化」,
|
||||
// 走 main/fsync-parent-dir.js#fsyncParentDir(与 main.js file:rename /
|
||||
// file:move / file:copy 同一份语义:win32 skip、open→sync→close、close
|
||||
// 失败静默、open/sync 失败返 {ok:false, error})。
|
||||
const fsyncResult = await fsyncParentDir(dst);
|
||||
if (!fsyncResult.ok) {
|
||||
console.warn('[file-ops] 父目录 fsync 失败(不影响写入成功):', fsyncResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -676,6 +773,51 @@ async function renameWithRetry(src, dst) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* audit fix (Folder move/copy):递归复制一个目录到目标目录。
|
||||
* 用于 file:copy 复制整个文件夹(含子目录)的场景。
|
||||
*
|
||||
* 安全:
|
||||
* - 拒绝跟随符号链接 —— 既跳过 src 树里的 symlink 文件/目录(不 follow 到
|
||||
* dataDir 外部),也拒绝 symlink 目录作为复制目的地(由调用方在入口已校验,
|
||||
* 这里再防御一层:递归途中遇到 symlink 直接跳过,绝不 follow)。
|
||||
* - 调用方必须保证 srcAbs / destAbs 都在数据目录内、且 srcAbs 本身不是 symlink。
|
||||
*
|
||||
* 行为:
|
||||
* - 普通文件 → fs.copyFile
|
||||
* - 普通目录 → mkdir(recursive:false,父已被建好)+ 递归
|
||||
* - 其它(socket / fifo / device / symlink)→ 跳过,不复制
|
||||
*
|
||||
* @param {string} srcAbs - 源目录绝对路径(已确认是真实目录)
|
||||
* @param {string} destAbs - 目标目录绝对路径(已确认不存在,由调用方 mkdir 好父级)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function recursiveCopy(srcAbs, destAbs) {
|
||||
const dirents = await fs.readdir(srcAbs, { withFileTypes: true });
|
||||
// 父目录已存在,确保目标目录本体存在(destAbs 是全新的空目录名)
|
||||
await fs.mkdir(destAbs, { recursive: false });
|
||||
for (const d of dirents) {
|
||||
const srcChild = path.join(srcAbs, d.name);
|
||||
const destChild = path.join(destAbs, d.name);
|
||||
// symlink 一律跳过(lstat 探真实类型,不 follow)
|
||||
if (d.isSymbolicLink()) continue;
|
||||
if (d.isDirectory()) {
|
||||
// 递归前再确认不是 symlink 目录(withFileTypes 的 isDirectory 已排除 symlink,
|
||||
// 但防御性 stat 一层更稳)
|
||||
try {
|
||||
const st = await fs.lstat(srcChild);
|
||||
if (st.isSymbolicLink()) continue;
|
||||
} catch {
|
||||
continue; // 读不到就跳过,不阻断整棵复制
|
||||
}
|
||||
await recursiveCopy(srcChild, destChild);
|
||||
} else if (d.isFile()) {
|
||||
await fs.copyFile(srcChild, destChild);
|
||||
}
|
||||
// 其它类型(FIFO / 设备 / socket)→ 不复制
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除数据目录里残留的 `*.tmp.<pid>.<ts>.<rand>` 文件。
|
||||
* 由 main.js 在启动时 / 切换数据目录时调用一次。
|
||||
@@ -701,11 +843,12 @@ async function cleanupStaleFileTmpFiles(dataDir) {
|
||||
const STALE_THRESHOLD_MS = 60_000;
|
||||
const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
for (const f of files) {
|
||||
// 只匹配 atomicWriteFile 生成的 `<name>.<ext>.tmp.<pid>.<ts>.<rand>`。
|
||||
// 只匹配 atomicWriteFile 生成的 `<name>.<ext>.tmp.<pid>.<ts>.<rand>`,
|
||||
// rand 为 generateTmpPath 生成的 36 位带连字符 UUID(P2-1 起,非旧版 base36)。
|
||||
// audit fix (Tmp-L2):旧版 `/\.tmp\.\d+\.\d+\.[a-z0-9]+$/i` 太宽,
|
||||
// 会把用户自己命名 / 同步盘生成 / 备份工具遗留的 `report.tmp.2024.1234567.abcdef`
|
||||
// 这种正常文件当成孤儿静默 unlink。必须要求 .tmp. 前面是 EDITABLE_EXTS
|
||||
// 之一(白名单扩展名),且 tmp 段是 pid.ts.rand 严格三段数字 + 小写 base36。
|
||||
// 之一(白名单扩展名)。
|
||||
// config-store 的 `config.json.tmp.*` 不在此正则范围内,仍由它自家扫(注释指向
|
||||
// shared/extension-lists.js#EDITABLE_EXTS,与 fileOps / sidebar 共用扩展名)。
|
||||
if (!ATOMIC_TMP_RE.test(f)) continue;
|
||||
@@ -753,6 +896,113 @@ function friendlyWriteError(e) {
|
||||
return sharedFriendlyFsError(e && e.code, e && e.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹名避让(重命名时同分支),与 resolveFolderName 完全对称。
|
||||
*
|
||||
* 文件夹的「合法名」语义比 file 简单:用户输入什么就用什么,没有强制扩展名;
|
||||
* Windows 保留字符 / 保留设备名 / 控制字符 / 路径分隔符沿用 file 同一套校验。
|
||||
*
|
||||
* 重名避让时整段 name 加 (2)(3)...,没有 "基础名 + 扩展名" 的两段切割。
|
||||
*
|
||||
* @param {string} raw
|
||||
* @param {string} parentDir - 父目录绝对路径
|
||||
* @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>}
|
||||
*/
|
||||
async function resolveRenameFolderName(raw, parentDir) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return { ok: false, error: '文件夹名不能为空' };
|
||||
}
|
||||
const name = raw.trim();
|
||||
if (/[/\\]/.test(name) || name.includes('..')) {
|
||||
return { ok: false, error: '文件夹名不能包含路径分隔符或 ..' };
|
||||
}
|
||||
if (/[<>:"|?*]/.test(name)) {
|
||||
return { ok: false, error: '文件夹名包含 Windows 保留字符(< > : " | ? *)' };
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1f]/.test(name)) {
|
||||
return { ok: false, error: '文件夹名包含非法字符' };
|
||||
}
|
||||
if (WIN_RESERVED_DEVICE_NAMES.has(name.toUpperCase())) {
|
||||
return { ok: false, error: `"${name}" 是 Windows 保留设备名,不允许作为文件夹名` };
|
||||
}
|
||||
|
||||
let candidate = name;
|
||||
let counter = 2;
|
||||
for (;;) {
|
||||
const fullPath = path.join(parentDir, candidate);
|
||||
if (!isWithinDataDir(fullPath, parentDir)) {
|
||||
return { ok: false, error: '非法路径' };
|
||||
}
|
||||
const occupancy = await pathOccupancy(fullPath);
|
||||
if (occupancy === 'free') {
|
||||
return { ok: true, path: fullPath, name: candidate };
|
||||
}
|
||||
candidate = `${name} (${counter})`;
|
||||
counter += 1;
|
||||
if (counter > 1000) return { ok: false, error: '重名次数过多' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹名清洗 + 重名避让。
|
||||
*
|
||||
* 与 file version 的 resolveFileName 完全同源:路径分隔符 / Windows 保留字符 /
|
||||
* 保留设备名 / 控制字符 / 非空 一律预先拒绝。
|
||||
*
|
||||
* 与 file version 的关键差别:
|
||||
* - 不强制补扩展名:用户输入 "projects" / "PROJECTS" / "2026.drafts" 一律原样保留
|
||||
* - 重名避让只加 (2)/(3)...,不分基础名 / 扩展名切割(文件夹名不切)
|
||||
* - 占用检测 pathOccupancy 同样适用:父目录里已有同名 file 也会触发避让
|
||||
*
|
||||
* 用途:folder:create IPC handler 调它来拿最终路径 + name,主进程据此 mkdir。
|
||||
*
|
||||
* @param {string} raw
|
||||
* @param {string} parentDir - 父目录绝对路径(新建文件夹的落点)
|
||||
* @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>}
|
||||
*/
|
||||
async function resolveFolderName(raw, parentDir) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return { ok: false, error: '文件夹名不能为空' };
|
||||
}
|
||||
const name = raw.trim();
|
||||
// 拒绝路径分隔符与 .. 段
|
||||
if (/[/\\]/.test(name) || name.includes('..')) {
|
||||
return { ok: false, error: '文件夹名不能包含路径分隔符或 ..' };
|
||||
}
|
||||
// Windows 保留字符
|
||||
if (/[<>:"|?*]/.test(name)) {
|
||||
return { ok: false, error: '文件夹名包含 Windows 保留字符(< > : " | ? *)' };
|
||||
}
|
||||
// 控制字符
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1f]/.test(name)) {
|
||||
return { ok: false, error: '文件夹名包含非法字符' };
|
||||
}
|
||||
// Windows 保留设备名:文件夹不带扩展名分支,直接整段判断;带点(DRAFT.2026)也按整段判断
|
||||
//(device name 不带点的语义由 NTFS 决定 —— 但 Notes 里不刻意区分,统按整段拒保守)
|
||||
if (WIN_RESERVED_DEVICE_NAMES.has(name.toUpperCase())) {
|
||||
return { ok: false, error: `"${name}" 是 Windows 保留设备名,不允许作为文件夹名` };
|
||||
}
|
||||
|
||||
let candidate = name;
|
||||
let counter = 2;
|
||||
for (;;) {
|
||||
const fullPath = path.join(parentDir, candidate);
|
||||
if (!isWithinDataDir(fullPath, parentDir)) {
|
||||
return { ok: false, error: '非法路径' };
|
||||
}
|
||||
const occupancy = await pathOccupancy(fullPath);
|
||||
if (occupancy === 'free') {
|
||||
return { ok: true, path: fullPath, name: candidate };
|
||||
}
|
||||
// file / directory 都视为占用
|
||||
candidate = `${name} (${counter})`;
|
||||
counter += 1;
|
||||
if (counter > 1000) return { ok: false, error: '重名次数过多' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_FILE_SIZE,
|
||||
EDITABLE_EXTS,
|
||||
@@ -763,12 +1013,15 @@ module.exports = {
|
||||
isWithinDataDir,
|
||||
resolveFileName,
|
||||
resolveRenameName,
|
||||
resolveFolderName,
|
||||
resolveRenameFolderName,
|
||||
scanFiles,
|
||||
scanDir,
|
||||
resolveDirRelative,
|
||||
toRelativeDir,
|
||||
atomicWriteFile,
|
||||
renameWithRetry,
|
||||
recursiveCopy,
|
||||
cleanupStaleFileTmpFiles,
|
||||
friendlyWriteError,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,17 @@ const fsSync = require('fs');
|
||||
// 把登记 key 和 dirWatcher 上报的 absPath 都归一化到同一形态。
|
||||
const path = require('path');
|
||||
|
||||
const DIR_POLL_INTERVAL_MS = 2000;
|
||||
// audit fix (R7a P1-19):自适应轮询间隔。活动期间(最近 POLL_ACTIVE_WINDOW_MS
|
||||
// 内有 fs.watch 事件 / 探测到列表变化)用 500ms 高频轮询,静止期 5s 一次。
|
||||
// 旧版固定 2s 在 SMB / OneDrive 上常因 scanDir 自身 > 2s + 重入保护 drop 5-15 轮
|
||||
// 兜底信号(fs.watch 不可靠环境的唯一变化源),侧栏 stale 数分钟。自适应后:
|
||||
// - 文件刚改完时高频扫,能在 1s 内反映
|
||||
// - 静止后回到 5s 节流,省 SMB 流量 / IO
|
||||
// - 关键:「不 drop」—— in-flight 时挂 pending flag,完成后立即补一次,
|
||||
// 上一轮被跳过的探测不会丢失(旧版在 pollInFlight 守卫下直接吞掉)
|
||||
const POLL_INTERVAL_ACTIVE_MS = 500;
|
||||
const POLL_INTERVAL_QUIET_MS = 5000;
|
||||
const POLL_ACTIVE_WINDOW_MS = 2000;
|
||||
const EMIT_DEBOUNCE_MS = 300;
|
||||
// audit fix (Round 7 FS-F8):debounce maxWait —— 持续事件流(git checkout /
|
||||
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远不能 trailing flush,
|
||||
@@ -94,6 +104,22 @@ function createFsWatcher(deps) {
|
||||
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
|
||||
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
|
||||
let pollInFlight = false;
|
||||
// audit fix (R7a P1-19):「不 drop」pending。in-flight 期间又触发的 tick
|
||||
// 不再被吞,挂 pendingPoll = true;in-flight 完成后立即补一次(delay=0),
|
||||
// 紧跟的那次 tick 间隔回到 adaptive 值。被跳过的探测不再丢。
|
||||
let pendingPoll = false;
|
||||
// audit fix (R7a P1-19 bugfix):「暂停期间不 re-schedule」。pauseDirPolling
|
||||
// 通过 stopDirPoll 清掉 dirPollTimer + 把 pendingPoll 重置为 false,但
|
||||
// 已 in-flight 的 runPollTick 仍在 await scanDir;scanDir 返回后 finally
|
||||
// 走「无 pending → scheduleNextPoll(getPollInterval())」,等于暂停期间
|
||||
// 重新挂了一个 5s 后才退到的轮询定时器,窗口隐藏时白白浪费 SMB / OneDrive
|
||||
// 流量。pollPaused 标志让 finally 跳过 scheduleNextPoll,真正停止轮询;
|
||||
// resumeDirPolling 清标志 + startDirPoll() 立即重启。
|
||||
let pollPaused = false;
|
||||
// audit fix (R7a P1-19):自适应轮询的「活动窗口」基线。fs.watch 事件 /
|
||||
// emitFilesChanged 实际探测到变化时刷新;poll tick 取间隔时比 POLL_ACTIVE_WINDOW_MS,
|
||||
// 在窗口内 → ACTIVE 500ms,否则 QUIET 5000ms。
|
||||
let lastChangeAt = 0;
|
||||
// audit fix (Round 16 M1):self-write filter。file:write / file:create 成功后
|
||||
// 通过 markExpectedWrite(absPath, mtimeMs) 登记;dirWatcher callback 看到这个
|
||||
// key 命中 + 未过期 → 跳过 scheduleEmit,避免「renderer 把自己的写盘当外部
|
||||
@@ -227,6 +253,26 @@ function createFsWatcher(deps) {
|
||||
lastErrorAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* audit fix (R7a P1-19):标记「刚刚有活动」。被 fs.watch callback / scheduleEmit
|
||||
* / 探测到列表变化时调用。poll tick 读 lastChangeAt 决定下次间隔是 ACTIVE
|
||||
* (500ms) 还是 QUIET (5000ms)。
|
||||
*/
|
||||
function markActivity() {
|
||||
lastChangeAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应 poll 间隔:距 lastChangeAt 在 ACTIVE_WINDOW 内 → 500ms,否则 5s。
|
||||
* 仅「是否在活动窗口」的二元判定 —— 不做指数退避,简单的「5s 节流」就够
|
||||
* 应付 SMB 流量压力(5s 一次 readdir 1w 文件 ≈ 200KB 网络 IO,可忽略)。
|
||||
*/
|
||||
function getPollInterval() {
|
||||
return (Date.now() - lastChangeAt) < POLL_ACTIVE_WINDOW_MS
|
||||
? POLL_INTERVAL_ACTIVE_MS
|
||||
: POLL_INTERVAL_QUIET_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全发送(main-M7:teardown 窗口过程抛 throw → unhandledException)。
|
||||
* @param {object} payload
|
||||
@@ -391,6 +437,10 @@ function createFsWatcher(deps) {
|
||||
// 不向 renderer 推送,避免「刚加载就 emitFilesChanged 又被 renderer 当
|
||||
// fs:changed 事件 → 触发 scanDir 重复请求」循环。
|
||||
if (snapshot !== lastFilesSnapshot) {
|
||||
// audit fix (R7a P1-19):真正探测到列表变化时也算「活动」,把 poll
|
||||
// 切到 ACTIVE 500ms 高频,确保连续改动(git checkout / AI 批量写)
|
||||
// 期间每 500ms 兜底一次,即便 fs.watch 漏报。
|
||||
markActivity();
|
||||
lastFilesSnapshot = snapshot;
|
||||
if (!skipInitialEmit) {
|
||||
// 成功路径同样补 relDir 字段(FS-F11);移除绝对 dir 字段(M12)。
|
||||
@@ -402,6 +452,10 @@ function createFsWatcher(deps) {
|
||||
|
||||
function scheduleEmit() {
|
||||
const now = Date.now();
|
||||
// audit fix (R7a P1-19):fs.watch 事件流持续期间把 poll 切到 ACTIVE 500ms
|
||||
// 高频。markActivity 写 lastChangeAt,下一次 poll tick 拿到的就是 active
|
||||
// 间隔;事件流停 2s 后回 QUIET 5s。无需显式定时器,纯「窗口判定」。
|
||||
markActivity();
|
||||
// audit fix (Round 7 FS-F8):maxWait —— 持续事件流(git checkout /
|
||||
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远 trailing flush 不出。
|
||||
// burstStartedAt 记录首事件时间,到 maxWait 时强制立即 emit。
|
||||
@@ -417,7 +471,13 @@ function createFsWatcher(deps) {
|
||||
const t = setTimeout(() => {
|
||||
emitDebounceTimer = null;
|
||||
burstStartedAt = 0;
|
||||
emitFilesChanged(undefined, dirAtSchedule);
|
||||
// 防御性兜底:emitFilesChanged 是 async,其 scanDir 之后的 entriesSnapshot /
|
||||
// safeSend 路径(try 之外)若抛错会变成未捕获拒绝,触发全局
|
||||
// unhandledRejection → app.exit(1) 杀进程。这里 .catch 吞掉,避免一次
|
||||
// 畸形 scan 结果拖垮整个应用。
|
||||
emitFilesChanged(undefined, dirAtSchedule).catch((e) => {
|
||||
console.warn('[fs-watcher] emitFilesChanged 失败:', e && e.message);
|
||||
});
|
||||
}, delay);
|
||||
// audit fix (main-M8):unref —— 否则 fs-watcher 还在挂定时器时 Electron
|
||||
// 主进程不会自然退出(before-quit 取消 close path 后这 300ms 会再卡一下)。
|
||||
@@ -456,33 +516,83 @@ function createFsWatcher(deps) {
|
||||
reattachTimer = t;
|
||||
}
|
||||
|
||||
function startDirPoll() {
|
||||
if (dirPollTimer || !watchedDir) return;
|
||||
/**
|
||||
* audit fix (R7a P1-19):重构 setInterval → setTimeout 链。每次 tick 后
|
||||
* 重新决策下次间隔(active / quiet 二元),用 setTimeout 而不是 setInterval
|
||||
* 才能让间隔真正自适应。
|
||||
*
|
||||
* 「不 drop」语义:in-flight 期间触发的 tick 不再被 pollInFlight 守卫吞掉,
|
||||
* 挂 pendingPoll = true;in-flight 完成后 nextTick 立即补一次(delay=0),
|
||||
* 之后再回到 adaptive 间隔。被跳过的探测不会丢。
|
||||
*
|
||||
* @param {number} [delay] - 距上次结束的延迟(ms),默认 = getPollInterval()
|
||||
*/
|
||||
function scheduleNextPoll(delay) {
|
||||
if (!watchedDir) return;
|
||||
if (dirPollTimer) {
|
||||
clearTimeout(dirPollTimer);
|
||||
dirPollTimer = null;
|
||||
}
|
||||
const wait = Number.isFinite(delay) ? delay : getPollInterval();
|
||||
// audit fix (Round 7 FS-F14):unref —— 这个 timer 是长期存活的,叠加
|
||||
// will-quit 不清 fsWatcher 会在退出路径上拖住主进程。
|
||||
dirPollTimer = setInterval(() => {
|
||||
// audit fix (Round 7 FS-F7):重入保护。慢盘(OneDrive / SMB)上
|
||||
// scanDir 可能 > 2s,setInterval 触发新一轮与上一轮 await 并发,
|
||||
// 争写 lastFilesSnapshot → 后完成的可能是先发起的(旧覆盖新)。
|
||||
if (pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
updateSnapshot(watchedDir)
|
||||
.catch((e) => {
|
||||
// audit fix (Round 7 FS-F16):floating promise 兜底。
|
||||
console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
pollInFlight = false;
|
||||
});
|
||||
}, DIR_POLL_INTERVAL_MS);
|
||||
if (typeof dirPollTimer.unref === 'function') dirPollTimer.unref();
|
||||
const t = setTimeout(() => {
|
||||
dirPollTimer = null;
|
||||
runPollTick();
|
||||
}, wait);
|
||||
if (typeof t.unref === 'function') t.unref();
|
||||
dirPollTimer = t;
|
||||
}
|
||||
|
||||
async function runPollTick() {
|
||||
if (!watchedDir) return;
|
||||
// audit fix (R7a P1-19):「不 drop」pending。in-flight 时挂 pending,
|
||||
// 完成后立即补一次(delay=0),再回 adaptive 间隔。
|
||||
if (pollInFlight) {
|
||||
pendingPoll = true;
|
||||
scheduleNextPoll(getPollInterval());
|
||||
return;
|
||||
}
|
||||
pollInFlight = true;
|
||||
try {
|
||||
await updateSnapshot(watchedDir);
|
||||
} catch (e) {
|
||||
// audit fix (Round 7 FS-F16):floating promise 兜底。
|
||||
console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message);
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
// 探测完成后再决定下次:
|
||||
// - pending 标记被 set 过 → 立即(delay=0)补一次
|
||||
// - 否则按 adaptive 间隔
|
||||
// audit fix (R7a P1-19 bugfix):暂停期间 → 不挂新 timer。pauseDirPolling
|
||||
// 已经清掉了 dirPollTimer + pendingPoll,但已 in-flight 的 scanDir 在
|
||||
// await 上挂了几秒,恢复时如果不查 pollPaused 就走 scheduleNextPoll,
|
||||
// 等于窗口隐藏期间还有 5s 一次的后台 readdir,违背「暂停省 SMB 流量」
|
||||
// 的设计意图(main.js#707-710 的 minimize/hide handler 假设轮询真的停了)。
|
||||
if (pollPaused) return;
|
||||
if (pendingPoll) {
|
||||
pendingPoll = false;
|
||||
scheduleNextPoll(0);
|
||||
} else {
|
||||
scheduleNextPoll(getPollInterval());
|
||||
}
|
||||
}
|
||||
|
||||
function startDirPoll() {
|
||||
if (dirPollTimer || !watchedDir) return;
|
||||
// 启动时算 active:第一次 tick 立即跑(delay=0),给用户即时反馈
|
||||
markActivity();
|
||||
scheduleNextPoll(0);
|
||||
}
|
||||
|
||||
function stopDirPoll() {
|
||||
if (dirPollTimer) {
|
||||
clearInterval(dirPollTimer);
|
||||
clearTimeout(dirPollTimer);
|
||||
dirPollTimer = null;
|
||||
}
|
||||
pollInFlight = false;
|
||||
pendingPoll = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -587,11 +697,16 @@ function createFsWatcher(deps) {
|
||||
*/
|
||||
function pauseDirPolling() {
|
||||
stopDirPoll();
|
||||
// audit fix (R7a P1-19 bugfix):挂「不 re-schedule」闸,让已 in-flight 的
|
||||
// runPollTick finally 跳过 scheduleNextPoll;否则 5s 一次的轮询还在悄悄跑,
|
||||
// 违反 pause 的省 SMB 流量语义。
|
||||
pollPaused = true;
|
||||
}
|
||||
|
||||
/** 恢复轮询,并立刻补扫一次,追上隐藏期间 fs.watch 可能漏掉的改动 */
|
||||
function resumeDirPolling() {
|
||||
if (!watchedDir) return;
|
||||
pollPaused = false;
|
||||
startDirPoll();
|
||||
updateSnapshot(watchedDir);
|
||||
}
|
||||
@@ -652,6 +767,24 @@ function createFsWatcher(deps) {
|
||||
_test_updateSnapshot: updateSnapshot,
|
||||
/** 测试 / 调试用:当前是否在监听某个目录 */
|
||||
get watchedDir() { return watchedDir; },
|
||||
// audit fix (R7a P1-19) 测试钩子:暴露 getPollInterval / markActivity /
|
||||
// scheduleNextPoll / runPollTick 给单测,验证自适应间隔 + 「不 drop」pending
|
||||
// 语义。生产路径不暴露 —— 上层不应该直接调轮询内部。
|
||||
_test_getPollInterval: getPollInterval,
|
||||
_test_markActivity: markActivity,
|
||||
_test_scheduleNextPoll: scheduleNextPoll,
|
||||
_test_runPollTick: runPollTick,
|
||||
// audit fix (R7a P1-19) 测试钩子:直接设 watchedDir 跳过 fs.watch 订阅,
|
||||
// 让单测可以纯驱动 runPollTick 验证 pending 链,不污染临时目录。
|
||||
_test_setWatchedDir: (dir) => { watchedDir = dir; },
|
||||
// audit fix (R7a P1-19 bugfix) 测试钩子:暴露 pause/resume,让单测验证
|
||||
// 暂停期间 in-flight runPollTick finally 不会 re-schedule(否则窗口
|
||||
// 隐藏时还在偷偷 readdir)。
|
||||
_test_pauseDirPolling: pauseDirPolling,
|
||||
_test_resumeDirPolling: resumeDirPolling,
|
||||
// audit fix (R7a P1-19 bugfix) 测试钩子:当前是否挂有 poll timer。pause
|
||||
// bugfix 测试用它确认 in-flight runPollTick finally 不会偷偷挂新 timer。
|
||||
_test_hasPollTimer: () => dirPollTimer !== null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
64
main/fsync-parent-dir.js
Normal file
64
main/fsync-parent-dir.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// main.js file:rename / folder:rename —— rename 后父目录 fsync(audit P1-6)
|
||||
//
|
||||
// 背景:fs.rename 在 POSIX 下只 fsync 自己的 inode,不 fsync 父目录的 directory entry。
|
||||
// 崩溃时新名可能没落盘,恢复后看到的是「旧名 inode + 文件内容」。audit 要求
|
||||
// 与 atomicWriteFile(main.js:1801-1809 模式)一致:rename 成功后对父目录做 fsync。
|
||||
//
|
||||
// 现状:folder:create 已经在 mkdir 后做了父目录 fsync(main.js:1854-1864),
|
||||
// 但 file:rename(main.js:1986)和 folder:rename(main.js:2085)都没有。
|
||||
//
|
||||
// 设计:
|
||||
// - 抽 fsyncParentDir(filePath, opts?) helper
|
||||
// - win32 上 NTFS journal 自带元数据持久化,直接 noop(与 folder:create 一致)
|
||||
// - fsImpl 注入:生产用 Node 内置 fs;单测用 mock 验证 open→sync→close 顺序
|
||||
// - close 失败静默吞(与 folder:create 行为一致)
|
||||
// - open/sync 失败透传错误(不静默 —— rename 已成功,父目录 fsync 失败要可观测)
|
||||
//
|
||||
// 调用方语义:rename 成功后调用。失败不抛、返 {ok:false, error, code} 让调用方
|
||||
// 决定是否吞(推荐:log warn,不回滚 rename —— 文件已改名成功,父目录 fsync
|
||||
// 失败只是「崩溃时不保证新名持久化」,回滚成本高、收益低)。
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* 对 filePath 的父目录做 fsync,确保 directory entry 落盘。
|
||||
* @param {string} filePath 文件/文件夹的完整路径
|
||||
* @param {{
|
||||
* fsImpl?: { open: (p: string, m: string) => Promise<{ sync: () => Promise<void>, close: () => Promise<void> }> },
|
||||
* platform?: NodeJS.Platform,
|
||||
* }} [opts]
|
||||
* @returns {Promise<{ ok: boolean, error?: string, code?: string, skipped?: string }>}
|
||||
*/
|
||||
async function fsyncParentDir(filePath, opts) {
|
||||
const fsImpl = (opts && opts.fsImpl) || require('fs').promises;
|
||||
const platform = (opts && opts.platform) || process.platform;
|
||||
|
||||
if (platform === 'win32') {
|
||||
// NTFS journal 自带元数据持久化,无需 fsync
|
||||
return { ok: true, skipped: 'win32' };
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(filePath);
|
||||
let fh;
|
||||
try {
|
||||
fh = await fsImpl.open(parentDir, 'r');
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message, code: e.code };
|
||||
}
|
||||
try {
|
||||
await fh.sync();
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message, code: e.code };
|
||||
} finally {
|
||||
try {
|
||||
await fh.close();
|
||||
} catch {
|
||||
// close 失败静默吞(与 folder:create 行为一致 —— 防止 fh 泄漏已尽力)
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { fsyncParentDir };
|
||||
38
main/generate-tmp-path.js
Normal file
38
main/generate-tmp-path.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// main/file-ops.js atomicWriteFile —— tmp 路径生成(audit P2-1)
|
||||
//
|
||||
// 背景:原 inline 实现 `Math.random().toString(36).slice(2, 8)`
|
||||
// - Math.random() 不加密安全,并发同毫秒 + 同 pid + 同 dst 可能撞名
|
||||
// (虽然 36^6 ≈ 21 亿,实际不撞,但语义上 audit 要求与 crypto.randomUUID 对齐)
|
||||
// - 仓库其它路径已用 crypto.randomUUID(),统一风格
|
||||
//
|
||||
// 设计:
|
||||
// - 抽 generateTmpPath(dst, { crypto, pid, now }) 工厂
|
||||
// - 注入 crypto / pid / now 便于单测验证随机性来源
|
||||
// - 默认走 node:crypto.randomUUID()(128-bit 加密安全)
|
||||
// - 路径格式:${dst}.tmp.${pid}.${ts}.${randomId}
|
||||
// 保持现有格式不破坏 fs-watcher(fs-watcher 旧逻辑只匹配 .tmp.* 前缀,randomId
|
||||
// 改成 UUID 不影响匹配)
|
||||
//
|
||||
// 调用方:atomicWriteFile 在 fs.open 之前构造 tmpPath。
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 生成 atomicWriteFile 用的 tmp 路径。
|
||||
* @param {string} dst 目标文件完整路径
|
||||
* @param {{
|
||||
* crypto?: { randomUUID: () => string },
|
||||
* pid?: number,
|
||||
* now?: () => number,
|
||||
* }} [opts] 注入依赖,便于单测
|
||||
* @returns {string} `${dst}.tmp.${pid}.${ts}.${randomId}`
|
||||
*/
|
||||
function generateTmpPath(dst, opts) {
|
||||
const cryptoImpl = (opts && opts.crypto) || require('node:crypto');
|
||||
const pid = (opts && typeof opts.pid === 'number') ? opts.pid : process.pid;
|
||||
const now = (opts && typeof opts.now === 'function') ? opts.now : Date.now;
|
||||
const randomId = cryptoImpl.randomUUID();
|
||||
return `${dst}.tmp.${pid}.${now()}.${randomId}`;
|
||||
}
|
||||
|
||||
module.exports = { generateTmpPath };
|
||||
111
main/ipc-sender.js
Normal file
111
main/ipc-sender.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// IPC handler 入口 sender / frame 守卫(audit R5 P1-10 / P1-11 / P1-14)
|
||||
// ============================================================================
|
||||
//
|
||||
// 背景:渲染端的 window.api.* 是 contextBridge 暴露的,渲染端任意 JS(XSS、
|
||||
// devtools console 注入、扩展、未来的 webview)都能调。`renderer:set-dirty`
|
||||
// 单独加了 sender 校验(main.js:952),其它 20+ file/folder/shell/window/ai
|
||||
// handler 全裸奔 —— 攻击面包括:
|
||||
// - 读 / 写 / 删 用户笔记(file:read/write/delete)
|
||||
// - 改设置 / dataDir / 主题(app:*, window:*)
|
||||
// - 取消别人正在跑的 AI 请求(ai:cancel —— 跨窗口 race)
|
||||
// - 弹出 arm 窗口让 reveal-ai-key 流程推进(B-10,未来 webview 嵌入)
|
||||
//
|
||||
// 修复:抽 assertMainSender(event, getMainWindow) → { ok:true } | { ok:false,
|
||||
// error:SENDER_NOT_ALLOWED }。所有 IPC handler 顶部一行 if 早退。
|
||||
// senderFrame 守卫(assertTopFrame)单独抽,对子 frame 嵌入场景生效。
|
||||
//
|
||||
// 测试注入:getMainWindow 走参数注入,不直接读 main.js 闭包 —— 单测里
|
||||
// 传 mock 即可。vi.mock('electron') 拦不住 CJS require,所以走参数。
|
||||
//
|
||||
// 用法示例(main.js):
|
||||
// const { assertMainSender, SENDER_NOT_ALLOWED } = require('./ipc-sender.js');
|
||||
// ipcMain.handle('file:read', async (event, filePath) => {
|
||||
// const guard = assertMainSender(event, getMainWindow);
|
||||
// if (!guard.ok) return { ok: false, error: guard.error };
|
||||
// // ... 原有逻辑
|
||||
// });
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* IPC handler 调用方来源校验。返回 ok=true 放行,ok=false 拒绝并附错误码。
|
||||
*
|
||||
* @param {Electron.IpcMainInvokeEvent | Electron.IpcMainMessageEvent} event
|
||||
* IPC 事件对象;读取 event.sender(webContents 引用)。
|
||||
* @param {() => (Electron.BrowserWindow | null)} getMainWindow
|
||||
* 返回当前主窗口的 getter —— 走参数注入避免循环依赖 + 单测里 mock 方便。
|
||||
* 主窗口未创建(早期启动 race)或已销毁 → 拒绝。
|
||||
* @returns {{ ok: true } | { ok: false, error: string }}
|
||||
*/
|
||||
function assertMainSender(event, getMainWindow) {
|
||||
if (!event || !event.sender) {
|
||||
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||
}
|
||||
const win = typeof getMainWindow === 'function' ? getMainWindow() : null;
|
||||
if (!win || typeof win.isDestroyed !== 'function' || win.isDestroyed()) {
|
||||
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||
}
|
||||
if (event.sender !== win.webContents) {
|
||||
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层 frame 守卫:拒绝来自 iframe / webview 子 frame 的调用。
|
||||
* 用途:未来给主窗口加 webview 嵌入时(如设置面板内嵌文档预览),防止子
|
||||
* frame 里的脚本通过 ipcRenderer 直接调主进程敏感接口。
|
||||
*
|
||||
* @param {Electron.IpcMainInvokeEvent | Electron.IpcMainMessageEvent} event
|
||||
* @returns {{ ok: true } | { ok: false, error: string }}
|
||||
*/
|
||||
function assertTopFrame(event) {
|
||||
// senderFrame 缺失(极少数场景)= 拒绝保守
|
||||
if (!event || !event.senderFrame) {
|
||||
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||
}
|
||||
// parent 真值 = 子 frame(iframe / webview / 子 BrowserWindow);null = 顶层
|
||||
if (event.senderFrame.parent) {
|
||||
return { ok: false, error: SENDER_NOT_ALLOWED };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// AI cancel requestId 两种合法格式(main/ai.js:968 / src/ai/ai-chat-panel.js:21):
|
||||
// - 'ai-<timestamp>-<UUIDv4>'(main 侧 fallback 生成)
|
||||
// - '<UUIDv4>'(renderer 侧 crypto.randomUUID())
|
||||
// 这条正则同时拒掉任意垃圾字符串、过长 payload(防内存放大)、非 hex 字符。
|
||||
// 编译期一次,运行时零开销。
|
||||
const RE_AI_REQUEST_ID = /^(?:ai-\d{1,18}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
|
||||
|
||||
/**
|
||||
* ai:cancel IPC 的 requestId 格式校验。配合 assertMainSender 做纵深防御:
|
||||
* - assertMainSender 拒掉恶意 webContents
|
||||
* - assertAiRequestId 拒掉畸形 requestId(防 typo / 攻击者探测 Map 结构)
|
||||
*
|
||||
* @param {unknown} requestId
|
||||
* @returns {{ ok: true, requestId: string } | { ok: false, error: string }}
|
||||
*/
|
||||
function assertAiRequestId(requestId) {
|
||||
if (typeof requestId !== 'string') {
|
||||
return { ok: false, error: 'INVALID_REQUEST_ID' };
|
||||
}
|
||||
if (requestId.length > 200) {
|
||||
return { ok: false, error: 'INVALID_REQUEST_ID' };
|
||||
}
|
||||
if (!RE_AI_REQUEST_ID.test(requestId)) {
|
||||
return { ok: false, error: 'INVALID_REQUEST_ID' };
|
||||
}
|
||||
return { ok: true, requestId };
|
||||
}
|
||||
|
||||
const SENDER_NOT_ALLOWED = 'SENDER_NOT_ALLOWED';
|
||||
const INVALID_REQUEST_ID = 'INVALID_REQUEST_ID';
|
||||
|
||||
module.exports = {
|
||||
assertMainSender,
|
||||
assertTopFrame,
|
||||
assertAiRequestId,
|
||||
SENDER_NOT_ALLOWED,
|
||||
INVALID_REQUEST_ID,
|
||||
};
|
||||
42
main/lru-cache.js
Normal file
42
main/lru-cache.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// P2-6(audit):通用 LRU 缓存 —— Map 迭代序即插入序,get/set 命中时
|
||||
// delete+set 把键提到最新;set 超 maxSize 删最老(第一个键)。
|
||||
//
|
||||
// 用途:main/ai.js 的 pickOpenAITokenConfig / joinUrl 每次调用重复跑
|
||||
// 正则 + new URL 解析;同 model / 同 baseURL 在一次会话里反复命中,
|
||||
// 缓存结果省 CPU。maxSize 64 足够覆盖「用户试过的模型 + URL」组合,
|
||||
// 不会无界增长。
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxSize=64]
|
||||
* @returns {{ get: (k: any) => any, set: (k: any, v: any) => void,
|
||||
* has: (k: any) => boolean, size: number }}
|
||||
*/
|
||||
function createLruCache({ maxSize = 64 } = {}) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const v = map.get(key);
|
||||
// LRU:提到最新
|
||||
map.delete(key);
|
||||
map.set(key, v);
|
||||
return v;
|
||||
},
|
||||
set(key, value) {
|
||||
// 覆盖同键 = 使用一次,提到最新
|
||||
if (map.has(key)) map.delete(key);
|
||||
map.set(key, value);
|
||||
if (map.size > maxSize) {
|
||||
// Map 第一个键 = 最老
|
||||
map.delete(map.keys().next().value);
|
||||
}
|
||||
},
|
||||
has(key) { return map.has(key); },
|
||||
get size() { return map.size; },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLruCache };
|
||||
105
main/main-log-writer.js
Normal file
105
main/main-log-writer.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// P2-10 / P2-12(audit):主进程日志异步缓冲写出。
|
||||
//
|
||||
// 旧实现(main.js 顶部 console 重写层):每行 mkdirSync + appendFileSync ——
|
||||
// 同步 IO 直接阻塞 main 事件循环。AI 流式 / fsWatcher 高频日志期间,
|
||||
// 每次 console.* 都是一次磁盘往返;顶层同步 fs 也拖慢启动路径。
|
||||
//
|
||||
// 新设计:write(line) 只进内存缓冲并调度一次 flush 定时器(默认 250ms,
|
||||
// .unref() 不拖进程退出);flush 把整段缓冲拼成一次 mkdir(recursive) +
|
||||
// appendFile 异步写出。崩溃窗口 = 最多 flushMs 毫秒的日志丢失 ——
|
||||
// 对诊断日志可接受(崩溃路径另有 stderr 兜底)。
|
||||
//
|
||||
// 失败语义与旧版一致:写盘失败静默丢弃(不应影响主流程)。
|
||||
// 缓冲上限 maxBuffered 行,超出丢最老,防 AI 刷屏撑爆内存。
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.logDir
|
||||
* @param {string} opts.logFile
|
||||
* @param {{ mkdir: Function, appendFile: Function }} [opts.fsImpl] 默认 require('fs').promises
|
||||
* @param {number} [opts.flushMs=250]
|
||||
* @param {number} [opts.maxBuffered=1000]
|
||||
* @param {Function} [opts.setTimeoutImpl]
|
||||
* @param {Function} [opts.clearTimeoutImpl]
|
||||
*/
|
||||
function createMainLogWriter({
|
||||
logDir,
|
||||
logFile,
|
||||
fsImpl,
|
||||
flushMs = 250,
|
||||
maxBuffered = 1000,
|
||||
setTimeoutImpl,
|
||||
clearTimeoutImpl,
|
||||
}) {
|
||||
const fs = fsImpl || require('fs').promises;
|
||||
const _setTimeout = setTimeoutImpl || setTimeout;
|
||||
const _clearTimeout = clearTimeoutImpl || clearTimeout;
|
||||
|
||||
/** @type {string[]} */
|
||||
let buffer = [];
|
||||
/** @type {Promise<void>|null} mkdir 只做一次,缓存 promise */
|
||||
let mkdirPromise = null;
|
||||
/** @type {Promise<void>|null} in-flight flush,dispose/重复 flush 复用 */
|
||||
let flushing = null;
|
||||
let timer = null;
|
||||
|
||||
function ensureDir() {
|
||||
if (!mkdirPromise) {
|
||||
mkdirPromise = fs.mkdir(logDir, { recursive: true }).catch(() => {
|
||||
// mkdir 失败:下次 flush 重试(不缓存失败的 promise)
|
||||
mkdirPromise = null;
|
||||
throw new Error('mkdir failed');
|
||||
});
|
||||
}
|
||||
return mkdirPromise;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (flushing) return flushing;
|
||||
if (buffer.length === 0) return;
|
||||
const lines = buffer;
|
||||
buffer = [];
|
||||
flushing = (async () => {
|
||||
try {
|
||||
await ensureDir();
|
||||
await fs.appendFile(logFile, lines.join(''));
|
||||
} catch {
|
||||
// 写日志失败静默丢弃 —— 与旧同步实现「catch 忽略」语义一致
|
||||
} finally {
|
||||
flushing = null;
|
||||
}
|
||||
})();
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (timer) return;
|
||||
timer = _setTimeout(() => {
|
||||
timer = null;
|
||||
flush();
|
||||
}, flushMs);
|
||||
// 日志定时器不应单独吊住进程退出
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
}
|
||||
|
||||
return {
|
||||
write(line) {
|
||||
buffer.push(line);
|
||||
if (buffer.length > maxBuffered) {
|
||||
buffer.splice(0, buffer.length - maxBuffered);
|
||||
}
|
||||
scheduleFlush();
|
||||
},
|
||||
flush,
|
||||
dispose() {
|
||||
if (timer) {
|
||||
_clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createMainLogWriter };
|
||||
32
main/save-settings-validate.js
Normal file
32
main/save-settings-validate.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// main.js app:save-settings —— partial payload 校验(audit P1-1)
|
||||
//
|
||||
// 背景:原 handler `if (!partial || typeof partial !== 'object') return
|
||||
// { ok: true, settings: configStore.getConfig() };` 把 null / undefined /
|
||||
// string / array 都当成「无更新」静默放过,renderer 缓存与磁盘脱钩
|
||||
// (settingsStore.update({...}) 拿不到 error 也不会重试,直到下次合法保存)。
|
||||
//
|
||||
// 修复:抽 isValidPartialSettings(partial) 纯函数;handler 顶部调用,
|
||||
// 拒收集合 → 返回 { ok: false, error: 'INVALID_PAYLOAD' }。
|
||||
//
|
||||
// 为什么纯函数 + 抽出:
|
||||
// 1. 单测无需 mock ipcMain / Electron 运行时;
|
||||
// 2. 日后若 renderer 端要前置校验(避免 round-trip),同一函数可直接 import。
|
||||
//
|
||||
// 拒收集合:null / undefined / 任何 primitive / Array / Function。
|
||||
// 接收:plain object(typeof === 'object' && !Array.isArray)。
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {unknown} partial
|
||||
* @returns {boolean} true = 合法 partial(plain object),false = 应拒
|
||||
*/
|
||||
function isValidPartialSettings(partial) {
|
||||
return (
|
||||
partial !== null &&
|
||||
typeof partial === 'object' &&
|
||||
!Array.isArray(partial)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { isValidPartialSettings };
|
||||
167
main/secret-store.js
Normal file
167
main/secret-store.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// API Key 等敏感字段的加密存储层(audit P0-1)
|
||||
// ============================================================================
|
||||
//
|
||||
// 背景:原来 aiApiKey 直接以明文写入 userData/config.json。任何能拿到
|
||||
// userData 目录副本的人 / 备份脚本 / 同步盘历史版本都拿得到明文 Key。
|
||||
//
|
||||
// 修复:用 electron.safeStorage(DPAPI / Keychain / libsecret)做对称加密,
|
||||
// 磁盘仅留包络(enc:v1:<base64 ciphertext>)。运行时内存需要时再解密
|
||||
// 给 fetch 用 —— 主进程栈上才有明文窗口。
|
||||
//
|
||||
// 包络格式:
|
||||
// - 'enc:v1:' 前缀 + base64 编码的密文 Buffer
|
||||
// - 没有前缀 = 旧版明文(升级前配置),按明文处理(懒迁移见下)
|
||||
// - 前缀之后是 v1 版本号:未来升级算法 / 改用非对称 / 换 key 时 bump 到 v2
|
||||
//
|
||||
// 降级(isEncryptionAvailable() === false,典型 Linux 无 gnome-keyring/kwallet):
|
||||
// - encrypt 返回明文 + console.warn 一次(每进程)
|
||||
// - decrypt 遇到包络但 keyring 不可用 → 返回 ''(AI 配置临时不可用,UI 提示重输)
|
||||
//
|
||||
// 迁移(懒迁移,audit P0-1 要求"启动时一次性加密" → 这里更轻量):
|
||||
// - 老配置 plaintext 在 loadConfig 时进内存仍是明文
|
||||
// - 任意 settingsStore.update 触发 saveConfig → 写入前自动加密
|
||||
// - 不在 loadConfig 里主动写盘:避免「启动 + 配置没改也要做一次 IO」
|
||||
// - 透明:上层读 appConfig.aiApiKey 永远拿到明文(loadConfig decrypt 一次,
|
||||
// saveConfig 只在写盘路径加密,merged 内存对象保持明文)
|
||||
//
|
||||
// 测试注入:_setSafeStorage(mock) 替换底层 safeStorage,_reset() 还原。
|
||||
// 单测里 vi.mock('electron') 拦不住 CJS require(与 main/config-store.js
|
||||
// 同模式),用本模块自己的 setter。
|
||||
|
||||
'use strict';
|
||||
|
||||
const ENVELOPE_PREFIX = 'enc:v1:';
|
||||
let _warnedUnavailable = false;
|
||||
let _safeStorageOverride = null;
|
||||
|
||||
/**
|
||||
* 取当前生效的 safeStorage(生产 = electron.safeStorage,单测 = 注入的 mock)。
|
||||
* 模块未在 Electron 内运行时(极少见,e.g. 脚本化测试)返回 null。
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function getSafeStorage() {
|
||||
if (_safeStorageOverride !== null) return _safeStorageOverride;
|
||||
try {
|
||||
const electron = require('electron');
|
||||
return electron && electron.safeStorage ? electron.safeStorage : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统密钥库是否可用。不可用时(典型 Linux 无 keyring)→ 降级到明文存储。
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAvailable() {
|
||||
const ss = getSafeStorage();
|
||||
if (!ss || typeof ss.isEncryptionAvailable !== 'function') return false;
|
||||
try {
|
||||
return !!ss.isEncryptionAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「不可用」时一次性 warn。每个进程只 warn 一次,避免设置面板来回点
|
||||
* 反复触发;用户看到的也是控制台一次性提示。
|
||||
* @param {string} reason
|
||||
*/
|
||||
function warnOnceDegraded(reason) {
|
||||
if (_warnedUnavailable) return;
|
||||
_warnedUnavailable = true;
|
||||
console.warn(
|
||||
`[secret-store] safeStorage 不可用 (${reason}); API Key 以明文写入 config.json(降级安全)。` +
|
||||
'Linux 上安装 gnome-keyring / kwallet 后重启即可恢复加密。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密一段明文 → 包络字符串(可写盘)。
|
||||
* 输入:任意 string(空串、null、undefined 都安全处理)。
|
||||
* 输出:
|
||||
* - 空 / null / undefined → ''(不写盘也无所谓;saveConfig 会跳过空 aiApiKey)
|
||||
* - 已是包络(enc:v1:...)→ 原样返回(幂等,防止重复加密变套娃)
|
||||
* - 明文 + safeStorage 可用 → 'enc:v1:' + base64(ciphertext)
|
||||
* - 明文 + safeStorage 不可用 → 原明文 + warn 一次(降级)
|
||||
* @param {string|null|undefined} plain
|
||||
* @returns {string}
|
||||
*/
|
||||
function encrypt(plain) {
|
||||
if (plain == null) return '';
|
||||
const s = String(plain);
|
||||
if (s === '') return '';
|
||||
if (s.startsWith(ENVELOPE_PREFIX)) return s;
|
||||
const ss = getSafeStorage();
|
||||
if (!ss || !isAvailable()) {
|
||||
warnOnceDegraded('isEncryptionAvailable() === false');
|
||||
return s;
|
||||
}
|
||||
let buf;
|
||||
try {
|
||||
buf = ss.encryptString(s);
|
||||
} catch (e) {
|
||||
warnOnceDegraded(`encryptString 抛错: ${e && e.message ? e.message : 'unknown'}`);
|
||||
return s;
|
||||
}
|
||||
return ENVELOPE_PREFIX + buf.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密包络 → 明文。
|
||||
* 输入:任意 string(空 / null / undefined 安全)。
|
||||
* 输出:
|
||||
* - 空 / null / undefined → ''
|
||||
* - 没有 enc:v1: 前缀 → 原样返回(旧版明文 passthrough;懒迁移关键)
|
||||
* - 包络 + safeStorage 可用 → 解密后的明文
|
||||
* - 包络 + safeStorage 不可用 → ''(AI 临时不可用,UI 应引导重输)
|
||||
* - 包络 + decryptString 抛错(损坏 / 跨用户 / 改 key 后)→ ''(同不可用)
|
||||
* @param {string|null|undefined} stored
|
||||
* @returns {string}
|
||||
*/
|
||||
function decrypt(stored) {
|
||||
if (stored == null) return '';
|
||||
const s = String(stored);
|
||||
if (s === '') return '';
|
||||
if (!s.startsWith(ENVELOPE_PREFIX)) return s;
|
||||
const ss = getSafeStorage();
|
||||
if (!ss || !isAvailable()) return '';
|
||||
try {
|
||||
const b64 = s.slice(ENVELOPE_PREFIX.length);
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
return ss.decryptString(buf);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 测试注入点(生产代码不应调用) ----
|
||||
|
||||
/**
|
||||
* 替换底层 safeStorage。传 null 还原到 require('electron') 路径。
|
||||
* @param {object|null} mock
|
||||
*/
|
||||
function _setSafeStorage(mock) {
|
||||
_safeStorageOverride = mock;
|
||||
// 重置 warn 标志 —— 测试之间不串
|
||||
_warnedUnavailable = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置模块状态(safeStorage 注入 + warn 标志)回默认。
|
||||
* 测试 beforeEach 用。
|
||||
*/
|
||||
function _reset() {
|
||||
_safeStorageOverride = null;
|
||||
_warnedUnavailable = false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
isAvailable,
|
||||
ENVELOPE_PREFIX,
|
||||
_setSafeStorage,
|
||||
_reset,
|
||||
};
|
||||
Reference in New Issue
Block a user