## 问题(皇上指出) 项目里两套编号并行,无法判断哪个是"当前版本": - **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0 - **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档) 时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。 ## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范) - **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent) - **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名** - 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准) ## 统一动作 1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0 2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版 3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0 4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0; 补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除) 5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身 ## 保留项(真实发布版本,不动) v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag) ## 验证 - 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓ - v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期) - 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
185 lines
6.4 KiB
JavaScript
185 lines
6.4 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import {
|
||
initAudit,
|
||
audit,
|
||
computeAuditHead,
|
||
anchorAudit,
|
||
compareWithAnchors,
|
||
anchorFilePath,
|
||
} from '../../apps/agent/dist/audit.js';
|
||
|
||
/**
|
||
* v2.0.0 审计链外部锚定的逻辑测试。
|
||
*
|
||
* 守的是三件最容易写错的事:
|
||
* 1. 只记 head_hash 会把**轮转**误判成篡改(必须连 entries + rotated_segments 一起记)
|
||
* 2. 整链重写(条目数变少且无轮转)必须被识别
|
||
* 3. 条目数相同但哈希不同 = 该段被重写,必须被识别
|
||
*/
|
||
|
||
function freshDir() {
|
||
return mkdtempSync(join(tmpdir(), 'audit-anchor-'));
|
||
}
|
||
|
||
test('computeAuditHead:空链返回 null 链头而非报错', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
const h = computeAuditHead();
|
||
assert.equal(h.entries, 0);
|
||
assert.equal(h.head_hash, null);
|
||
assert.equal(h.head_ts, null);
|
||
assert.equal(h.rotated_segments, 0);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('anchorAudit:写出的锚点含四元组(entries + hash + ts + rotated)', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
audit({ type: 'agent.start' });
|
||
audit({ type: 'auth.success', client_id: 'mac_01' });
|
||
const file = join(dir, 'anchors.jsonl');
|
||
const r = anchorAudit({ path: file, note: 'unit-test' });
|
||
assert.equal(r.total_lines, 1);
|
||
assert.equal(r.record.entries, 2);
|
||
assert.ok(r.record.head_hash, '链头哈希应存在');
|
||
assert.equal(r.record.rotated_segments, 0);
|
||
assert.equal(r.record.note, 'unit-test');
|
||
// 追加式:再锚一次应有两行
|
||
anchorAudit({ path: file });
|
||
assert.equal(readFileSync(file, 'utf8').trim().split('\n').length, 2);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('compareWithAnchors:链未变化 → 三者相符', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
audit({ type: 'agent.start' });
|
||
const file = join(dir, 'anchors.jsonl');
|
||
anchorAudit({ path: file });
|
||
const c = compareWithAnchors(file);
|
||
assert.equal(c.ok, true);
|
||
assert.match(c.verdict, /完全一致/);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('compareWithAnchors:链增长 → ok 且说明锚点仍在链上', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
audit({ type: 'agent.start' });
|
||
const file = join(dir, 'anchors.jsonl');
|
||
anchorAudit({ path: file });
|
||
audit({ type: 'invoke', capability: 'system.info', status: 'ok' });
|
||
const c = compareWithAnchors(file);
|
||
assert.equal(c.ok, true);
|
||
assert.match(c.verdict, /已增长|轮转/);
|
||
assert.equal(c.detail.current_entries, 2);
|
||
assert.equal(c.detail.anchored_entries, 1);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('compareWithAnchors:**整链重写**(条目数变少且无轮转)必须被抓', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
audit({ type: 'agent.start' });
|
||
audit({ type: 'auth.success', client_id: 'mac_01' });
|
||
audit({ type: 'invoke', capability: 'system.info', status: 'ok' });
|
||
const file = join(dir, 'anchors.jsonl');
|
||
anchorAudit({ path: file });
|
||
|
||
// 模拟攻击者:把审计文件换成一份只有 1 条的自洽假链
|
||
const auditFile = join(dir, 'audit.log');
|
||
const one = readFileSync(auditFile, 'utf8').trim().split('\n')[0];
|
||
writeFileSync(auditFile, `${one}\n`, 'utf8');
|
||
|
||
const c = compareWithAnchors(file);
|
||
assert.equal(c.ok, false, '整链重写必须判为不可信');
|
||
assert.match(c.verdict, /整链重写|回滚/);
|
||
assert.equal(c.detail.anchored_entries, 3);
|
||
assert.equal(c.detail.current_entries, 1);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('compareWithAnchors:条目数相同但链头哈希不同 → 该段被重写', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
audit({ type: 'agent.start' });
|
||
audit({ type: 'auth.success', client_id: 'mac_01' });
|
||
const file = join(dir, 'anchors.jsonl');
|
||
anchorAudit({ path: file });
|
||
|
||
// 篡改末条内容但保持条目数不变(链内自洽性会被 verifyAudit 抓,这里测的是与锚点的比对)
|
||
const auditFile = join(dir, 'audit.log');
|
||
const lines = readFileSync(auditFile, 'utf8').trim().split('\n');
|
||
const last = JSON.parse(lines[1]);
|
||
last.hash = 'deadbeefdeadbeef';
|
||
lines[1] = JSON.stringify(last);
|
||
writeFileSync(auditFile, `${lines.join('\n')}\n`, 'utf8');
|
||
|
||
const c = compareWithAnchors(file);
|
||
assert.equal(c.ok, false);
|
||
assert.match(c.verdict, /链头哈希与锚点不一致/);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('compareWithAnchors:无锚点文件时明确提示先建锚点(不算失败)', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
const c = compareWithAnchors(join(dir, 'nope.jsonl'));
|
||
assert.equal(c.ok, true);
|
||
assert.equal(c.anchors_checked, 0);
|
||
assert.match(c.verdict, /尚无锚点/);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('anchorFilePath:默认落在数据目录,可被 path 覆盖', () => {
|
||
const def = anchorFilePath();
|
||
assert.match(def, /audit-anchors\.jsonl$/);
|
||
assert.equal(anchorFilePath(' /tmp/custom.jsonl '), '/tmp/custom.jsonl', '应 trim');
|
||
assert.match(anchorFilePath(''), /audit-anchors\.jsonl$/, '空串回退默认');
|
||
});
|
||
|
||
test('锚点文件是追加式:历史锚点不会被后续覆盖(防"重写链后刷锚点")', () => {
|
||
const dir = freshDir();
|
||
try {
|
||
initAudit({}, dir);
|
||
audit({ type: 'agent.start' });
|
||
const file = join(dir, 'anchors.jsonl');
|
||
anchorAudit({ path: file, note: 'first' });
|
||
audit({ type: 'invoke', capability: 'x', status: 'ok' });
|
||
anchorAudit({ path: file, note: 'second' });
|
||
const lines = readFileSync(file, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
||
assert.equal(lines.length, 2);
|
||
assert.equal(lines[0].note, 'first');
|
||
assert.equal(lines[0].entries, 1, '第一条锚点必须保持原值');
|
||
assert.equal(lines[1].entries, 2);
|
||
assert.ok(existsSync(file));
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|