Files
drouter/devtools/check-snap-render.js
T
liuzhuohua 309be8b0a5 feat(snapshot): 快照可单独上锁 + 备注事后可改,全部并入紧急救援通道页
用户反馈的四件事一次做完:
1. 给快照加保护(单份上锁,自动清理一律跳过)
2. 说明「手动快照受保护不被清理」,并讲清它与上锁的区别
3. 快照备注事后可改(此前只能在创建时填,存下来就只能看不能改)
4. 列表从「配置快照与回滚」卡片搬进「紧急救援通道」,回滚入口不再弹残缺弹窗

protected 与 keep_manual 是两件事,必须分开:
- keep_manual 是全局策略,按 tag.startswith('auto') 猜来源
- protected 是用户对这一份的显式意图,跨策略生效
判据只能加在删除分支上,混进 auto 算法的话,受保护的自动快照
会开始占 keep_count 配额,把别的自动快照挤掉 —— 用户设「保留 5 份」
却只剩 4 份,且没有任何提示。

上锁不等于删不掉:删受保护的那份时后端回 needs_force,前端弹二次确认
后带 force 重试。审计里标注「受保护,已强制删除」,否则事后只看得到
「删了快照」,看不出删的是用户特意保下来的那份。

meta 用 .tmp + os.replace 原子写 —— meta 是 prune 唯一的判据来源,
写到一半被打断会留下解析不了的残文件,之后那份快照既不会被清理,
也上不了锁(读不到就当没保护)。

备注消毒:控制字符一律换成空格而不是删掉。删掉会把「甲 BEL 乙」
变成「甲乙」,两个词粘成一坨,而用户看到的备注莫名其妙。

真机验收(40 项全绿)当场抓到两个本地回归漏掉的真 bug:
- 备注消毒把制表符删掉而不是换空格:'a\\nb\\tc' 变成 'ab c'
  本地判据只写「不含换行和制表符」,恰好放过 —— 「没有危险字符」
  和「内容没被偷偷改掉」是两件事
- web 层无条件写 'protected': b.get('protected'),
  payload 里这个键永远在(值可能是 None),helper 的
  `if 'protected' in p` 恒成立,「不传就取反」从 HTTP 路上彻底失效
两个都补了反向验证(注入后必须观察到红)。

顺带修掉一个既存缺陷:t-107.py 里的 js_code_only 一直在吃 app.js
的真代码 —— 用 re.sub 块注释优先、再剥行注释,而 app.js 8222 行有一句
讲「怎么防止 CSS 注释被提前闭合」的行注释,注释里写着块注释的起止符
字面量,于是被当成真注释开头一路吃到文件尾。
证据:块注释起始 169 次而结束 170 次,数量不等就是配对错位一格。
HEAD 版同样中招(163/164),所以不是本轮引入。
换成 _dev/jsstrip.py 的词法扫描版(正确处理模板串的插值、正则字面量、
prev 记 token 尾词而不是一个字符),并加 node 侧 V8 交叉验证。
2026-10-03 14:22:14 +08:00

