【本次核心 · S6-P21】 - 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams; data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。 - 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。 - 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 / 变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。 - 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成 TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed; 三份 CSS md5 完全相同 = 一份样式表服务三个组件。 - 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、 tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。 【同时清掉此前已完成但未提交的批次】 生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告) 跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交: - Package:三端可 import(S5-P18)+ 发布到私有 npm 源 - Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性 - Security:生产部署链审计修复(2026-09-19)+ 线上部署 - Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性; S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程 - 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、 安全审计修复与待决策项.md 【验收】 - node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变) - node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44) - node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时 - 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误 - 零运行时依赖 OK;build-site.ps1 ASCII-only OK 【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
171 lines
7.7 KiB
JavaScript
171 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-dev-server.mjs — 静态服务的健壮性验证(零依赖,只用 node 内置模块)。
|
||
*
|
||
* 背景(实测缺陷 P1):
|
||
* 修复前 `site/dev-server.js` 的请求回调直接调用 fs.readFile(filePath, cb),
|
||
* 而 decodeURIComponent('%00') 会解出 NUL 字符;fs.readFile 对含 NUL 的路径**同步**
|
||
* 抛 TypeError [ERR_INVALID_ARG_VALUE],异常逃逸出请求回调 → 整个进程退出。
|
||
* 即:单个畸形请求 = 一次 DoS。
|
||
*
|
||
* 反例断言(修复前必然失败,用来区分修复前后):
|
||
* [COUNTEREXAMPLE] MALFORMED_BURST_SERVER_ALIVE
|
||
* —— 发完 %00 / 穿越 / 双重编码 NUL 请求后,子进程仍存活(exitCode === null),
|
||
* 且再发一次正常请求仍为 200;stderr 不含 ERR_INVALID_ARG_VALUE。
|
||
* 修复前的真实表现(本机实测,修复前工作树副本):
|
||
* GET /site/%00 -> 客户端 ECONNRESET
|
||
* child.exitCode -> 1
|
||
* 其后 GET /site/ -> ECONNREFUSED
|
||
* stderr: TypeError [ERR_INVALID_ARG_VALUE]: The argument 'path' must be a string,
|
||
* Uint8Array, or URL without null bytes.
|
||
* at Object.readFile (node:fs:385:16)
|
||
* at Server.<anonymous> (.../site/dev-server.js:106:6)
|
||
*
|
||
* 端口:默认用 13311,避免与正在运行的 3311 实例抢端口;可用 AA_PORT 覆盖。
|
||
* 验证脚本把 AA_PORT 传给子进程(这也是 dev-server.js 新增的唯一开关)。
|
||
*
|
||
* 运行:node tools/verify-dev-server.mjs
|
||
* 退出码:0 = 全部通过;1 = 有断言失败 / 服务起不来。
|
||
*/
|
||
import { spawn } from 'node:child_process';
|
||
import { request } from 'node:http';
|
||
import { join } from 'node:path';
|
||
|
||
const ROOT = join(import.meta.dirname, '..');
|
||
const HOST = '127.0.0.1';
|
||
const PORT = Number(process.env.AA_PORT) || 13311;
|
||
const BASELINE_PORT = 13311;
|
||
|
||
let failed = 0;
|
||
let passed = 0;
|
||
const results = [];
|
||
function check(name, ok, detail = '') {
|
||
results.push({ name, ok, detail });
|
||
if (ok) passed++;
|
||
else failed++;
|
||
}
|
||
|
||
function get(path) {
|
||
return new Promise((resolve) => {
|
||
const req = request({ host: HOST, port: PORT, path, method: 'GET' }, (res) => {
|
||
let body = '';
|
||
res.setEncoding('utf8');
|
||
res.on('data', (chunk) => { body += chunk; });
|
||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, error: null }));
|
||
});
|
||
req.on('error', (e) => resolve({ status: 0, headers: {}, body: '', error: e.code || e.message }));
|
||
req.setTimeout(5000, () => { req.destroy(); resolve({ status: 0, headers: {}, body: '', error: 'TIMEOUT' }); });
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
console.log(`[dev-server] 启动子进程 node site/dev-server.js (AA_PORT=${PORT}, HOST=${HOST})`);
|
||
const child = spawn(process.execPath, [join('site', 'dev-server.js')], {
|
||
cwd: ROOT,
|
||
env: { ...process.env, AA_PORT: String(PORT) },
|
||
stdio: ['ignore', 'pipe', 'pipe']
|
||
});
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.on('data', (d) => { stdout += d; });
|
||
child.stderr.on('data', (d) => { stderr += d; });
|
||
|
||
const watchdog = setTimeout(() => {
|
||
console.error('[dev-server] 看门狗超时(20s),强制收尾');
|
||
try { child.kill(); } catch { /* 已退出 */ }
|
||
process.exit(1);
|
||
}, 20000);
|
||
|
||
/* ---------- 1. 等待就绪 ---------- */
|
||
let ready = null;
|
||
for (let i = 0; i < 40; i++) {
|
||
if (child.exitCode !== null) break;
|
||
const r = await get('/site/');
|
||
if (r.status === 200) { ready = r; break; }
|
||
await sleep(250);
|
||
}
|
||
check('server starts and answers GET /site/ with 200', ready !== null,
|
||
ready ? `status=${ready.status}` : `子进程未就绪 exitCode=${child.exitCode} stderr=${JSON.stringify(stderr.slice(0, 200))}`);
|
||
check('AA_PORT honored in startup banner', stdout.includes(`:${PORT}/site/`),
|
||
`banner=${JSON.stringify(stdout.trim().split('\n')[0] || '')}`);
|
||
if (ready) {
|
||
check('GET /site/ serves the real site document', /<!DOCTYPE html>/i.test(ready.body) && ready.body.length > 200,
|
||
`bytes=${ready.body.length}`);
|
||
check('security headers kept (CSP + nosniff)', /default-src 'self'/.test(ready.headers['content-security-policy'] || '') &&
|
||
ready.headers['x-content-type-options'] === 'nosniff');
|
||
}
|
||
|
||
/* ---------- 2. 既有行为回归(不得被本次修复改动) ---------- */
|
||
const rootRedirect = await get('/');
|
||
check('GET / -> 302 to /site/', rootRedirect.status === 302 && rootRedirect.headers.location === '/site/',
|
||
`status=${rootRedirect.status} location=${rootRedirect.headers.location}`);
|
||
|
||
const indexHtml = await get('/site/index.html');
|
||
check('GET /site/index.html -> 200 text/html', indexHtml.status === 200 &&
|
||
/text\/html/.test(indexHtml.headers['content-type'] || ''), `status=${indexHtml.status}`);
|
||
|
||
const appJs = await get('/site/app.js');
|
||
check('GET /site/app.js -> 200 text/javascript', appJs.status === 200 &&
|
||
/text\/javascript/.test(appJs.headers['content-type'] || ''), `status=${appJs.status}`);
|
||
|
||
const missing = await get('/site/definitely-missing-xyz.html');
|
||
check('missing file -> 404 with unchanged wording', missing.status === 404 &&
|
||
missing.body === '404 Not Found: /site/definitely-missing-xyz.html', `status=${missing.status}`);
|
||
|
||
/* ---------- 3. 畸形请求矩阵(全部必须 4xx,且不得打挂进程) ---------- */
|
||
const malformed = [
|
||
['/site/%00', 'NUL 编码'],
|
||
['/site/%2500', '双重编码 NUL(%2500)'],
|
||
['/site/%00.html', 'NUL 编码 + 后缀'],
|
||
['/site/sub/%2500', '子目录下的双重编码 NUL'],
|
||
['/site/../package.json', '相对穿越'],
|
||
['/..%2fpackage.json', '编码穿越 %2f'],
|
||
['/site/%2e%2e/package.json', '编码穿越 %2e'],
|
||
['/site/.git/config', '敏感段 .git']
|
||
];
|
||
const malformedSeen = [];
|
||
for (const [path, label] of malformed) {
|
||
const r = await get(path);
|
||
malformedSeen.push(`${path}(${label}) -> ${r.status || r.error}`);
|
||
check(`malformed ${path} (${label}) -> 4xx`,
|
||
r.status >= 400 && r.status < 500, `status=${r.status || r.error}`);
|
||
}
|
||
|
||
/* ---------- 4. 反例断言:修复前必然失败 ---------- */
|
||
await sleep(300);
|
||
const aliveExitCode = child.exitCode;
|
||
const afterBurst = await get('/site/');
|
||
check('[COUNTEREXAMPLE] MALFORMED_BURST_SERVER_ALIVE — 畸形请求突发后进程仍存活',
|
||
aliveExitCode === null, `child.exitCode=${aliveExitCode}(修复前为 1)`);
|
||
check('[COUNTEREXAMPLE] MALFORMED_BURST_STILL_SERVING — 突发后正常请求仍 200',
|
||
afterBurst.status === 200, `status=${afterBurst.status || afterBurst.error}(修复前为 ECONNREFUSED)`);
|
||
check('[COUNTEREXAMPLE] NO_FATAL_STDERR — stderr 不含 ERR_INVALID_ARG_VALUE 崩溃栈',
|
||
!/ERR_INVALID_ARG_VALUE/.test(stderr), `stderr=${JSON.stringify(stderr.slice(0, 160))}`);
|
||
check('repeated NUL requests stay handled (10x)', await (async () => {
|
||
for (let i = 0; i < 10; i++) {
|
||
const r = await get('/site/%00');
|
||
if (!(r.status >= 400 && r.status < 500)) return false;
|
||
}
|
||
const last = await get('/site/');
|
||
return last.status === 200 && child.exitCode === null;
|
||
})(), '10 次 %00 后仍存活且仍 4xx');
|
||
|
||
/* ---------- 收尾 ---------- */
|
||
try { child.kill(); } catch { /* 已退出 */ }
|
||
clearTimeout(watchdog);
|
||
|
||
console.log('');
|
||
console.log(`[dev-server] 端口 ${PORT}${PORT === BASELINE_PORT ? '(默认验证端口,未占用 3311)' : '(AA_PORT 覆盖)'}`);
|
||
console.log(`[dev-server] 畸形请求实测:${malformedSeen.join(' | ')}`);
|
||
for (const r of results) {
|
||
console.log(`[dev-server] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
|
||
}
|
||
if (failed) {
|
||
console.error(`\n[dev-server] ${failed} check(s) failed / ${passed} passed`);
|
||
process.exit(1);
|
||
}
|
||
console.log(`\n[dev-server] OK — ${passed} checks passed(含 3 条修复前必失败的反例断言)`);
|
||
process.exit(0);
|