2026-09-18 08:08:51 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 覆盖网络线 · 序㉔「内容分发(块级内容寻址)」单测。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 本文件**不在** `package.json` 的 `test` 脚本文件列表里(交接单 §3.1 禁止改那张列表)
|
|
|
|
|
|
* ⇒ 单跑:`node --test test/overlay-content.test.mjs`
|
|
|
|
|
|
* (`npm test` 的基线与"本文件新增用例数"分开报,见交接单 §8 的 ④)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 判据对应关系(交接单 §1):
|
|
|
|
|
|
* - **E2** 只拿到一部分也能开始共享 ⇒ 「切分与部分持有」组
|
|
|
|
|
|
* - **E3** 版本更新只传变化块 ⇒ 「局部性」组
|
|
|
|
|
|
* - **E4** 客户端校验哈希 ⇒ 「校验」组
|
|
|
|
|
|
* - **E5** 分组隔离 ⇒ 「分组」组
|
|
|
|
|
|
* - **E6** 内容源优先级可观测 ⇒ 「优先级链」组
|
|
|
|
|
|
*
|
|
|
|
|
|
* 纪律(本线反复踩过的坑):
|
|
|
|
|
|
* - ⛔ **只写日志不算计数** ⇒ 每个判别器断言的是**数字递增**,不是"调用没抛错";
|
|
|
|
|
|
* - ⛔ **不许放宽判据凑绿** ⇒ 断言用**精确等值**(`deepStrictEqual` / `strictEqual`),
|
|
|
|
|
|
* 不用 `>=` 这类可以让实现变差仍然通过的写法(除非判据本身就要求"至少")。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { test } from 'node:test'
|
|
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
DEFAULT_BLOCK_SIZE,
|
|
|
|
|
|
BLOCK_ID_HEX_LEN,
|
|
|
|
|
|
blockIdOf,
|
|
|
|
|
|
chunkify,
|
|
|
|
|
|
contentIdOf,
|
|
|
|
|
|
isBlockId,
|
|
|
|
|
|
planOf,
|
|
|
|
|
|
reassemble,
|
|
|
|
|
|
} from '../lib/net/relay/content/chunker.js'
|
|
|
|
|
|
import { ContentStore, DEFAULT_MAX_BYTES } from '../lib/net/relay/content/store.js'
|
|
|
|
|
|
import {
|
|
|
|
|
|
ContentSourceChain,
|
|
|
|
|
|
SOURCE_TIERS,
|
|
|
|
|
|
DEFAULT_TIER_ORDER,
|
|
|
|
|
|
emptySourceCounters,
|
|
|
|
|
|
} from '../lib/net/relay/content/source.js'
|
|
|
|
|
|
import { ContentPeerGroup, groupKeyOf, sameGroup, PEER_COUNTER_KEYS } from '../lib/net/relay/content/peer.js'
|
|
|
|
|
|
|
|
|
|
|
|
/** 造一段可复现的伪随机内容(⛔ 不用 `Math.random` —— 用例必须可复现)。 */
|
|
|
|
|
|
function makeBytes(size, seed = 1) {
|
|
|
|
|
|
const b = Buffer.alloc(size)
|
|
|
|
|
|
let x = seed >>> 0
|
|
|
|
|
|
for (let i = 0; i < size; i += 1) {
|
|
|
|
|
|
// xorshift32:确定性、够散、零依赖
|
|
|
|
|
|
x ^= x << 13
|
|
|
|
|
|
x >>>= 0
|
|
|
|
|
|
x ^= x >>> 17
|
|
|
|
|
|
x ^= x << 5
|
|
|
|
|
|
x >>>= 0
|
|
|
|
|
|
b[i] = x & 0xff
|
|
|
|
|
|
}
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 1:切分(S1 的核心不变量)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: 同一份内容切两次 ⇒ 块 id 序列完全一致(确定性)', () => {
|
|
|
|
|
|
const buf = makeBytes(3 * DEFAULT_BLOCK_SIZE + 12345)
|
|
|
|
|
|
const a = chunkify(buf)
|
|
|
|
|
|
const b = chunkify(buf)
|
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
|
a.chunks.map((c) => c.id),
|
|
|
|
|
|
b.chunks.map((c) => c.id),
|
|
|
|
|
|
'同一份字节两次切分必须给出逐位相同的块 id 序列',
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.strictEqual(a.contentId, b.contentId)
|
|
|
|
|
|
assert.strictEqual(a.size, buf.length)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: 块 id 只由字节决定(改 1 字节 ⇒ 只有 1 块变化)', () => {
|
|
|
|
|
|
const size = 3 * DEFAULT_BLOCK_SIZE
|
|
|
|
|
|
const before = makeBytes(size)
|
|
|
|
|
|
const after = Buffer.from(before)
|
|
|
|
|
|
// 改第 2 块中间的一个字节(偏移显然落在第二块内)
|
|
|
|
|
|
const flipAt = DEFAULT_BLOCK_SIZE + 100
|
|
|
|
|
|
after[flipAt] = after[flipAt] ^ 0xff
|
|
|
|
|
|
|
|
|
|
|
|
const p0 = planOf(before)
|
|
|
|
|
|
const p1 = planOf(after)
|
|
|
|
|
|
assert.strictEqual(p0.ids.length, p1.ids.length, '块数不变')
|
|
|
|
|
|
|
|
|
|
|
|
const changed = []
|
|
|
|
|
|
for (let i = 0; i < p0.ids.length; i += 1) {
|
|
|
|
|
|
if (p0.ids[i] !== p1.ids[i]) changed.push(i)
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.deepStrictEqual(changed, [1], `改 1 字节应只影响第 2 块(index=1),实际变了 ${JSON.stringify(changed)}`)
|
|
|
|
|
|
// 其余块 id 必须逐位相同 ⇒ 对端持有的那几块**不用重传**(E3)
|
|
|
|
|
|
assert.strictEqual(p0.ids[0], p1.ids[0])
|
|
|
|
|
|
assert.strictEqual(p0.ids[2], p1.ids[2])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: 块 id 不掺序号/长度(同内容不同位置 ⇒ 同 id ⇒ 可去重)', () => {
|
|
|
|
|
|
const block = makeBytes(1024, 42)
|
|
|
|
|
|
const dup = Buffer.concat([block, makeBytes(1024, 43), block]) // 同一段内容出现两次
|
|
|
|
|
|
const r = chunkify(dup, 1024)
|
|
|
|
|
|
assert.strictEqual(r.chunks.length, 3)
|
|
|
|
|
|
assert.strictEqual(r.chunks[0].id, r.chunks[2].id, '同内容必须同 id —— id 不得掺入序号')
|
|
|
|
|
|
assert.deepStrictEqual(r.ids, [r.chunks[0].id, r.chunks[1].id], '去重后的 id 列表只保留首次出现序')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: id 形状与长度(小写 hex,恰 32 位)', () => {
|
|
|
|
|
|
const id = blockIdOf(Buffer.from('hello'))
|
|
|
|
|
|
assert.ok(isBlockId(id), `${id} 应为合法块 id`)
|
|
|
|
|
|
assert.strictEqual(id.length, BLOCK_ID_HEX_LEN)
|
|
|
|
|
|
assert.ok(!isBlockId(id.toUpperCase()), '大写 hex 不是合法 id(口径收紧 = 避免"看着像但实际上不同")')
|
|
|
|
|
|
assert.ok(!isBlockId('zz'), '乱码不是合法 id')
|
|
|
|
|
|
assert.strictEqual(contentIdOf(Buffer.from('hello')), createHash('sha256').update(Buffer.from('hello')).digest('hex').slice(0, BLOCK_ID_HEX_LEN))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: id 算法**逐字节钉死**(= sha256(内容) 前 32 hex,⛔ 不得掺任何东西)', () => {
|
|
|
|
|
|
// 🔑 为什么必须有这条:id 算法一旦掺入"序号 / 长度 / 来源 / 时间",
|
|
|
|
|
|
// 同内容会得到不同 id ⇒ **去重与共享同时静默失效**,而"两次切分一致"这类
|
|
|
|
|
|
// 自洽性断言**照样全绿**(先红后绿实测:只改 id 算法时旧用例一条都不红 ⇒ 覆盖缺口)。
|
|
|
|
|
|
// 故这里用**外部复算**(node:crypto 直接算)做绝对锚,而不依赖实现自洽。
|
|
|
|
|
|
const cases = [
|
|
|
|
|
|
Buffer.alloc(0),
|
|
|
|
|
|
Buffer.from('hello'),
|
|
|
|
|
|
Buffer.from([0x00]),
|
|
|
|
|
|
Buffer.from([0xff, 0x00, 0xff]),
|
|
|
|
|
|
makeBytes(1024, 1),
|
|
|
|
|
|
makeBytes(1024, 2),
|
|
|
|
|
|
makeBytes(2048, 1),
|
|
|
|
|
|
]
|
|
|
|
|
|
for (const buf of cases) {
|
|
|
|
|
|
const expected = createHash('sha256').update(buf).digest('hex').slice(0, BLOCK_ID_HEX_LEN)
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
|
|
blockIdOf(buf),
|
|
|
|
|
|
expected,
|
|
|
|
|
|
`id 必须恰为 sha256(内容) 的前 ${BLOCK_ID_HEX_LEN} 位 hex(内容 ${buf.length}B)`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 长度相同、内容不同 ⇒ id 必须不同(防"掺长度"这类退化)
|
|
|
|
|
|
assert.notStrictEqual(blockIdOf(makeBytes(1024, 1)), blockIdOf(makeBytes(1024, 2)))
|
|
|
|
|
|
// 长度不同但**前缀相同** ⇒ id 也必须不同(防"截断 / 掺长度"这类退化)
|
|
|
|
|
|
const prefix = makeBytes(2048, 9)
|
|
|
|
|
|
assert.notStrictEqual(blockIdOf(prefix), blockIdOf(prefix.subarray(0, 1024)))
|
|
|
|
|
|
// 整份内容 id 同理钉死
|
|
|
|
|
|
const whole = makeBytes(4096, 3)
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
|
|
contentIdOf(whole),
|
|
|
|
|
|
createHash('sha256').update(whole).digest('hex').slice(0, BLOCK_ID_HEX_LEN),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: 空内容与不足一块的边界', () => {
|
|
|
|
|
|
const empty = chunkify(Buffer.alloc(0))
|
|
|
|
|
|
assert.strictEqual(empty.chunks.length, 0)
|
|
|
|
|
|
assert.strictEqual(empty.size, 0)
|
|
|
|
|
|
|
|
|
|
|
|
const small = chunkify(Buffer.from('abc'), 1024)
|
|
|
|
|
|
assert.strictEqual(small.chunks.length, 1)
|
|
|
|
|
|
assert.strictEqual(small.chunks[0].offset, 0)
|
|
|
|
|
|
assert.strictEqual(small.chunks[0].bytes.length, 3)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: 非法块大小必须抛错(⛔ 不许静默取默认)', () => {
|
|
|
|
|
|
assert.throws(() => chunkify(Buffer.from('x'), 0), /正整数/)
|
|
|
|
|
|
assert.throws(() => chunkify(Buffer.from('x'), -1), /正整数/)
|
|
|
|
|
|
assert.throws(() => chunkify(Buffer.from('x'), 1.5), /正整数/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('chunker: subarray 视图陷阱 —— 块字节必须已复制(不随源 buffer 漂移)', () => {
|
|
|
|
|
|
const src = makeBytes(2 * DEFAULT_BLOCK_SIZE)
|
|
|
|
|
|
const r = chunkify(src)
|
|
|
|
|
|
const idBefore = r.chunks[0].id
|
|
|
|
|
|
const saved = Buffer.from(r.chunks[0].bytes)
|
|
|
|
|
|
// 原地改源 buffer:已切出的块**不应**受影响
|
|
|
|
|
|
src[0] = src[0] ^ 0xff
|
|
|
|
|
|
assert.ok(r.chunks[0].bytes.equals(saved), '切出的块必须与源 buffer 脱钩')
|
|
|
|
|
|
assert.strictEqual(blockIdOf(r.chunks[0].bytes), idBefore)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 2:校验(E4 —— 篡改块必须被丢弃,⛔ 不落盘)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('E4: store.put 拒绝内容与声明 id 不符的块,并计数', () => {
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 4 * 1024 * 1024 })
|
|
|
|
|
|
const good = makeBytes(1000, 7)
|
|
|
|
|
|
const id = blockIdOf(good)
|
|
|
|
|
|
store.put(id, good)
|
|
|
|
|
|
assert.strictEqual(store.get(id)?.equals(good), true)
|
|
|
|
|
|
|
|
|
|
|
|
const tampered = Buffer.from(good)
|
|
|
|
|
|
tampered[10] = tampered[10] ^ 0x01
|
|
|
|
|
|
const before = store.counters()
|
|
|
|
|
|
assert.throws(() => store.put(id, tampered), /块校验失败|丢弃/)
|
|
|
|
|
|
const after = store.counters()
|
|
|
|
|
|
assert.strictEqual(after.putRejected, before.putRejected + 1, 'E4 写侧:被拒次数必须 +1(⛔ 只写日志不算)')
|
|
|
|
|
|
assert.strictEqual(after.puts, before.puts, '被拒的块不得计入成功入库')
|
|
|
|
|
|
// ⛔ 不落盘:仓库里那一份仍然是好的
|
|
|
|
|
|
assert.strictEqual(store.get(id)?.equals(good), true, '原块必须完好(篡改块没覆盖它)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E4: store.get 读出损坏块 ⇒ 丢弃 + corruptReads 递增(不返回坏数据)', () => {
|
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'dshs-content-'))
|
|
|
|
|
|
try {
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 4 * 1024 * 1024, dir })
|
|
|
|
|
|
const bytes = makeBytes(2048, 11)
|
|
|
|
|
|
const id = blockIdOf(bytes)
|
|
|
|
|
|
store.put(id, bytes)
|
|
|
|
|
|
|
|
|
|
|
|
// 绕过 store 直接在盘上改坏(模拟"磁盘写坏 / 进程外改动")—— 内存里有副本,先建个空 store 走盘路径
|
|
|
|
|
|
const store2 = new ContentStore({ maxBytes: 4 * 1024 * 1024, dir })
|
|
|
|
|
|
const bad = Buffer.from(bytes)
|
|
|
|
|
|
bad[3] = bad[3] ^ 0xff
|
|
|
|
|
|
writeFileSync(join(dir, id), bad)
|
|
|
|
|
|
|
|
|
|
|
|
const c0 = store2.counters()
|
|
|
|
|
|
const got = store2.get(id)
|
|
|
|
|
|
const c1 = store2.counters()
|
|
|
|
|
|
assert.strictEqual(got, undefined, '损坏块必须返回 undefined')
|
|
|
|
|
|
assert.strictEqual(c1.corruptReads, c0.corruptReads + 1, 'E4 读侧:损坏读必须 +1(⛔ 不许静默当"没有")')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
rmSync(dir, { recursive: true, force: true })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E4: reassemble 在缺块 / 篡改块时抛错并点名 index', () => {
|
|
|
|
|
|
const buf = makeBytes(2 * 1024 + 10, 5)
|
|
|
|
|
|
const r = chunkify(buf, 1024)
|
|
|
|
|
|
const plan = r.ids
|
|
|
|
|
|
const parts = new Map()
|
|
|
|
|
|
for (const c of r.chunks) parts.set(c.id, c.bytes)
|
|
|
|
|
|
assert.ok(reassemble(plan, parts).equals(buf), '完好块集必须能重组回原内容')
|
|
|
|
|
|
|
|
|
|
|
|
// 缺一块
|
|
|
|
|
|
const missing = new Map(parts)
|
|
|
|
|
|
missing.delete(plan[1])
|
|
|
|
|
|
assert.throws(() => reassemble(plan, missing), /缺少块 index=1/)
|
|
|
|
|
|
|
|
|
|
|
|
// 篡改一块(id 对不上)
|
|
|
|
|
|
const tampered = new Map(parts)
|
|
|
|
|
|
const t = Buffer.from(parts.get(plan[1]))
|
|
|
|
|
|
t[0] = t[0] ^ 0xff
|
|
|
|
|
|
tampered.set(plan[1], t)
|
|
|
|
|
|
assert.throws(() => reassemble(plan, tampered), /块校验失败 index=1/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 3:内容寻址存储(S1 —— 去重 / LRU / 计数)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('store: 同内容重复入库 ⇒ 去重(容量不重复计,命中仍计数)', () => {
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 1024 * 1024 })
|
|
|
|
|
|
const bytes = makeBytes(4096, 3)
|
|
|
|
|
|
const id = blockIdOf(bytes)
|
|
|
|
|
|
store.put(id, bytes)
|
|
|
|
|
|
const usedAfterFirst = store.bytes
|
|
|
|
|
|
store.put(id, bytes)
|
|
|
|
|
|
store.put(id, Buffer.from(bytes))
|
|
|
|
|
|
assert.strictEqual(store.size, 1, '同 id 只应有一份')
|
|
|
|
|
|
assert.strictEqual(store.bytes, usedAfterFirst, '去重 ⇒ 容量不得重复累加')
|
|
|
|
|
|
assert.strictEqual(store.counters().puts, 1, '只有第一次算新入库')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('store: 超上限 ⇒ LRU 淘汰,且 evicted 计数与容量约束都被断言', () => {
|
|
|
|
|
|
const blockSize = 1024
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 3 * blockSize })
|
|
|
|
|
|
const ids = []
|
|
|
|
|
|
for (let i = 0; i < 5; i += 1) {
|
|
|
|
|
|
const b = makeBytes(blockSize, 100 + i)
|
|
|
|
|
|
const id = blockIdOf(b)
|
|
|
|
|
|
ids.push(id)
|
|
|
|
|
|
store.put(id, b)
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.ok(store.bytes <= 3 * blockSize, `容量必须被约束:${store.bytes} <= ${3 * blockSize}`)
|
|
|
|
|
|
assert.ok(store.counters().evicted >= 2, `应发生 ≥2 次淘汰,实际 ${store.counters().evicted}`)
|
|
|
|
|
|
assert.strictEqual(store.get(ids[4]) === undefined, false, '最近写入的块必须还在')
|
|
|
|
|
|
assert.strictEqual(store.get(ids[0]), undefined, '最早的块应已被淘汰')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('store: 单块超上限 ⇒ 明确拒绝并计数(⛔ 不是静默丢)', () => {
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 10_000, maxBlockBytes: 500 })
|
|
|
|
|
|
const big = makeBytes(600, 1)
|
|
|
|
|
|
const id = blockIdOf(big)
|
|
|
|
|
|
const c0 = store.counters()
|
|
|
|
|
|
assert.throws(() => store.put(id, big), /超过单块上限/)
|
|
|
|
|
|
assert.strictEqual(store.counters().oversizeRejected, c0.oversizeRejected + 1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('store: 命中/未命中计数可断言(E1 的直接来源)', () => {
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 1024 * 1024 })
|
|
|
|
|
|
const bytes = makeBytes(2048, 9)
|
|
|
|
|
|
const id = blockIdOf(bytes)
|
|
|
|
|
|
const c0 = store.counters()
|
|
|
|
|
|
assert.strictEqual(store.get(id), undefined)
|
|
|
|
|
|
assert.strictEqual(store.counters().misses, c0.misses + 1, '未命中必须 +1')
|
|
|
|
|
|
store.put(id, bytes)
|
|
|
|
|
|
const c1 = store.counters()
|
|
|
|
|
|
assert.notStrictEqual(store.get(id), undefined)
|
|
|
|
|
|
assert.strictEqual(store.counters().hits, c1.hits + 1, '命中必须 +1(这就是"省下一次回源"的机器判据)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('store: 非法 id 与非法构造参数必须拒绝', () => {
|
|
|
|
|
|
assert.throws(() => new ContentStore({ maxBytes: 0 }), /正数/)
|
|
|
|
|
|
assert.throws(() => new ContentStore({ maxBytes: -5 }), /正数/)
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 1024 })
|
|
|
|
|
|
const c0 = store.counters()
|
|
|
|
|
|
assert.throws(() => store.put('not-a-valid-id', Buffer.from('x')), /非法块 id/)
|
|
|
|
|
|
assert.strictEqual(store.counters().putRejected, c0.putRejected + 1)
|
|
|
|
|
|
assert.strictEqual(store.get('not-a-valid-id'), undefined)
|
|
|
|
|
|
assert.strictEqual(store.has('not-a-valid-id'), false)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('store: 默认上限 = DEFAULT_MAX_BYTES 且 > 0(P5 预算的固化点)', () => {
|
|
|
|
|
|
assert.strictEqual(DEFAULT_MAX_BYTES, 64 * 1024 * 1024)
|
|
|
|
|
|
const store = new ContentStore()
|
|
|
|
|
|
assert.strictEqual(store.size, 0)
|
|
|
|
|
|
assert.strictEqual(store.bytes, 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 4:内容源优先级链(E6 —— 判别器必须落计数)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('E6: 五档链路顺序固定,且每次取块点名来源档位(计数递增)', async () => {
|
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
|
DEFAULT_TIER_ORDER,
|
|
|
|
|
|
['local', 'peer', 'edge', 'region', 'origin'],
|
|
|
|
|
|
'内容源优先级:本地 → 同局域网 peer → 同区域边缘缓存 → 区域分发点 → 公网源',
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.deepStrictEqual(SOURCE_TIERS, DEFAULT_TIER_ORDER)
|
|
|
|
|
|
|
|
|
|
|
|
const hits = []
|
|
|
|
|
|
const chain = new ContentSourceChain({
|
|
|
|
|
|
// 四档都返回内容(夹具):链必须**按顺序**命中第一档可用者
|
|
|
|
|
|
fetchers: {
|
|
|
|
|
|
local: async () => undefined, // 本地没有
|
|
|
|
|
|
peer: async () => ({ tier: 'peer', bytes: Buffer.from('from-peer') }),
|
|
|
|
|
|
edge: async () => ({ tier: 'edge', bytes: Buffer.from('from-edge') }),
|
|
|
|
|
|
region: async () => ({ tier: 'region', bytes: Buffer.from('from-region') }),
|
|
|
|
|
|
origin: async () => ({ tier: 'origin', bytes: Buffer.from('from-origin') }),
|
|
|
|
|
|
},
|
|
|
|
|
|
onHit: (tier) => hits.push(tier),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const r = await chain.fetch('a'.repeat(BLOCK_ID_HEX_LEN))
|
|
|
|
|
|
assert.strictEqual(r?.bytes.toString(), 'from-peer', '本地空 ⇒ 命中 peer(顺序:local → peer)')
|
|
|
|
|
|
assert.deepStrictEqual(hits, ['peer'], '命中档位必须被点名')
|
|
|
|
|
|
|
|
|
|
|
|
const c = chain.counters()
|
|
|
|
|
|
assert.strictEqual(c.local, 0)
|
|
|
|
|
|
assert.strictEqual(c.peer, 1, 'E6:peer 命中计数必须为 1(⛔ 只写日志 = 不合格)')
|
|
|
|
|
|
assert.strictEqual(c.edge, 0, '⛔ 不许越过 peer 直接打 edge')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E6: 逐档递减 —— 去掉某一档后必须落到下一档,且计数逐档递增', async () => {
|
|
|
|
|
|
const make = (available) =>
|
|
|
|
|
|
new ContentSourceChain({
|
|
|
|
|
|
fetchers: Object.fromEntries(
|
|
|
|
|
|
DEFAULT_TIER_ORDER.map((tier) => [
|
|
|
|
|
|
tier,
|
|
|
|
|
|
async () => (available.includes(tier) ? { tier, bytes: Buffer.from(`from-${tier}`) } : undefined),
|
|
|
|
|
|
]),
|
|
|
|
|
|
),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 只 origin 有
|
|
|
|
|
|
const c1 = make(['origin'])
|
|
|
|
|
|
const r1 = await c1.fetch('b'.repeat(BLOCK_ID_HEX_LEN))
|
|
|
|
|
|
assert.strictEqual(r1?.tier, 'origin')
|
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
|
DEFAULT_TIER_ORDER.map((t) => c1.counters()[t]),
|
|
|
|
|
|
[0, 0, 0, 0, 1],
|
|
|
|
|
|
'只有 origin 命中 ⇒ 计数必须是 [0,0,0,0,1]',
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// edge 也有 ⇒ 必须停在 edge(⛔ 不许越过更靠前的档)
|
|
|
|
|
|
const c2 = make(['edge', 'origin'])
|
|
|
|
|
|
const r2 = await c2.fetch('c'.repeat(BLOCK_ID_HEX_LEN))
|
|
|
|
|
|
assert.strictEqual(r2?.tier, 'edge')
|
|
|
|
|
|
assert.strictEqual(c2.counters().origin, 0, '⛔ 越过 edge 去打 origin = 优先级链失效')
|
|
|
|
|
|
|
|
|
|
|
|
// local 也有 ⇒ 必须停在 local(最短路径)
|
|
|
|
|
|
const c3 = make(DEFAULT_TIER_ORDER)
|
|
|
|
|
|
const r3 = await c3.fetch('d'.repeat(BLOCK_ID_HEX_LEN))
|
|
|
|
|
|
assert.strictEqual(r3?.tier, 'local')
|
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
|
DEFAULT_TIER_ORDER.map((t) => c3.counters()[t]),
|
|
|
|
|
|
[1, 0, 0, 0, 0],
|
|
|
|
|
|
'local 命中 ⇒ 后面四档计数必须全 0',
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E6: 全档皆无 ⇒ 返回 undefined 且**每一档都留下未命中痕迹**(⛔ 不许静默返空)', async () => {
|
|
|
|
|
|
const tried = []
|
|
|
|
|
|
const chain = new ContentSourceChain({
|
|
|
|
|
|
fetchers: Object.fromEntries(DEFAULT_TIER_ORDER.map((tier) => [tier, async () => undefined])),
|
|
|
|
|
|
onMiss: (tier) => tried.push(tier),
|
|
|
|
|
|
})
|
|
|
|
|
|
const r = await chain.fetch('e'.repeat(BLOCK_ID_HEX_LEN))
|
|
|
|
|
|
assert.strictEqual(r, undefined)
|
|
|
|
|
|
assert.deepStrictEqual(tried, DEFAULT_TIER_ORDER, '"没有"必须留下**逐档**痕迹,否则就是本线反复踩的静默失效')
|
|
|
|
|
|
const total = Object.values(chain.counters()).reduce((a, b) => a + b, 0)
|
|
|
|
|
|
assert.strictEqual(total, 0, '未命中不得计入任何档位的命中计数')
|
|
|
|
|
|
assert.strictEqual(chain.misses(), DEFAULT_TIER_ORDER.length, '未命中合计 = 档数')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E6: fetcher 抛错必须计数并**继续往下一档**(⛔ 不许整体失败、不许静默吞)', async () => {
|
|
|
|
|
|
const chain = new ContentSourceChain({
|
|
|
|
|
|
fetchers: {
|
|
|
|
|
|
local: async () => {
|
|
|
|
|
|
throw new Error('local boom')
|
|
|
|
|
|
},
|
|
|
|
|
|
peer: async () => undefined,
|
|
|
|
|
|
edge: async () => ({ tier: 'edge', bytes: Buffer.from('ok') }),
|
|
|
|
|
|
region: async () => undefined,
|
|
|
|
|
|
origin: async () => undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
const r = await chain.fetch('f'.repeat(BLOCK_ID_HEX_LEN))
|
|
|
|
|
|
assert.strictEqual(r?.bytes.toString(), 'ok', '前面的档抛错后必须继续尝试后面的档')
|
|
|
|
|
|
assert.strictEqual(chain.errors().local, 1, '抛错必须单独计数(与"没有"可区分)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('source: emptySourceCounters 覆盖五档且形状完整', () => {
|
|
|
|
|
|
const c = emptySourceCounters()
|
|
|
|
|
|
for (const tier of DEFAULT_TIER_ORDER) {
|
|
|
|
|
|
assert.strictEqual(typeof c[tier], 'number', `档位 ${tier} 必须有计数`)
|
|
|
|
|
|
assert.strictEqual(c[tier], 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 5:分组隔离(E5 —— 跨组不穿透)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('E5: 分组键由 (网络, 组) 决定;跨组/跨网一律不同组', () => {
|
|
|
|
|
|
assert.strictEqual(groupKeyOf('u:1', 'lan-a'), 'u:1|lan-a')
|
|
|
|
|
|
assert.notStrictEqual(groupKeyOf('u:1', 'lan-a'), groupKeyOf('u:1', 'lan-b'), '同网不同组必须不同')
|
|
|
|
|
|
assert.notStrictEqual(groupKeyOf('u:1', 'lan-a'), groupKeyOf('u:2', 'lan-a'), '跨网不得同组')
|
|
|
|
|
|
assert.strictEqual(sameGroup('u:1', 'lan-a', 'u:1', 'lan-a'), true)
|
|
|
|
|
|
assert.strictEqual(sameGroup('u:1', 'lan-a', 'u:1', 'lan-b'), false)
|
|
|
|
|
|
assert.strictEqual(sameGroup('u:1', 'lan-a', 'u:2', 'lan-a'), false)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E5: 本地组内取块命中;跨组一律拒绝且计入 crossGroupDenied(⛔ 必须是显式拒绝)', () => {
|
|
|
|
|
|
const group = new ContentPeerGroup({ network: 'u:1', group: 'lan-a', log: () => {} })
|
|
|
|
|
|
const bytes = makeBytes(4096, 21)
|
|
|
|
|
|
const id = blockIdOf(bytes)
|
|
|
|
|
|
|
|
|
|
|
|
// 同组 peer 供块
|
|
|
|
|
|
group.addPeer({ name: 'u:1/p1', network: 'u:1', group: 'lan-a', holds: [id] })
|
|
|
|
|
|
// 跨组 peer 也持有同一块(用来证明"不穿透")
|
|
|
|
|
|
group.addPeer({ name: 'u:1/p2', network: 'u:1', group: 'lan-b', holds: [id] })
|
|
|
|
|
|
group.addPeer({ name: 'u:2/p3', network: 'u:2', group: 'lan-a', holds: [id] })
|
|
|
|
|
|
|
|
|
|
|
|
const inGroup = group.candidates(id)
|
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
|
inGroup.map((p) => p.name),
|
|
|
|
|
|
['u:1/p1'],
|
|
|
|
|
|
'E5:只有同 (网, 组) 的 peer 能成为候选',
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const c0 = group.counters()
|
|
|
|
|
|
const denied = group.markDenied('u:1/p2', id)
|
|
|
|
|
|
assert.strictEqual(denied, true, '跨组请求必须被显式拒绝')
|
|
|
|
|
|
const c1 = group.counters()
|
|
|
|
|
|
assert.strictEqual(c1.crossGroupDenied, c0.crossGroupDenied + 1, 'E5:跨组拒绝必须计数(⛔ 静默返空 = 假绿)')
|
|
|
|
|
|
assert.strictEqual(group.markDenied('u:2/p3', id), true)
|
|
|
|
|
|
assert.strictEqual(group.counters().crossGroupDenied, 2)
|
|
|
|
|
|
|
|
|
|
|
|
// 同组请求不被拒绝
|
|
|
|
|
|
assert.strictEqual(group.markDenied('u:1/p1', id), false, '同组不得被拒')
|
|
|
|
|
|
assert.strictEqual(group.counters().crossGroupDenied, 2, '同组不得计入跨组拒绝')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E5: PEER_COUNTER_KEYS 齐全(判别器存在性可断言)', () => {
|
|
|
|
|
|
const group = new ContentPeerGroup({ network: 'ops', group: 'lan-x', log: () => {} })
|
|
|
|
|
|
const c = group.counters()
|
|
|
|
|
|
for (const k of PEER_COUNTER_KEYS) {
|
|
|
|
|
|
assert.strictEqual(typeof c[k], 'number', `peer 判别器 ${k} 必须是数字(OBS 会断言它)`)
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.ok(PEER_COUNTER_KEYS.includes('crossGroupDenied'))
|
|
|
|
|
|
assert.ok(PEER_COUNTER_KEYS.includes('peerHits'))
|
|
|
|
|
|
assert.ok(PEER_COUNTER_KEYS.includes('peerMisses'))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('E5: 分组不影响同组内多 peer 的可用性(E2 的支撑)', () => {
|
|
|
|
|
|
const group = new ContentPeerGroup({ network: 'u:1', group: 'lan-a', log: () => {} })
|
|
|
|
|
|
const a = makeBytes(2048, 31)
|
|
|
|
|
|
const b = makeBytes(2048, 32)
|
|
|
|
|
|
const ida = blockIdOf(a)
|
|
|
|
|
|
const idb = blockIdOf(b)
|
|
|
|
|
|
group.addPeer({ name: 'u:1/pA', network: 'u:1', group: 'lan-a', holds: [ida] })
|
|
|
|
|
|
group.addPeer({ name: 'u:1/pB', network: 'u:1', group: 'lan-a', holds: [idb] })
|
|
|
|
|
|
// 只拿到一部分也能开始共享:两人各持一块,请求任一块都有人能供
|
|
|
|
|
|
assert.deepStrictEqual(group.candidates(ida).map((p) => p.name), ['u:1/pA'])
|
|
|
|
|
|
assert.deepStrictEqual(group.candidates(idb).map((p) => p.name), ['u:1/pB'])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 6:装配级不回归(本单的模块集合必须自洽)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
test('端到端(进程内):切分 → 入库 → 计划重组 → 校验闭环', async () => {
|
|
|
|
|
|
const store = new ContentStore({ maxBytes: 8 * 1024 * 1024 })
|
|
|
|
|
|
const buf = makeBytes(4 * 1024 * 1024 + 777, 77)
|
|
|
|
|
|
const r = chunkify(buf)
|
|
|
|
|
|
for (const c of r.chunks) store.put(c.id, c.bytes)
|
|
|
|
|
|
|
|
|
|
|
|
// 只拿到 30% 也要能列出计划(E2)
|
|
|
|
|
|
const plan = planOf(buf)
|
|
|
|
|
|
assert.strictEqual(plan.ids.length, r.chunks.length)
|
|
|
|
|
|
|
|
|
|
|
|
// 从 store 取回并重组
|
|
|
|
|
|
const parts = new Map()
|
|
|
|
|
|
for (const id of plan.ids) {
|
|
|
|
|
|
const got = store.get(id)
|
|
|
|
|
|
assert.notStrictEqual(got, undefined)
|
|
|
|
|
|
parts.set(id, got)
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.ok(reassemble(plan.ids, parts).equals(buf), '重组必须逐字节等于原内容')
|
|
|
|
|
|
assert.strictEqual(store.counters().hits, plan.ids.length, `命中数应等于块数 ${plan.ids.length}`)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 7:序㉘ · 单 B「组密钥加密」—— F1–F6(交接单_组密钥加密_20260918 §5)
|
|
|
|
|
|
// ⚠️ 断言一律**精确等值**(⛔ 不用 `>=` 让实现变差还能过,除非判据本身就要求"至少")
|
|
|
|
|
|
// ⚠️ 本组**刻意不进** `package.json` 的 test 列表(那张列表是硬编码的)⇒ 单跑本文件
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
|
import { ContentRuntime } from '../lib/net/relay/content/runtime.js'
|
|
|
|
|
|
import { generateAuthorityKey, signPayloadWith } from '../lib/net/relay/identity.js'
|
|
|
|
|
|
import {
|
|
|
|
|
|
ContentCipher,
|
|
|
|
|
|
CONTENT_CRYPTO_COUNTER_KEYS,
|
|
|
|
|
|
CONTENT_CIPHER_VERSION,
|
|
|
|
|
|
IV_LEN,
|
|
|
|
|
|
KEY_LEN,
|
|
|
|
|
|
TAG_LEN,
|
|
|
|
|
|
groupKeyCredentialPayload,
|
|
|
|
|
|
keyIdOf,
|
|
|
|
|
|
loadGroupKeyFile,
|
|
|
|
|
|
verifyGroupKeyCredential,
|
2026-09-19 05:35:35 +08:00
|
|
|
|
// 🆕 序㊻ · C(域分离)
|
|
|
|
|
|
BLOCK_ID_DOMAIN_TAG,
|
|
|
|
|
|
deriveBlockIdKey,
|
2026-09-18 08:08:51 +08:00
|
|
|
|
} from '../lib/net/relay/content/crypto.js'
|
|
|
|
|
|
|
|
|
|
|
|
/** 造一个确定性的组密钥(⛔ 不用随机 —— 用例必须可复现)。 */
|
|
|
|
|
|
function makeKey(seed = 9) {
|
|
|
|
|
|
return makeBytes(KEY_LEN, seed)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
test('F1 确定性:同组 + 同明文两次 ⇒ 密文逐字节相同("按哈希共享块"的前提)', () => {
|
|
|
|
|
|
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(1) })
|
|
|
|
|
|
const plain = Buffer.from('dshs-content-block-0123456789', 'utf8')
|
|
|
|
|
|
const a = cipher.encryptBlock(plain)
|
|
|
|
|
|
const b = cipher.encryptBlock(plain)
|
|
|
|
|
|
assert.ok(a.equals(b), '同明文两次必须逐字节相同(否则块 id 每次都变 ⇒ 去重与 peer 命中全废)')
|
|
|
|
|
|
assert.strictEqual(a.length, plain.length + IV_LEN + TAG_LEN, '密文 = iv(12) + tag(16) + 明文')
|
|
|
|
|
|
assert.strictEqual(cipher.decodeBlock(a).toString('utf8'), 'dshs-content-block-0123456789')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F1-b 换 epoch / 换密钥 ⇒ 密文必不同;且旧密文用新密钥解 ⇒ 认证失败', () => {
|
|
|
|
|
|
const k = makeKey(1)
|
|
|
|
|
|
const c1 = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: k })
|
|
|
|
|
|
const c2 = new ContentCipher({ groupKey: 'ops|relay', epoch: 2, key: k })
|
|
|
|
|
|
const c3 = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(2) })
|
|
|
|
|
|
const plain = Buffer.from('same-plaintext', 'utf8')
|
|
|
|
|
|
const b1 = c1.encryptBlock(plain)
|
|
|
|
|
|
assert.ok(!b1.equals(c2.encryptBlock(plain)), '换 epoch ⇒ AAD 与密钥双变 ⇒ 密文必须不同')
|
|
|
|
|
|
assert.ok(!b1.equals(c3.encryptBlock(plain)), '换密钥 ⇒ 密文必须不同')
|
|
|
|
|
|
assert.strictEqual(c2.decodeBlock(b1), undefined, '跨 epoch 必须**在认证阶段**被拒')
|
|
|
|
|
|
assert.strictEqual(c3.decodeBlock(b1), undefined, '跨密钥必须被拒')
|
|
|
|
|
|
assert.strictEqual(c2.counters().decryptRejected, 1)
|
|
|
|
|
|
assert.strictEqual(c3.counters().decryptRejected, 1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F1-c 块 id 挂密文(β′):同组两次独立"切块+加密" ⇒ id 序列逐字一致;换组 ⇒ 全变', () => {
|
|
|
|
|
|
const buf = makeBytes(3 * 1024 * 1024 + 13, 41)
|
|
|
|
|
|
const A = new ContentCipher({ groupKey: 'ops|grpA', epoch: 1, key: makeKey(3) })
|
|
|
|
|
|
const B = new ContentCipher({ groupKey: 'ops|grpB', epoch: 1, key: makeKey(4) })
|
|
|
|
|
|
const encA = { encode: (p) => A.encryptBlock(p) }
|
|
|
|
|
|
const r1 = chunkify(buf, 1024 * 1024, encA)
|
|
|
|
|
|
const r2 = chunkify(buf, 1024 * 1024, encA)
|
|
|
|
|
|
assert.deepStrictEqual(r1.chunks.map((c) => c.id), r2.chunks.map((c) => c.id), '组内 id 必须稳定')
|
|
|
|
|
|
const rb = chunkify(buf, 1024 * 1024, { encode: (p) => B.encryptBlock(p) })
|
|
|
|
|
|
assert.notDeepStrictEqual(
|
|
|
|
|
|
r1.chunks.map((c) => c.id),
|
|
|
|
|
|
rb.chunks.map((c) => c.id),
|
|
|
|
|
|
'换组 ⇒ 全部 id 改变(= 轮换 ⇒ 全量回源 的直接证据)',
|
|
|
|
|
|
)
|
|
|
|
|
|
// 明文哈希(α)与密文哈希(β′)**必须不同**(否则等于没换口径)
|
|
|
|
|
|
assert.notDeepStrictEqual(r1.chunks.map((c) => c.id), chunkify(buf, 1024 * 1024).chunks.map((c) => c.id))
|
|
|
|
|
|
// 密文口径下"计划"与"切分"必须同源(否则先查 peer 会查错 id)
|
|
|
|
|
|
assert.deepStrictEqual(planOf(buf, 1024 * 1024, encA).ids, r1.chunks.map((c) => c.id))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F1-d 缺省(不传 transforms)⇒ 与序㉔ 逐字一致(零回归口径)', () => {
|
|
|
|
|
|
const buf = makeBytes(1024 * 1024 + 5, 5)
|
|
|
|
|
|
const a = chunkify(buf)
|
|
|
|
|
|
const b = chunkify(buf, DEFAULT_BLOCK_SIZE, {})
|
|
|
|
|
|
assert.deepStrictEqual(a.chunks.map((c) => c.id), b.chunks.map((c) => c.id))
|
|
|
|
|
|
assert.strictEqual(a.contentId, b.contentId)
|
|
|
|
|
|
assert.strictEqual(a.contentId, contentIdOf(buf), '缺省 contentId 仍是明文哈希')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F2 共享不退化:put → 链上取回两次 ⇒ local 命中递增且解密成功', async () => {
|
|
|
|
|
|
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(6) })
|
|
|
|
|
|
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher, storeMaxBytes: 16 * 1024 * 1024 })
|
|
|
|
|
|
const buf = makeBytes(2 * 1024 * 1024 + 9, 61)
|
|
|
|
|
|
const put = rt.putContent(buf)
|
|
|
|
|
|
assert.strictEqual(put.plan.length, 3, '2 MiB + 9 B ⇒ 3 块')
|
|
|
|
|
|
const once = await rt.fetchContent(put.plan)
|
|
|
|
|
|
const twice = await rt.fetchContent(put.plan)
|
|
|
|
|
|
assert.ok(once.equals(buf), '第一次取回必须是原明文')
|
|
|
|
|
|
assert.ok(twice.equals(buf), '第二次取回必须是原明文')
|
|
|
|
|
|
const c = rt.snapshot()
|
|
|
|
|
|
assert.strictEqual(c.source.local, 6, '两次取回 × 3 块 ⇒ local 命中 6(⛔ 不许退化成回源)')
|
|
|
|
|
|
assert.strictEqual(c.source.origin, 0)
|
|
|
|
|
|
assert.strictEqual(c.store.hits, 6)
|
|
|
|
|
|
assert.strictEqual(c.crypto.decrypts, 6)
|
|
|
|
|
|
assert.strictEqual(c.crypto.decryptRejected, 0)
|
|
|
|
|
|
// ⚠️ 存储里放的是**密文**:库里的字节 ≠ 明文
|
|
|
|
|
|
const firstId = put.plan[0]
|
|
|
|
|
|
assert.ok(!rt.store.get(firstId).equals(buf.subarray(0, 1024 * 1024)), '库里的块必须是密文')
|
|
|
|
|
|
// 16 字节的标记在密文里必须找不到
|
|
|
|
|
|
assert.ok(rt.store.get(firstId).length > 1024 * 1024, '密文比明文长 iv+tag')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F2-b 去重仍成立(E1 口径):同一份内容重复 put ⇒ cache 不翻倍', () => {
|
|
|
|
|
|
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(7) })
|
|
|
|
|
|
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher })
|
|
|
|
|
|
const buf = makeBytes(1024 * 1024 * 2, 71)
|
|
|
|
|
|
rt.putContent(buf)
|
|
|
|
|
|
const blocksAfter1 = rt.snapshot().storeBlocks
|
|
|
|
|
|
assert.strictEqual(blocksAfter1, 2)
|
|
|
|
|
|
rt.putContent(buf)
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().storeBlocks, blocksAfter1, '同内容重复入库必须去重(否则 E1 直接崩)')
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().store.puts, blocksAfter1, '重复 put 不得重复计 puts')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F3 跨组不可解:组 B 用自己的密钥解组 A 的密文 ⇒ 拒 + decryptRejected +1', () => {
|
|
|
|
|
|
const A = new ContentCipher({ groupKey: 'ops|grpA', epoch: 1, key: makeKey(8) })
|
|
|
|
|
|
const B = new ContentCipher({ groupKey: 'ops|grpB', epoch: 1, key: makeKey(9) })
|
|
|
|
|
|
const blob = A.encryptBlock(Buffer.from('只有 A 组能看的内容', 'utf8'))
|
|
|
|
|
|
assert.strictEqual(B.decodeBlock(blob), undefined)
|
|
|
|
|
|
assert.strictEqual(B.counters().decryptRejected, 1)
|
|
|
|
|
|
assert.strictEqual(A.decodeBlock(blob).toString('utf8'), '只有 A 组能看的内容')
|
|
|
|
|
|
assert.strictEqual(A.counters().decryptRejected, 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F3-b 跨组判定不被放松:未声明 epoch 的 peer 仍按"同组即可用"(序㉔ 语义不变)', () => {
|
|
|
|
|
|
const g = new ContentPeerGroup({ network: 'ops', group: 'lan-a', epoch: 1 })
|
|
|
|
|
|
g.addPeer({ name: 'ops/p1', network: 'ops', group: 'lan-a', holds: ['aa'] })
|
|
|
|
|
|
g.addPeer({ name: 'ops/p2', network: 'ops', group: 'lan-b', holds: ['aa'] })
|
|
|
|
|
|
assert.deepStrictEqual(g.candidates('aa').map((p) => p.name), ['ops/p1'])
|
|
|
|
|
|
assert.strictEqual(g.counters().crossGroupDenied, 0, '跨组拒绝语义未被本单改动')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F3-c epoch 不一致 ⇒ 不作候选 + 单独计数(⛔ 不混进 crossGroupDenied)', () => {
|
|
|
|
|
|
const g = new ContentPeerGroup({ network: 'ops', group: 'lan-a', epoch: 2 })
|
|
|
|
|
|
g.addPeer({ name: 'ops/p1', network: 'ops', group: 'lan-a', holds: ['aa'], epoch: 1 })
|
|
|
|
|
|
g.addPeer({ name: 'ops/p2', network: 'ops', group: 'lan-a', holds: ['aa'], epoch: 2 })
|
|
|
|
|
|
assert.deepStrictEqual(g.candidates('aa').map((p) => p.name), ['ops/p2'])
|
|
|
|
|
|
assert.strictEqual(g.counters().epochMismatch, 1)
|
|
|
|
|
|
assert.strictEqual(g.counters().crossGroupDenied, 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F4 明文不出现(+ 反向证明):加密 ⇒ 扫不到;不加密 ⇒ 必须扫得到', () => {
|
|
|
|
|
|
const marker = 'PLAINTEXT-MARKER-7f3a91'
|
|
|
|
|
|
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(10) })
|
|
|
|
|
|
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher })
|
|
|
|
|
|
const put = rt.putContent(Buffer.from(`head-${marker}-tail`, 'utf8'))
|
|
|
|
|
|
for (const id of put.plan) {
|
|
|
|
|
|
assert.ok(!rt.store.get(id).includes(Buffer.from(marker, 'utf8')), '密文里不许出现明文标记')
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⛔ 反向夹具:**关掉加密** ⇒ 同一份内容入库后**必须**扫得到标记(否则"没扫到"毫无意义)
|
|
|
|
|
|
const plainRt = new ContentRuntime({ network: 'ops', group: 'relay' })
|
|
|
|
|
|
const put2 = plainRt.putContent(Buffer.from(`head-${marker}-tail`, 'utf8'))
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
plainRt.store.get(put2.plan[0]).includes(Buffer.from(marker, 'utf8')),
|
|
|
|
|
|
'未启用加密时必须扫得到(= 判据有效性证明)',
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F4-b 启动自证:detChecks/detMismatches/plainScans/plainLeaks 四键语义', async () => {
|
|
|
|
|
|
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(11) })
|
|
|
|
|
|
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher })
|
|
|
|
|
|
const ok = await rt.selfProbe('self-probe-marker')
|
|
|
|
|
|
assert.strictEqual(ok, true)
|
|
|
|
|
|
const c = rt.snapshot().crypto
|
|
|
|
|
|
assert.strictEqual(c.detChecks, 1)
|
|
|
|
|
|
assert.strictEqual(c.detMismatches, 0)
|
|
|
|
|
|
assert.strictEqual(c.plainScans, 1)
|
|
|
|
|
|
assert.strictEqual(c.plainLeaks, 0)
|
|
|
|
|
|
assert.strictEqual(c.decrypts, 2, '自证两次解密:crypto 自证一次 + 优先级链上一次')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F4-c 不启用加密 ⇒ crypto 键整体缺席(⛔ 不是补零 ⇒ 探针才能记 SKIP)', async () => {
|
|
|
|
|
|
const rt = new ContentRuntime({ network: 'ops', group: 'relay' })
|
|
|
|
|
|
assert.strictEqual('crypto' in rt.snapshot(), false)
|
|
|
|
|
|
assert.strictEqual(rt.cryptoEnabled, false)
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().peer.epochMismatch, 0)
|
|
|
|
|
|
assert.strictEqual(await rt.selfProbe('x'), undefined, '未启用 ⇒ 自证直接短路(不打日志、不计数)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F6 失败关闭(五种情形各有具名原因,⛔ 绝不静默"以为加密了")', () => {
|
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'cfgx-'))
|
|
|
|
|
|
const k = makeKey(12)
|
|
|
|
|
|
const good = { version: 1, group: 'relay', epoch: 1, key: k.toString('base64') }
|
|
|
|
|
|
// ① no-file
|
|
|
|
|
|
assert.strictEqual(loadGroupKeyFile({ file: join(dir, 'nope.json'), group: 'relay' }).reason, 'no-file')
|
|
|
|
|
|
// ② bad-perms(🔴 POSIX-only:靠 enforcePerms 显式开启 ⇒ 本机 Windows 也能证明"判据有牙")
|
|
|
|
|
|
const p600 = join(dir, 'k600.json')
|
|
|
|
|
|
writeFileSync(p600, JSON.stringify(good), { mode: 0o600 })
|
|
|
|
|
|
assert.strictEqual(loadGroupKeyFile({ file: p600, group: 'relay', enforcePerms: true }).reason, 'bad-perms')
|
|
|
|
|
|
// ③ group-mismatch
|
|
|
|
|
|
assert.strictEqual(loadGroupKeyFile({ file: p600, group: 'local', enforcePerms: false }).reason, 'group-mismatch')
|
|
|
|
|
|
// ③-b network 不配也要拦("同名不同网")
|
|
|
|
|
|
const pNet = join(dir, 'knet.json')
|
|
|
|
|
|
writeFileSync(pNet, JSON.stringify({ ...good, network: 'ops' }), { mode: 0o600 })
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
|
|
loadGroupKeyFile({ file: pNet, group: 'relay', network: 'u:1', enforcePerms: false }).reason,
|
|
|
|
|
|
'group-mismatch',
|
|
|
|
|
|
)
|
|
|
|
|
|
// ④ bad-key
|
|
|
|
|
|
const pBad = join(dir, 'kbad.json')
|
|
|
|
|
|
writeFileSync(pBad, JSON.stringify({ ...good, key: Buffer.alloc(16).toString('base64') }), { mode: 0o600 })
|
|
|
|
|
|
assert.strictEqual(loadGroupKeyFile({ file: pBad, group: 'relay', enforcePerms: false }).reason, 'bad-key')
|
|
|
|
|
|
// ⑤ bad-epoch
|
|
|
|
|
|
const pEpoch = join(dir, 'kepoch.json')
|
|
|
|
|
|
writeFileSync(pEpoch, JSON.stringify({ ...good, epoch: 0 }), { mode: 0o600 })
|
|
|
|
|
|
assert.strictEqual(loadGroupKeyFile({ file: pEpoch, group: 'relay', enforcePerms: false }).reason, 'bad-epoch')
|
|
|
|
|
|
// ⑥ 解析错
|
|
|
|
|
|
const pJunk = join(dir, 'kjunk.json')
|
|
|
|
|
|
writeFileSync(pJunk, '{ not json', { mode: 0o600 })
|
|
|
|
|
|
assert.strictEqual(loadGroupKeyFile({ file: pJunk, group: 'relay', enforcePerms: false }).reason, 'parse-error')
|
|
|
|
|
|
// ⑦ 正例:装载成功且 keyId 与 key 一致;双 epoch 计数对
|
|
|
|
|
|
const both = { ...good, epoch: 2, previous: [{ epoch: 1, key: makeKey(3).toString('base64') }] }
|
|
|
|
|
|
writeFileSync(p600, JSON.stringify(both), { mode: 0o600 })
|
|
|
|
|
|
const okv = loadGroupKeyFile({ file: p600, group: 'relay', network: 'ops', enforcePerms: false })
|
|
|
|
|
|
assert.strictEqual(okv.ok, true)
|
|
|
|
|
|
assert.strictEqual(okv.keyId, keyIdOf(k))
|
|
|
|
|
|
assert.strictEqual(okv.epoch, 2)
|
|
|
|
|
|
assert.strictEqual(okv.epochs, 2)
|
|
|
|
|
|
assert.strictEqual(okv.permsChecked, false)
|
|
|
|
|
|
// 🔴 权限判定的**平台语义必须分开写**:Windows 没有 POSIX 权限位(实测 mode 恒 666)
|
|
|
|
|
|
// ⇒ 强制判定在 Windows 上**必然**报 `bad-perms`(这正是"判据有牙"的证明),
|
|
|
|
|
|
// 在 POSIX 上 0600 的文件则必须通过并回报 `permsChecked=true`。
|
|
|
|
|
|
const forced = loadGroupKeyFile({ file: p600, group: 'relay', enforcePerms: true })
|
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
|
assert.strictEqual(forced.reason, 'bad-perms', 'Windows:无权限位 ⇒ 强制判定必红(判据有牙)')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
assert.strictEqual(forced.ok, true)
|
|
|
|
|
|
assert.strictEqual(forced.permsChecked, true, 'POSIX:0600 ⇒ 通过且已判定')
|
|
|
|
|
|
}
|
|
|
|
|
|
rmSync(dir, { recursive: true, force: true })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F6-b 双 epoch 过渡:窗口内两种密文都能解;超窗口 ⇒ epochExpired + 拒', () => {
|
|
|
|
|
|
const kOld = makeKey(13)
|
|
|
|
|
|
const kNew = makeKey(14)
|
|
|
|
|
|
const retired = new Date(Date.now() - 60_000).toISOString()
|
|
|
|
|
|
const cipher = new ContentCipher({
|
|
|
|
|
|
groupKey: 'ops|relay',
|
|
|
|
|
|
epoch: 2,
|
|
|
|
|
|
key: kNew,
|
|
|
|
|
|
previous: [{ epoch: 1, key: kOld, retiredAt: retired }],
|
|
|
|
|
|
graceMs: 10 * 60 * 1000,
|
|
|
|
|
|
})
|
|
|
|
|
|
const old1 = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: kOld })
|
|
|
|
|
|
const blob = old1.encryptBlock(Buffer.from('上一代的内容', 'utf8'))
|
|
|
|
|
|
assert.strictEqual(cipher.decodeBlock(blob).toString('utf8'), '上一代的内容', '窗口内旧 epoch 必须能解')
|
|
|
|
|
|
assert.strictEqual(cipher.counters().epochExpired, 0)
|
|
|
|
|
|
// 超窗口(graceMs 小于已退休时长)
|
|
|
|
|
|
const expired = new ContentCipher({
|
|
|
|
|
|
groupKey: 'ops|relay',
|
|
|
|
|
|
epoch: 2,
|
|
|
|
|
|
key: kNew,
|
|
|
|
|
|
previous: [{ epoch: 1, key: kOld, retiredAt: retired }],
|
|
|
|
|
|
graceMs: 1000,
|
|
|
|
|
|
})
|
|
|
|
|
|
assert.strictEqual(expired.decodeBlock(blob), undefined)
|
|
|
|
|
|
assert.strictEqual(expired.counters().epochExpired, 1)
|
|
|
|
|
|
assert.strictEqual(expired.counters().decryptRejected, 1)
|
|
|
|
|
|
// 写入一律用**新** epoch(旧 epoch 只解不写)
|
|
|
|
|
|
assert.strictEqual(cipher.epoch, 2)
|
|
|
|
|
|
assert.deepStrictEqual(cipher.epochs(), [2, 1])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('S1 组密钥凭据:签名者签发 ⇒ 验签通过;篡改 epoch ⇒ 失败关闭', () => {
|
|
|
|
|
|
const signer = generateAuthorityKey()
|
|
|
|
|
|
const k = makeKey(15)
|
|
|
|
|
|
const doc = {
|
|
|
|
|
|
version: CONTENT_CIPHER_VERSION,
|
|
|
|
|
|
network: 'ops',
|
|
|
|
|
|
group: 'relay',
|
|
|
|
|
|
epoch: 1,
|
|
|
|
|
|
keyId: keyIdOf(k),
|
|
|
|
|
|
issuedAt: new Date(0).toISOString(),
|
|
|
|
|
|
}
|
|
|
|
|
|
const sig = signPayloadWith(signer.privateKeyPem, groupKeyCredentialPayload(doc))
|
|
|
|
|
|
const okv = verifyGroupKeyCredential(doc, sig, [signer.publicKey])
|
|
|
|
|
|
assert.strictEqual(okv.ok, true)
|
|
|
|
|
|
assert.strictEqual(okv.doc.epoch, 1)
|
|
|
|
|
|
// 篡改 epoch ⇒ 验签必失败(载荷覆盖全部字段)
|
|
|
|
|
|
const bad = verifyGroupKeyCredential({ ...doc, epoch: 2 }, sig, [signer.publicKey])
|
|
|
|
|
|
assert.strictEqual(bad.ok, false)
|
|
|
|
|
|
assert.strictEqual(bad.reason, 'signature-mismatch')
|
|
|
|
|
|
// ⛔ 不可验 = 不接受(受信签名者为空)
|
|
|
|
|
|
assert.strictEqual(verifyGroupKeyCredential(doc, sig, []).reason, 'no-trusted-keys')
|
|
|
|
|
|
// 🔴 载荷内**不含密钥本体**(本单红线:中继只广播三元组)
|
|
|
|
|
|
const payload = groupKeyCredentialPayload(doc)
|
|
|
|
|
|
assert.ok(!payload.includes(k.toString('base64')))
|
|
|
|
|
|
assert.ok(payload.includes('keyId=' + keyIdOf(k)))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('口径守卫:探针 OBS-23 的键表必须与 crypto 模块**同源**(防"改了模块没改探针")', () => {
|
|
|
|
|
|
const probeSrc = readFileSync(new URL('../scripts/overlay-probe.cjs', import.meta.url), 'utf8')
|
|
|
|
|
|
const m = /const CRYPTO_NUM_KEYS = \[([\s\S]*?)\]/.exec(probeSrc)
|
|
|
|
|
|
assert.notStrictEqual(m, null, '探针里必须能找到 CRYPTO_NUM_KEYS')
|
|
|
|
|
|
const inProbe = [...m[1].matchAll(/'([a-zA-Z]+)'/g)].map((x) => x[1])
|
|
|
|
|
|
assert.deepStrictEqual(inProbe, [...CONTENT_CRYPTO_COUNTER_KEYS], '探针键表与模块键表必须逐字一致')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('F5/E1 口径:加密前后"回源份数"不变(同组 N 次取用 ⇒ 全部 local 命中、零回源)', async () => {
|
|
|
|
|
|
const cipher = new ContentCipher({ groupKey: 'ops|relay', epoch: 1, key: makeKey(16) })
|
|
|
|
|
|
const rt = new ContentRuntime({ network: 'ops', group: 'relay', cipher, storeMaxBytes: 32 * 1024 * 1024 })
|
|
|
|
|
|
const pack = makeBytes(1024 * 1024 * 2 + 11, 88)
|
|
|
|
|
|
let missing = 0
|
|
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
|
|
|
|
const put = rt.putContent(pack)
|
|
|
|
|
|
for (const id of put.plan) {
|
|
|
|
|
|
const got = await rt.source.fetch(id)
|
|
|
|
|
|
if (got === undefined) missing += 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.strictEqual(missing, 0)
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().source.local, 12, '4 轮 × 3 块,全部 local 命中(= E1 的"回源 1 份"口径)')
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().store.puts, 3, '同内容只入库 3 个块(去重)')
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().crypto.decryptRejected, 0)
|
|
|
|
|
|
})
|
2026-09-19 05:35:35 +08:00
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 组 G(序㊻ · C 域分离):块 id 的 **per-network keyed hash**
|
|
|
|
|
|
//
|
|
|
|
|
|
// 🔑 这一组是 `OBS-29` 的**离线等价体**(探针那条判据在同一批函数上跑)。
|
|
|
|
|
|
// 两条腿**必须同时有**(缺一即视为未覆盖):
|
|
|
|
|
|
// · **正腿** = 同字节 + **不同 network** ⇒ 块 id **不同**;
|
|
|
|
|
|
// · 🔴 **负腿** = **去掉 per-network 维度**(`netKey` 取同值 / 取空)⇒ 同一谓词**必红**。
|
|
|
|
|
|
// ⛔ 只给正腿 = 会漏掉"去了重"(把去重也一起干掉照样全绿);
|
|
|
|
|
|
// ⛔ 没有负腿 = "上了个**无效的**域分离"会全绿 —— 本线老病根(装了但没生效 = 静默放行)。
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** C 的取数夹具:**同一把组密钥** + 不同 network ⇒ 只有 network 维度在变。 */
|
|
|
|
|
|
function keyingFixture(keySeed = 21) {
|
|
|
|
|
|
const cipher = new ContentCipher({ groupKey: 'ops|content', epoch: 1, key: makeKey(keySeed) })
|
|
|
|
|
|
const bytes = makeBytes(3 * 1024 * 1024 + 7, 5) // 4 块(⛔ 不用整块数 —— 边界块也一起验)
|
|
|
|
|
|
// ⚠️ **同网复用同一个 runtime**(缓存):既省事,也让"同网 ⇒ 同一套 id / 同一份存储"这一
|
|
|
|
|
|
// 语义在夹具层面就成立(`E1` 重取需要"同一个网内重复取用")。
|
|
|
|
|
|
const cache = new Map()
|
|
|
|
|
|
const rtOf = (network) => {
|
|
|
|
|
|
if (!cache.has(network)) cache.set(network, new ContentRuntime({ network, group: 'content', cipher }))
|
|
|
|
|
|
return cache.get(network)
|
|
|
|
|
|
}
|
|
|
|
|
|
return { cipher, bytes, rtOf }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 🔴 正腿的**谓词本体**(放在函数里 ⇒ 正/负两腿断言的是同一个谓词,⛔ 不是两份写法)。 */
|
|
|
|
|
|
const idDiffersAcrossNetworks = (planA, planB) => planA[0] !== planB[0]
|
|
|
|
|
|
|
|
|
|
|
|
test('G1 🔴 正腿:同字节 + 不同 network ⇒ 块 id 必须不同(且两侧都不是裸哈希)', () => {
|
|
|
|
|
|
const { bytes, rtOf } = keyingFixture()
|
|
|
|
|
|
const a = rtOf('ops').planContent(bytes).ids
|
|
|
|
|
|
const b = rtOf('u:1').planContent(bytes).ids
|
|
|
|
|
|
const bare = planOf(bytes).ids
|
|
|
|
|
|
assert.strictEqual(idDiffersAcrossNetworks(a, b), true, '不同 network ⇒ 块 id 必须不同(域分离生效)')
|
|
|
|
|
|
assert.notStrictEqual(a[0], bare[0], '⚠️ A 网不得等于裸哈希 —— 否则 = 域分离没生效("装了没生效"的本形)')
|
|
|
|
|
|
assert.notStrictEqual(b[0], bare[0], '⚠️ B 网不得等于裸哈希(同上)')
|
|
|
|
|
|
assert.strictEqual(a.length, 4, '4 块:正腿必须覆盖多块而不是只比第一块')
|
|
|
|
|
|
for (let i = 0; i < a.length; i += 1) {
|
|
|
|
|
|
assert.notStrictEqual(a[i], b[i], `第 ${i} 块:跨网必须不同`)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G1-b 正腿(id 函数直测):blockIdOf / contentIdOf 的 netKey 维度必须起作用', () => {
|
|
|
|
|
|
const k = makeKey(21)
|
|
|
|
|
|
const b = makeBytes(4096, 5)
|
|
|
|
|
|
assert.notStrictEqual(blockIdOf(b, deriveBlockIdKey(k, 'ops')), blockIdOf(b, deriveBlockIdKey(k, 'u:1')))
|
|
|
|
|
|
assert.notStrictEqual(contentIdOf(b, deriveBlockIdKey(k, 'ops')), contentIdOf(b, deriveBlockIdKey(k, 'u:1')))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G2 🔴 正腿:同 network + 同字节 ⇒ 块 id 必须相同(**去重不得丢**)', () => {
|
|
|
|
|
|
const { bytes, rtOf } = keyingFixture()
|
|
|
|
|
|
const rt1 = rtOf('ops')
|
|
|
|
|
|
const rt2 = rtOf('ops')
|
|
|
|
|
|
assert.strictEqual(idDiffersAcrossNetworks(rt1.planContent(bytes).ids, rt2.planContent(bytes).ids), false)
|
|
|
|
|
|
assert.deepStrictEqual(rt1.planContent(bytes).ids, rt2.planContent(bytes).ids, '同网同内容 ⇒ 计划逐字一致')
|
|
|
|
|
|
// 去重仍然成立:同一份内容入库两次 ⇒ store 只入 4 个**唯一**块(3 整块 + 1 边界块)
|
|
|
|
|
|
const put1 = rt1.putContent(bytes)
|
|
|
|
|
|
const put2 = rt1.putContent(bytes)
|
|
|
|
|
|
assert.deepStrictEqual(put2.plan, put1.plan, '同网重复写 ⇒ 计划 id 逐字一致(去重必须还在)')
|
|
|
|
|
|
assert.strictEqual(rt1.snapshot().store.puts, 4, '同内容只入库 4 个块(⛔ 域分离不得把去重干掉)')
|
|
|
|
|
|
assert.strictEqual(rt1.snapshot().store.putRejected, 0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G3 装配面:域分离启用后 store 两处复算 / 链取回 / 重组位三条路都必须过(= 调用点无漏改)', async () => {
|
|
|
|
|
|
const { bytes, rtOf } = keyingFixture()
|
|
|
|
|
|
const rt = rtOf('ops')
|
|
|
|
|
|
const put = rt.putContent(bytes)
|
|
|
|
|
|
// ① store 写侧复算(漏改 ⇒ 这里必然抛 / putRejected 涨)
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().store.putRejected, 0, 'store 写侧复算必须过')
|
|
|
|
|
|
// ② 生产路径:优先级链取回(local 档命中 → 唯一解密点)
|
|
|
|
|
|
const got = await rt.fetchContent(put.plan)
|
|
|
|
|
|
assert.ok(got !== undefined && got.equals(bytes), '经优先级链取回必须逐字节等于原内容')
|
|
|
|
|
|
assert.strictEqual(rt.snapshot().store.corruptReads, 0, 'store 读侧复算必须过')
|
|
|
|
|
|
// ③ 工具路径:重组位(`chunker#reassemble` 也要同一个 netKey)
|
|
|
|
|
|
const stored = new Map()
|
|
|
|
|
|
for (const id of put.plan) stored.set(id, rt.store.get(id))
|
|
|
|
|
|
const back = await rt.reassembleContent(stored, put.plan)
|
|
|
|
|
|
assert.ok(back !== undefined && back.equals(bytes), '重组位必须逐字节等于原内容(⛔ 漏 netKey ⇒ 必抛校验失败)')
|
|
|
|
|
|
// ④ D7 闸门:**跨网**取回的块(块本身完好)必须被丢弃 —— 域不同 ⇒ id 不等 ⇒ 不算命中
|
|
|
|
|
|
const other = rtOf('u:1')
|
|
|
|
|
|
const putOther = other.putContent(bytes)
|
|
|
|
|
|
assert.notDeepStrictEqual(putOther.plan, put.plan, '两个域的块 id 必须不同')
|
|
|
|
|
|
assert.strictEqual(rt.store.has(putOther.plan[0]), false, '跨域的块 id 不得在本地命中')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G4 回滚路径:缺省 / 空 netKey ⇒ 回落裸 sha256(与序㉔ 逐字一致)', () => {
|
|
|
|
|
|
const b = makeBytes(4096, 5)
|
|
|
|
|
|
const bare = createHash('sha256').update(b).digest('hex').slice(0, BLOCK_ID_HEX_LEN)
|
|
|
|
|
|
assert.strictEqual(blockIdOf(b), bare, '缺省必须恰为裸 sha256 前 32 hex')
|
|
|
|
|
|
assert.strictEqual(blockIdOf(b, Buffer.alloc(0)), bare, '⚠️ 空 netKey 必须**等价于**不传(回滚路径)')
|
|
|
|
|
|
assert.strictEqual(contentIdOf(b, Buffer.alloc(0)), bare)
|
|
|
|
|
|
// ⛔ 反向:非空 netKey **必须**离出裸哈希(否则"回落"与"生效"不可分 ⇒ 判据无牙)
|
|
|
|
|
|
assert.notStrictEqual(blockIdOf(b, Buffer.alloc(4, 0xab)), bare, '非空 netKey 必须走 HMAC 分支')
|
|
|
|
|
|
// 不启用组密钥的 runtime ⇒ 恒回落裸哈希(= 缺省不启用 / 回滚路径成立)
|
|
|
|
|
|
const plain = new ContentRuntime({ network: 'ops', group: 'content' })
|
|
|
|
|
|
assert.strictEqual(plain.netKey, undefined)
|
|
|
|
|
|
assert.strictEqual(plain.blockIdKeyId, undefined)
|
|
|
|
|
|
assert.deepStrictEqual(plain.planContent(b).ids, planOf(b).ids, '不启用 ⇒ 计划逐字等于裸哈希口径')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G5 🔴 负腿「netKey 取同值」:去掉 per-network 维度 ⇒ 正腿谓词必红(具名 flat-key-collapses)', () => {
|
|
|
|
|
|
const { bytes, rtOf } = keyingFixture()
|
|
|
|
|
|
// 正腿:两个**不同** network
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
|
|
idDiffersAcrossNetworks(rtOf('ops').planContent(bytes).ids, rtOf('u:1').planContent(bytes).ids),
|
|
|
|
|
|
true,
|
|
|
|
|
|
'正腿基线:不同 network ⇒ 谓词必须为真',
|
|
|
|
|
|
)
|
|
|
|
|
|
// 🔴 负腿:把 network 维度**去掉**(两侧取同一个 network ⇒ 同一把域密钥)
|
|
|
|
|
|
const flatA = rtOf('ops').planContent(bytes).ids
|
|
|
|
|
|
const flatB = rtOf('ops').planContent(bytes).ids
|
|
|
|
|
|
assert.strictEqual(
|
|
|
|
|
|
idDiffersAcrossNetworks(flatA, flatB),
|
|
|
|
|
|
false,
|
|
|
|
|
|
'⚠️ flat-key-collapses:域密钥取同值 ⇒ 谓词**必须为假**(判据有牙;若这里为真说明判据根本没看 network)',
|
|
|
|
|
|
)
|
|
|
|
|
|
// ⚠️ 且此时**仍然是"带密钥"的路径**(不等于裸哈希)—— 排除了"负腿其实是回落裸哈希"的混淆
|
|
|
|
|
|
assert.notStrictEqual(flatA[0], planOf(bytes).ids[0], '负腿必须走 HMAC 路径(⛔ 不是回落裸哈希)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G6 🔴 负腿「netKey 取空」:回落裸哈希 ⇒ 同一正腿谓词必红(具名 empty-key-falls-back-to-bare-hash)', () => {
|
|
|
|
|
|
const b = makeBytes(4096, 5)
|
|
|
|
|
|
const k = deriveBlockIdKey(makeKey(21), 'ops')
|
|
|
|
|
|
// 谓词本体在**函数级**再跑一遍(与 G1/G5 同一个谓词形状)
|
|
|
|
|
|
const differs = (x, y) => x !== y
|
|
|
|
|
|
assert.strictEqual(differs(blockIdOf(b, k), blockIdOf(b, k)), false, '取空(两侧同一把钥)⇒ 必为假')
|
|
|
|
|
|
assert.strictEqual(differs(blockIdOf(b), blockIdOf(b)), false, '两侧都不传 ⇒ 必为假')
|
|
|
|
|
|
// ⛔ 反向:只把**一侧**取空 ⇒ 谓词为真(说明"取空"确实会改变取值 ⇒ 判据对 netKey 敏感)
|
|
|
|
|
|
assert.strictEqual(differs(blockIdOf(b, k), blockIdOf(b)), true, '一侧带钥一侧不带 ⇒ 必须不同')
|
|
|
|
|
|
// ⚠️ 取空与生效**必须可分**:否则这条负腿没有判别力
|
|
|
|
|
|
assert.notStrictEqual(blockIdOf(b, k), blockIdOf(b))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G7 派生:确定性 + 只由 (组密钥, network) 决定 + 换钥必变 + 不等于密钥本体', () => {
|
|
|
|
|
|
const k = makeKey(21)
|
|
|
|
|
|
assert.strictEqual(deriveBlockIdKey(k, 'ops').length, KEY_LEN, '派生钥必须是 32 B(HMAC-SHA256 输出)')
|
|
|
|
|
|
assert.ok(deriveBlockIdKey(k, 'ops').equals(deriveBlockIdKey(k, 'ops')), '确定性:同钥同网 ⇒ 同一把')
|
|
|
|
|
|
assert.ok(!deriveBlockIdKey(k, 'ops').equals(deriveBlockIdKey(k, 'u:1')), 'network 维度必须起作用')
|
|
|
|
|
|
assert.ok(!deriveBlockIdKey(k, 'ops').equals(deriveBlockIdKey(makeKey(22), 'ops')), '换组密钥 ⇒ 派生钥必变')
|
|
|
|
|
|
assert.ok(!deriveBlockIdKey(k, 'ops').equals(k), '⛔ 派生钥不得等于组密钥本体(否则"派生"是假的)')
|
|
|
|
|
|
assert.strictEqual(typeof BLOCK_ID_DOMAIN_TAG, 'string')
|
|
|
|
|
|
assert.ok(BLOCK_ID_DOMAIN_TAG.length > 0, '域标签必须非空(它进 HMAC ⇒ 换标签 = 全部块 id 换代)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G8 观测面:未启用 ⇒ 两键整体缺席;启用 ⇒ 只放指纹(⛔ 不许出现域密钥本体)', () => {
|
|
|
|
|
|
const plain = new ContentRuntime({ network: 'ops', group: 'content' })
|
|
|
|
|
|
const sp = plain.snapshot()
|
|
|
|
|
|
assert.ok(!('blockIdKeyed' in sp), '未启用域分离 ⇒ blockIdKeyed 必须**缺席**(⛔ 不补 false)')
|
|
|
|
|
|
assert.ok(!('blockIdKeyId' in sp), '未启用域分离 ⇒ blockIdKeyId 必须缺席')
|
|
|
|
|
|
|
|
|
|
|
|
const { rtOf } = keyingFixture()
|
|
|
|
|
|
const rt = rtOf('ops')
|
|
|
|
|
|
const s = rt.snapshot()
|
|
|
|
|
|
assert.strictEqual(s.blockIdKeyed, true)
|
|
|
|
|
|
assert.match(String(s.blockIdKeyId), /^[0-9a-f]{16}$/, '指纹必须是 16 hex')
|
|
|
|
|
|
assert.strictEqual(s.blockIdKeyId, keyIdOf(rt.netKey), '指纹必须 = keyIdOf(域密钥)')
|
|
|
|
|
|
// 🔑 跨机口径一致(47 / 106 必须逐字相同;不同 ⇒ 跨机取块全判校验失败)
|
|
|
|
|
|
assert.strictEqual(rtOf('ops').blockIdKeyId, rtOf('ops').blockIdKeyId, '同钥同网 ⇒ 口径指纹必须一致')
|
|
|
|
|
|
assert.notStrictEqual(rtOf('ops').blockIdKeyId, rtOf('u:1').blockIdKeyId, '不同网 ⇒ 指纹必须不同')
|
|
|
|
|
|
// 🔴 泄漏守卫:整份 /status 文本里不得出现域密钥本体(hex / base64 两种写法都扫)
|
|
|
|
|
|
const text = JSON.stringify(s)
|
|
|
|
|
|
assert.ok(!text.includes(rt.netKey.toString('hex')), '⛔ /status 不得出现域密钥(hex)')
|
|
|
|
|
|
assert.ok(!text.includes(rt.netKey.toString('base64')), '⛔ /status 不得出现域密钥(base64)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G9 边界:域分离启用后"同内容不同域"的块**共享必然失效**(= 设计代价,⛔ 不是缺陷)', async () => {
|
|
|
|
|
|
const { bytes, rtOf } = keyingFixture()
|
|
|
|
|
|
const a = rtOf('ops')
|
|
|
|
|
|
const b = rtOf('u:1')
|
|
|
|
|
|
const putA = a.putContent(bytes)
|
|
|
|
|
|
// B 域完全独立:既查不到 A 的块,也解不开 A 的密文(AAD 里就有 groupKey 的网名)
|
|
|
|
|
|
assert.strictEqual(b.store.has(putA.plan[0]), false, '跨域不得命中')
|
|
|
|
|
|
const gotB = await b.fetchContent(putA.plan)
|
|
|
|
|
|
assert.strictEqual(gotB, undefined, '跨域取内容必须**整体失败**(⛔ 不许拼半截 / 不许静默回源代价)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('G10 🔴 `E1` 基线**重取**(C 新 id 口径):同网重复取用 ⇒ 零增量回源;跨网 ⇒ 各 1 份', async () => {
|
|
|
|
|
|
// ⚠️ `E1` 的**定义不重估**(回源字节 ≈ 1 份 × 组数)—— 只重取**基线读数**。
|
|
|
|
|
|
const { bytes, rtOf } = keyingFixture()
|
|
|
|
|
|
const A = rtOf('ops')
|
|
|
|
|
|
const putA = A.putContent(bytes)
|
|
|
|
|
|
const hit0 = A.snapshot().source.local
|
|
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
|
|
|
|
const got = await A.fetchContent(putA.plan)
|
|
|
|
|
|
assert.ok(got !== undefined && got.equals(bytes), '第 ' + i + ' 轮取回必须逐字节等于原内容')
|
|
|
|
|
|
}
|
|
|
|
|
|
const s = A.snapshot()
|
|
|
|
|
|
assert.strictEqual(s.source.local - hit0, putA.plan.length * 4, '4 轮 × 4 块 ⇒ 全部 local 命中')
|
|
|
|
|
|
assert.strictEqual(s.source.origin, 0, '⚠️ 稳态回源必须 0("1 份"由首次写入承担 —— 新口径下不变)')
|
|
|
|
|
|
assert.strictEqual(s.store.puts, putA.dedupIds.length, '同内容只入库**去重后**的块数')
|
|
|
|
|
|
assert.strictEqual(s.store.putRejected, 0)
|
|
|
|
|
|
// 跨网:B 网必须**各自一份**(id 不同 ⇒ 天然不去重 —— 这正是 C 的域收窄点)
|
|
|
|
|
|
const B = rtOf('u:1')
|
|
|
|
|
|
assert.strictEqual(B.snapshot().source.local, 0, 'B 网起点零命中(= 各自回源 1 份)')
|
|
|
|
|
|
const putB = B.putContent(bytes)
|
|
|
|
|
|
assert.notDeepStrictEqual(putB.plan, putA.plan, '两个网的块 id 序列必须不同')
|
|
|
|
|
|
const gotB = await B.fetchContent(putB.plan)
|
|
|
|
|
|
assert.ok(gotB !== undefined && gotB.equals(bytes), 'B 网自己那一份必须能取回')
|
|
|
|
|
|
assert.notStrictEqual(putB.dedupIds[0], putA.dedupIds[0], '跨网不得共享块(= 设计意图,⛔ 不是缺陷)')
|
|
|
|
|
|
})
|