459 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 快照列表渲染回归:真调 listSnapshots(),验证内嵌渲染 + 备注编辑 +
// 上锁开关 + 受保护删除的二次确认。
//
// 为什么单独一个文件而不并进 check-render.js
// ------------------------------------------
// check-render.js 跑的是**全部 47 个视图**,它只检查「不抛异常、
// 不空渲染」。而这一版快照的坑全在细节里:
// · 备注输入框在不在、值对不对
// · 上锁开关的 checked 有没有正确反映后端的 protected
// · 点「存备注」发的请求体对不对
// · 删受保护快照时有没有真走 needs_force → 二次确认 → 带 force 这条链
// 这些 check-render 全都看不见 —— 它只数「有没有渲染出东西」。
//
// 三条踩过的坑,写在这里免得再犯
// ------------------------------
// 1. **桩没接上**:第一版用 ctx.listSnapshots() 调,fetch 桩一次都没
// 被调用,15 条断言全拿到「暂无快照」。症状是「数据为空」而不是
// 报错,根本猜不出是桩的问题。所以下面第一条断言就是「fetch 桩
// 被调用过」—— 这种静默失效比判据红掉难查得多。
// 2. **必须 vm.runInContext**:app.js 里的 fetch 靠作用域链查到我
// 注入的桩,从外部 ctx.listSnapshots() 调走的是另一条路径。
// 3. **用例必须串行**:第一版四个 IIFE 并发跑,全都往同一个
// SNAP_STATE 上写 —— 竞态。每个用例的 await 都会让出控制权,
// 后一个用例已经把 SNAP_STATE 换掉了。改成一条 await 链。
//
// 用法:node devtools/check-snap-render.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const DEFAULT_APP = path.join(__dirname, '..', 'web', 'app.js');
const code = fs.readFileSync(process.argv[2] || DEFAULT_APP, 'utf8');
let pass = 0, fail = 0;
const bad = [];
function chk(desc, cond, extra) {
if (cond) { pass++; console.log('[OK] ' + desc); }
else {
fail++; bad.push(desc);
console.log('[NG] ' + desc);
if (extra !== undefined) console.log(' ' + String(extra).slice(0, 300));
}
}
// ---------------------------------------------------------------- DOM 桩
function mkEl(id) {
const el = {
id, _html: '', textContent: '', value: '', disabled: false, checked: false,
className: '', tagName: 'DIV', dataset: {}, style: {}, children: [],
attributes: {},
classList: {
add(c) { el.className += ' ' + c; },
remove(c) { el.className = el.className.replace(c, ''); },
toggle() { return false; }, contains() { return false; },
},
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); },
addEventListener() {}, removeEventListener() {},
appendChild(c) { el.children.push(c); },
removeChild() {},
querySelector() { return mkEl('sub'); },
querySelectorAll() { return []; },
get parentElement() { return mkEl('parent'); },
get firstChild() { return mkEl('first'); },
insertBefore() {}, replaceChild() {}, cloneNode() { return mkEl('clone'); },
focus() {}, select() {}, scrollIntoView() {}, click() {},
onclick: null, onchange: null, oninput: null, onkeydown: null,
getContext() { return {}; },
closest() { return null; },
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0 }; },
setAttribute(k, v) { el.attributes[k] = v; },
getAttribute(k) { return el.attributes[k]; },
removeAttribute(k) { delete el.attributes[k]; },
};
return el;
}
const ELS = new Map();
function getEl(sel) {
const k = String(sel).replace(/^#/, '').split(/[\s>]/)[0];
if (!ELS.has(k)) ELS.set(k, mkEl(k));
return ELS.get(k);
}
const document = {
getElementById: id => getEl(id),
querySelector: sel => getEl(sel),
querySelectorAll: () => [],
createElement: t => mkEl('new:' + t),
body: mkEl('body'),
addEventListener() {},
documentElement: mkEl('html'),
head: mkEl('head'),
};
// ------------------------------------------------------------ fetch 桩
const REQS = [];
let FETCH_HITS = 0;
let SNAP_STATE = { items: [], root: '/opt/drouter/snapshots', scope: [], disk: null };
let DELETE_NEEDS_FORCE = true; // 模拟「后端对受保护快照要 force」
let PROTECT_FAILS = false; // 模拟「后端拒绝上锁」
function mkSnap(ts, tag, protectedFlag, extra) {
return Object.assign({
ts, tag, protected: !!protectedFlag, size_kb: 128.0, db_included: true,
created_at: ts, files: ['a'], count: 1,
}, extra || {});
}
// ⚠️ 桩必须返回 **Response 形状**(带 .json()),不能直接返回对象。
// app.js 的 api() 走的是 `await res.json()`,我第一版返回裸对象
// `{ok, data}` —— 于是 .json is not a function 抛进 catch,api() 落到
// PARSE 分支返回 `{ok:false}`,listSnapshots 拿到的 items 是 undefined。
// 症状又是「数据为空」而不是报错:19 条断言里 17 条红,根因却是
// 桩的返回类型不对。跟第 1 条「桩没接上」是同一类病 ——
// **桩的形状不对时,症状永远是「数据为空」**。
function resp(obj, status) {
return {
ok: obj && obj.ok !== false,
status: status || 200,
json: async () => obj,
text: async () => JSON.stringify(obj),
blob: async () => ({}),
};
}
async function fetchStub(url, opts) {
FETCH_HITS++;
const method = (opts && opts.method) || 'GET';
let body = null;
if (opts && opts.body) {
try { body = JSON.parse(opts.body); } catch (e) { body = opts.body; }
}
REQS.push({ url, method, body });
if (/\/api\/snapshots?(\?|$)/.test(url) && method === 'GET') {
return resp({ ok: true, data: JSON.parse(JSON.stringify(SNAP_STATE)) });
}
if (/\/api\/snapshot\/note/.test(url)) {
return resp({ ok: true, data: { ts: body && body.ts, tag: body && body.tag },
msg_cn: (body && body.tag) ? '已保存快照备注' : '已清空快照备注' });
}
if (/\/api\/snapshot\/protect/.test(url)) {
if (PROTECT_FAILS) return resp({ ok: false, msg_cn: '快照不存在', data: {} });
return resp({ ok: true, data: { ts: body && body.ts, protected: !!(body && body.protected) },
msg_cn: (body && body.protected) ? '已上锁' : '已解除' });
}
if (/\/api\/snapshot\/delete/.test(url)) {
if (body && body.force) {
return resp({ ok: true, data: { ts: body.ts }, msg_cn: '已删除快照' });
}
if (DELETE_NEEDS_FORCE) {
return resp({ ok: false,
msg_cn: '这份快照已上锁,自动清理不会删除它。若确实要删除,请确认后重试。',
data: { needs_force: true, ts: body && body.ts, protected: true } });
}
return resp({ ok: true, data: { ts: body && body.ts }, msg_cn: '已删除快照' });
}
return resp({ ok: true, data: {}, msg_cn: 'ok' });
}
// ---------------------------------------------------------------- 沙箱
const ctx = {
document, fetch: fetchStub, console,
setTimeout: () => 0, clearTimeout() {},
setInterval: () => 0, clearInterval() {},
location: { hash: '', href: '' },
history: { replaceState() {} },
navigator: { userAgent: 'node' },
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
URL: { createObjectURL: () => 'blob:x', revokeObjectURL() {} },
Blob: function () {},
alert() {}, confirm: () => true, prompt: () => '',
EventSource: function () { return { close() {} }; },
WebSocket: function () { return { close() {}, send() {} }; },
FormData: function () { return {}; },
crypto: { randomUUID: () => 'uuid' },
CSS: { escape: s => String(s) },
__TOASTS: [],
};
ctx.window = ctx;
ctx.globalThis = ctx;
ctx.self = ctx;
vm.createContext(ctx);
try {
vm.runInContext(code, ctx, { filename: 'app.js' });
} catch (e) {
console.error('加载 app.js 失败:' + e.message);
process.exit(1);
}
const run = expr => vm.runInContext(expr, ctx);
const callListSnapshots = () => run('listSnapshots()');
const snapHtml = () => getEl('#sy-snapout').innerHTML;
const modals = () => run('globalThis.__modals');
// 捕获 modal / toast
vm.runInContext(
'globalThis.__modals = [];' +
'var __origModal = modal; modal = function(t,h,cb,txt){' +
' globalThis.__modals.push({t:t, h:String(h), txt:txt, cb:cb}); };' +
'globalThis.__origToast = toast; toast = function(m,k,d){' +
' globalThis.__TOASTS.push({m:String(m), k:String(k)});' +
' return __origToast(m,k,d); };', ctx);
// 用一批假元素接管 querySelector/querySelectorAll,
// 好在渲染完成后手动触发 listSnapshots 绑上的 handler。
function withFakes(fakeMap, inputEl, fn) {
const oQsa = document.querySelectorAll, oQs = document.querySelector;
document.querySelectorAll = sel => {
for (const key of Object.keys(fakeMap)) {
if (sel.indexOf(key) >= 0) return fakeMap[key];
}
return [];
};
document.querySelector = sel => {
if (inputEl && /\[data-tag=/.test(String(sel))) return inputEl;
return oQs(sel);
};
return Promise.resolve()
.then(fn)
.finally(() => { document.querySelectorAll = oQsa; document.querySelector = oQs; });
}
// ---------------------------------------------------------------- 主体
async function main() {
console.log('--- 前置 ---');
chk('app.js 里 listSnapshots 是顶层函数声明',
run('typeof listSnapshots') === 'function');
chk('app.js 里存在 $$ 辅助函数(事件绑定依赖它)',
run('typeof $$') === 'function', 'typeof $$ = ' + run('typeof $$'));
// ================================================================
console.log('--- 用例 1:内嵌渲染 + 备注框 + 上锁开关 ---');
SNAP_STATE = {
items: [
mkSnap('20260901-101010', '切换双栈前', true),
mkSnap('20260902-202020', 'auto-interval', false),
mkSnap('20260903-303030', '', false),
],
root: '/opt/drouter/snapshots',
scope: [{ group: '网络', items: ['/etc/drouter/wan.conf'] }],
disk: { total_mb: 8192, used_mb: 2048, free_mb: 6144 },
};
getEl('#sy-snapout')._html = '';
REQS.length = 0;
const before1 = FETCH_HITS;
await callListSnapshots();
const h1 = snapHtml();
chk('fetch 桩真的被调用了(否则下面那些「空」全是假的)',
FETCH_HITS > before1, 'FETCH_HITS 没涨');
chk('列表写进了 #sy-snapout(内嵌,不是弹窗)', h1.length > 0);
chk('确实向 /api/snapshots 要了数据',
REQS.some(r => /\/api\/snapshots/.test(r.url) && r.method === 'GET'),
JSON.stringify(REQS));
// ⛔ 数<tr> 时必须排除表头那一行,否则「3 份快照」会数出 4
// (thead 里也有一个 <tr>)。第一版就是这么写的,注释还特意
// 标注了「不含表头」而正则根本没排除 —— 注释与判据不一致,
// 是最容易让人怀疑产品有 bug 的一种错。
chk('渲染出 3 行数据行(不含表头)',
((h1.match(/<tr>/g) || []).length - 1) === 3,
'含表头共 ' + (h1.match(/<tr>/g) || []).length);
chk('备注是可编辑输入框(data-tag × 3)',
(h1.match(/data-tag=/g) || []).length === 3, (h1.match(/data-tag=/g) || []).length);
chk('输入框里带上了后端返回的备注值', h1.includes('切换双栈前'));
chk('空备注渲染成占位符而不是留白', h1.includes('(未命名)'));
chk('上锁开关 3 个(data-lock × 3)',
(h1.match(/data-lock=/g) || []).length === 3, (h1.match(/data-lock=/g) || []).length);
const lock1 = (h1.match(/<input[^>]*data-lock="20260901-101010"[^>]*>/) || [''])[0];
chk('受保护那份的开关带 checked', /\bchecked\b/.test(lock1), lock1);
const lock2 = (h1.match(/<input[^>]*data-lock="20260902-202020"[^>]*>/) || [''])[0];
chk('未上锁的开关没有 checked', !/\bchecked\b/.test(lock2), lock2);
chk('表头有「保护」这一列', />保护</.test(h1));
chk('统计行说明了已上锁份数', /份已上锁/.test(h1), h1.slice(0, 200));
chk('写出了存放路径', h1.includes('/opt/drouter/snapshots'));
chk('磁盘信息渲染出来', /剩余/.test(h1));
chk('下载/回滚/删除按钮各 3 个',
(h1.match(/data-dl=/g) || []).length === 3 &&
(h1.match(/data-rb=/g) || []).length === 3 &&
(h1.match(/data-del=/g) || []).length === 3);
chk('「存备注」按钮 3 个', (h1.match(/data-save-tag=/g) || []).length === 3);
// 覆盖范围说明渲染进的是**另一个容器** #sy-scope-box(它属于
// 「配置快照与回滚」卡片,不在救援通道里)。第一版在 snapHtml()
// 里找它当然找不到 —— 判据找错了容器,不是产品没渲染。
chk('覆盖范围说明渲染进了 #sy-scope-box',
/快照包含以下内容/.test(getEl('#sy-scope-box').innerHTML),
getEl('#sy-scope-box').innerHTML.slice(0, 120));
chk('没有回退到 modal 分支', !/modal\(/i.test(h1));
// ================================================================
console.log('--- 用例 2:空列表 ---');
SNAP_STATE = { items: [], root: '/opt/drouter/snapshots', scope: [], disk: null };
getEl('#sy-snapout')._html = '';
await callListSnapshots();
chk('空列表给出可操作提示(不是空白)',
/暂无快照/.test(snapHtml()) && /创建/.test(snapHtml()), snapHtml());
// ================================================================
console.log('--- 用例 3:XSS ---');
SNAP_STATE = {
items: [mkSnap('20260904-404040', '<img src=x onerror=alert(1)>', false)],
root: '/x', scope: [], disk: null,
};
getEl('#sy-snapout')._html = '';
await callListSnapshots();
const h3 = snapHtml();
chk('恶意备注被转义(没有裸 <img)',
!h3.includes('<img src=x') && /&lt;img/.test(h3), h3.slice(0, 400));
chk('转义后仍能看出原文(&lt;img)', /&lt;img/.test(h3));
// ================================================================
console.log('--- 用例 4:存备注与上锁的请求体 ---');
SNAP_STATE = { items: [mkSnap('20260905-505050', 'old', false)],
root: '/x', scope: [], disk: null };
const saveBtn = mkEl('save'); saveBtn.dataset.saveTag = '20260905-505050';
const lockBox = mkEl('lock'); lockBox.dataset.lock = '20260905-505050';
const tagInput = mkEl('tagin'); tagInput.value = '新备注';
await withFakes({
'data-save-tag': [saveBtn], 'data-lock': [lockBox],
}, tagInput, async () => {
REQS.length = 0;
await callListSnapshots();
chk('「存备注」绑上了 onclick', typeof saveBtn.onclick === 'function');
chk('上锁开关绑上了 onchange', typeof lockBox.onchange === 'function');
chk('上锁开关走 onchange 而非 onclick(避免重复提交)',
typeof lockBox.onchange === 'function' && typeof lockBox.onclick !== 'function');
if (typeof saveBtn.onclick === 'function') {
await saveBtn.onclick();
const r = REQS.find(x => x.url === '/api/snapshot/note');
chk('存备注打到 /api/snapshot/note(POST)', !!r && r.method === 'POST',
JSON.stringify(REQS));
chk('备注请求体带上 ts 与新的 tag',
!!r && r.body && r.body.ts === '20260905-505050' && r.body.tag === '新备注',
JSON.stringify(r && r.body));
chk('提交后按钮恢复可用(没卡在 disabled)', saveBtn.disabled === false);
}
REQS.length = 0;
if (typeof lockBox.onchange === 'function') {
lockBox.checked = true;
await lockBox.onchange();
const r = REQS.find(x => x.url === '/api/snapshot/protect');
chk('上锁开关打到 /api/snapshot/protect(POST)', !!r && r.method === 'POST',
JSON.stringify(REQS));
chk('上锁请求体带 ts 与 protected:true',
!!r && r.body && r.body.ts === '20260905-505050' && r.body.protected === true,
JSON.stringify(r && r.body));
}
// 后端拒绝时,界面要把勾去掉
REQS.length = 0;
PROTECT_FAILS = true;
if (typeof lockBox.onchange === 'function') {
lockBox.checked = true;
await lockBox.onchange();
PROTECT_FAILS = false;
chk('上锁被拒时界面把勾去掉(不显示后端不认同的状态)',
lockBox.checked === false, 'checked=' + lockBox.checked);
}
});
// ================================================================
console.log('--- 用例 5:删受保护快照 → 二次确认 → 带 force 重试 ---');
getEl('#sy-snapout')._html = '';
SNAP_STATE = { items: [mkSnap('20260906-606060', 'locked', true)],
root: '/x', scope: [], disk: null };
DELETE_NEEDS_FORCE = true;
run('globalThis.__modals.length = 0');
const delLocked = mkEl('dl'); delLocked.dataset.del = '20260906-606060';
await withFakes({ 'data-del': [delLocked] }, null, async () => {
REQS.length = 0;
await callListSnapshots();
chk('删除按钮绑上了', typeof delLocked.onclick === 'function');
if (typeof delLocked.onclick !== 'function') return;
await delLocked.onclick(); // 第一次:后端回 needs_force
const first = REQS.find(x => /snapshot\/delete/.test(x.url));
chk('第一次删除不带 force', !!first && !first.body.force,
JSON.stringify(first && first.body));
const ms = modals();
chk('needs_force 触发了二次确认弹窗', ms.length === 1, '弹窗数 ' + ms.length);
if (ms.length === 1) {
chk('弹窗标题点明「已上锁」', /已上锁/.test(ms[0].t), ms[0].t);
chk('弹窗说明上锁只挡自动清理', /自动清理不会删除它/.test(ms[0].h));
chk('弹窗按钮文案是「仍要删除」(不是「确定」)',
ms[0].txt === '仍要删除', ms[0].txt);
REQS.length = 0;
await ms[0].cb(); // 点确认 → 带 force 重试
const second = REQS.find(x => /snapshot\/delete/.test(x.url));
chk('确认后带 force=true 重试', !!second && second.body.force === true,
JSON.stringify(second && second.body));
}
});
// ================================================================
console.log('--- 用例 6:删未受保护快照 —— 一次就删,不多问 ---');
getEl('#sy-snapout')._html = '';
SNAP_STATE = { items: [mkSnap('20260907-707070', 'plain', false)],
root: '/x', scope: [], disk: null };
DELETE_NEEDS_FORCE = false;
run('globalThis.__modals.length = 0');
const delPlain = mkEl('dp'); delPlain.dataset.del = '20260907-707070';
await withFakes({ 'data-del': [delPlain] }, null, async () => {
REQS.length = 0;
await callListSnapshots();
if (typeof delPlain.onclick !== 'function') {
chk('删除按钮绑上了', false);
return;
}
await delPlain.onclick();
chk('未受保护的快照不弹二次确认', modals().length === 0,
'弹窗数 ' + modals().length);
const r = REQS.find(x => /snapshot\/delete/.test(x.url));
chk('一次就删(不带 force)', !!r && !r.body.force,
JSON.stringify(r && r.body));
});
// ================================================================
console.log('--- 用例 7:表格结构自洽 ---');
getEl('#sy-snapout')._html = '';
SNAP_STATE = { items: [mkSnap('20260908-808080', 'a', false),
mkSnap('20260909-909090', 'b', true)], root: '/x', scope: [], disk: null };
await callListSnapshots();
const h7 = snapHtml();
// ⛔ `<th[^>]*>` 连 `<thead>` 一起匹配了(thead 的 t-h-e-a-d
// 恰好满足 `<th` + `>`)。所以要么带上前导空白限定,要么直接
// 剔掉 thead。数出来 7 而产品明明是 6 列 —— 判据自己的锅。
const thCount = (snapHtml().match(/<th[\s>]/g) || []).length;
chk('表头 6 列(时间/备注/保护/大小/配置库/操作)',
thCount === 6, '实际 ' + thCount);
chk('每行 6 个单元格(2 行 = 12)',
(h7.match(/<td[^>]*>/g) || []).length === 12,
(h7.match(/<td[^>]*>/g) || []).length);
chk('两行里恰好一行为checked', (h7.match(/data-lock="[^"]*"[^>]*checked/g) || []).length === 1,
(h7.match(/data-lock="[^"]*"[^>]*checked/g) || []).length);
}
main().then(() => {
console.log();
console.log('='.repeat(66));
console.log('通过 ' + pass + ' 项,失败 ' + fail + ' 项');
if (fail) { console.log(); bad.forEach(d => console.log(' · ' + d)); }
console.log('='.repeat(66));
process.exit(fail ? 1 : 0);
}).catch(e => {
console.error('主流程异常:');
console.error(e.stack || e.message);
console.log();
console.log('通过 ' + pass + ' 项,失败 ' + (fail + 1) + ' 项(主流程异常)');
process.exit(1);
});