211 lines
8.3 KiB
JavaScript
211 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
||||
|
|
/**
|
|||
|
|
* overlay-entropy.cjs —— 覆盖网络「低熵块治理」测熵探针(序㊺ · 只读 · 零第三方依赖)
|
|||
|
|
*
|
|||
|
|
* 口径(M1-a..d,见 `04-调整方案/133-覆盖网络-低熵块治理方案-C域分离与D非确定性.md` §2.2):
|
|||
|
|
* M1-a 种类数 / 重复率 —— 唯一块 id 数 ÷ 总块数 + 完全重复块(同 id ≥ 2)清单
|
|||
|
|
* M1-b 低熵块数 / 体积 —— 逐块经验 Shannon 熵(字节分布, bit/byte)+ H 直方图
|
|||
|
|
* M1-c 占首屏包比例 —— 低熵块字节 ÷ 总字节
|
|||
|
|
* M1-d 子窗口熵(反向腿) —— 滑窗扫整份,给出低熵窗口**连续段的字节尺寸分布**
|
|||
|
|
*
|
|||
|
|
* 🔴 两条硬口径(⛔ 不许改):
|
|||
|
|
* 1. **切分必须调用仓库里那份 `chunkify`**(默认 `../lib/net/relay/content/chunker.js`,
|
|||
|
|
* 由 `src/net/relay/content/chunker.ts` 编译而来)—— ⛔ 不复刻算法(复刻=双源)。
|
|||
|
|
* 可用 `OVERLAY_CHUNKER=<path>` 覆盖(例如把探针与 chunker.js 一起放到取证目录)。
|
|||
|
|
* 2. **本探针只读本地文件**:⛔ 不 ssh、⛔ 不碰网络、⛔ 不写生产路径
|
|||
|
|
* (取数由调用方完成 —— 见 `_tmp_seq45/` 的取数脚本,夹具模式封闭)。
|
|||
|
|
*
|
|||
|
|
* 用法:
|
|||
|
|
* node scripts/overlay-entropy.cjs --in <S1 流文件> [--parts <parts.json>] [--json]
|
|||
|
|
* [--window 4096] [--stride 4096] [--threshold 4.0] [--out <report.json>]
|
|||
|
|
*/
|
|||
|
|
'use strict'
|
|||
|
|
|
|||
|
|
const fs = require('node:fs')
|
|||
|
|
const path = require('node:path')
|
|||
|
|
const { pathToFileURL } = require('node:url')
|
|||
|
|
|
|||
|
|
function arg(name, dflt) {
|
|||
|
|
const i = process.argv.indexOf(`--${name}`)
|
|||
|
|
return i === -1 ? dflt : process.argv[i + 1]
|
|||
|
|
}
|
|||
|
|
const has = (name) => process.argv.includes(`--${name}`)
|
|||
|
|
|
|||
|
|
/** 经验 Shannon 熵(bit/byte)。空输入 ⇒ 0。 */
|
|||
|
|
function entropyOf(buf) {
|
|||
|
|
if (buf.length === 0) return 0
|
|||
|
|
const counts = new Uint32Array(256)
|
|||
|
|
for (let i = 0; i < buf.length; i += 1) counts[buf[i]] += 1
|
|||
|
|
let h = 0
|
|||
|
|
const n = buf.length
|
|||
|
|
for (let b = 0; b < 256; b += 1) {
|
|||
|
|
const c = counts[b]
|
|||
|
|
if (c === 0) continue
|
|||
|
|
const p = c / n
|
|||
|
|
h -= p * Math.log2(p)
|
|||
|
|
}
|
|||
|
|
return h
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** H 直方图桶(0.5 bit/byte 一档,末档到 8)。 */
|
|||
|
|
function histKey(h) {
|
|||
|
|
const lo = Math.min(7.5, Math.floor(h * 2) / 2)
|
|||
|
|
return `${lo.toFixed(1)}-${(lo + 0.5).toFixed(1)}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 低熵窗口的**连续段**(非重叠窗口,stride = window):返回段字节尺寸清单。 */
|
|||
|
|
function lowEntropyRuns(buf, window, stride, threshold) {
|
|||
|
|
const sizes = []
|
|||
|
|
let run = 0
|
|||
|
|
for (let off = 0; off + window <= buf.length; off += stride) {
|
|||
|
|
const h = entropyOf(buf.subarray(off, off + window))
|
|||
|
|
if (h <= threshold) run += stride
|
|||
|
|
else if (run > 0) { sizes.push(run); run = 0 }
|
|||
|
|
}
|
|||
|
|
if (run > 0) sizes.push(run)
|
|||
|
|
return sizes
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bucketBytes(sizes) {
|
|||
|
|
const edges = [4096, 16384, 65536, 262144, 1048576, Infinity]
|
|||
|
|
const labels = ['<=4KiB', '4KiB-16KiB', '16KiB-64KiB', '64KiB-256KiB', '256KiB-1MiB', '>1MiB']
|
|||
|
|
const out = {}
|
|||
|
|
for (const l of labels) out[l] = 0
|
|||
|
|
for (const s of sizes) {
|
|||
|
|
for (let i = 0; i < edges.length; i += 1) {
|
|||
|
|
if (s <= edges[i]) { out[labels[i]] += 1; break }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const inp = arg('in')
|
|||
|
|
if (inp === undefined) throw new Error('缺少 --in <文件>')
|
|||
|
|
const window = Number(arg('window', '4096'))
|
|||
|
|
const stride = Number(arg('stride', String(window)))
|
|||
|
|
const threshold = Number(arg('threshold', '4.0'))
|
|||
|
|
|
|||
|
|
const chunkerPath = process.env.OVERLAY_CHUNKER
|
|||
|
|
?? path.join(__dirname, '..', 'lib', 'net', 'relay', 'content', 'chunker.js')
|
|||
|
|
const chunker = await import(pathToFileURL(chunkerPath).href)
|
|||
|
|
const { chunkify, DEFAULT_BLOCK_SIZE } = chunker
|
|||
|
|
if (typeof chunkify !== 'function') throw new Error(`无法从 ${chunkerPath} 取到 chunkify`)
|
|||
|
|
|
|||
|
|
const buf = fs.readFileSync(inp)
|
|||
|
|
const cut = chunkify(buf) // ⛔ 不传 blockSize ⇒ 用代码常量 DEFAULT_BLOCK_SIZE
|
|||
|
|
|
|||
|
|
// ---- M1-a ----
|
|||
|
|
const freq = new Map()
|
|||
|
|
for (const c of cut.chunks) freq.set(c.id, (freq.get(c.id) ?? 0) + 1)
|
|||
|
|
const duplicates = [...freq.entries()].filter(([, n]) => n >= 2).map(([id, n]) => ({ id, count: n }))
|
|||
|
|
|
|||
|
|
// ---- M1-b ----
|
|||
|
|
const perBlock = cut.chunks.map((c) => ({
|
|||
|
|
index: c.index,
|
|||
|
|
bytes: c.bytes.length,
|
|||
|
|
entropy: Number(entropyOf(c.bytes).toFixed(4)),
|
|||
|
|
}))
|
|||
|
|
const histogram = {}
|
|||
|
|
for (const b of perBlock) {
|
|||
|
|
const k = histKey(b.entropy)
|
|||
|
|
histogram[k] = (histogram[k] ?? 0) + 1
|
|||
|
|
}
|
|||
|
|
const lowBlocks = perBlock.filter((b) => b.entropy <= threshold)
|
|||
|
|
const lowBytes = lowBlocks.reduce((s, b) => s + b.bytes, 0)
|
|||
|
|
|
|||
|
|
// ---- M1-d ----
|
|||
|
|
const runs = lowEntropyRuns(buf, window, stride, threshold)
|
|||
|
|
|
|||
|
|
// ---- S2:按 combo 边界切片(每份 combo = 一份独立分发内容)----
|
|||
|
|
let s2 = null
|
|||
|
|
const partsFile = arg('parts')
|
|||
|
|
if (partsFile !== undefined) {
|
|||
|
|
const parts = JSON.parse(fs.readFileSync(partsFile, 'utf8'))
|
|||
|
|
const contents = []
|
|||
|
|
let off = 0
|
|||
|
|
for (const p of parts) {
|
|||
|
|
const n = Number(p.bytes) || 0
|
|||
|
|
if (n === 0) { contents.push({ order: p.order, bytes: 0, blocks: 0, note: `zero-bytes(code=${p.code})` }); continue }
|
|||
|
|
const c = chunkify(Buffer.from(buf.subarray(off, off + n)))
|
|||
|
|
off += n
|
|||
|
|
const es = c.chunks.map((x) => Number(entropyOf(x.bytes).toFixed(4)))
|
|||
|
|
contents.push({
|
|||
|
|
order: p.order,
|
|||
|
|
bytes: n,
|
|||
|
|
blocks: c.chunks.length,
|
|||
|
|
contentId: c.contentId,
|
|||
|
|
blockEntropies: es,
|
|||
|
|
min: Math.min(...es),
|
|||
|
|
max: Math.max(...es),
|
|||
|
|
lowEntropyBlocks: es.filter((e) => e <= threshold).length,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
s2 = {
|
|||
|
|
contents,
|
|||
|
|
lowEntropyContents: contents.filter((c) => c.lowEntropyBlocks > 0).map((c) => c.order),
|
|||
|
|
singleBlockContents: contents.filter((c) => c.blocks === 1).length,
|
|||
|
|
coveredBytes: off,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const report = {
|
|||
|
|
generatedAt: new Date().toISOString(),
|
|||
|
|
blockSize: DEFAULT_BLOCK_SIZE,
|
|||
|
|
threshold,
|
|||
|
|
window,
|
|||
|
|
stride,
|
|||
|
|
s1: {
|
|||
|
|
source: inp,
|
|||
|
|
bytes: buf.length,
|
|||
|
|
blocks: cut.chunks.length,
|
|||
|
|
uniqueBlockIds: freq.size,
|
|||
|
|
duplicateRatePct: Number(((1 - freq.size / cut.chunks.length) * 100).toFixed(4)),
|
|||
|
|
duplicates,
|
|||
|
|
lowEntropyBlocks: lowBlocks.length,
|
|||
|
|
lowEntropyBytes: lowBytes,
|
|||
|
|
lowEntropyRatioPct: Number(((lowBytes / buf.length) * 100).toFixed(6)),
|
|||
|
|
minBlockEntropy: Math.min(...perBlock.map((b) => b.entropy)),
|
|||
|
|
maxBlockEntropy: Math.max(...perBlock.map((b) => b.entropy)),
|
|||
|
|
histogram,
|
|||
|
|
perBlock,
|
|||
|
|
},
|
|||
|
|
m1d: {
|
|||
|
|
windows: Math.floor(buf.length / window),
|
|||
|
|
lowEntropyRuns: runs.length,
|
|||
|
|
runBytesTotal: runs.reduce((s, x) => s + x, 0),
|
|||
|
|
runBytesMin: runs.length ? Math.min(...runs) : 0,
|
|||
|
|
runBytesMax: runs.length ? Math.max(...runs) : 0,
|
|||
|
|
sizeHistogram: bucketBytes(runs),
|
|||
|
|
topRuns: [...runs].sort((a, b) => b - a).slice(0, 10),
|
|||
|
|
},
|
|||
|
|
s2,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const out = arg('out')
|
|||
|
|
if (out !== undefined) fs.writeFileSync(out, JSON.stringify(report, null, 2))
|
|||
|
|
if (has('json')) {
|
|||
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const L = []
|
|||
|
|
L.push(`=== M1 读数(blockSize=${DEFAULT_BLOCK_SIZE} threshold=${threshold} window=${window} stride=${stride})===`)
|
|||
|
|
L.push(`[S1] ${inp} bytes=${buf.length} blocks=${cut.chunks.length} unique=${freq.size} dupRate=${report.s1.duplicateRatePct}%`)
|
|||
|
|
L.push(`[M1-a] 完全重复块=${duplicates.length}`)
|
|||
|
|
L.push(`[M1-b] 低熵块=${lowBlocks.length} 低熵字节=${lowBytes} Hmin=${report.s1.minBlockEntropy} Hmax=${report.s1.maxBlockEntropy}`)
|
|||
|
|
L.push(`[M1-b] H 直方图=${JSON.stringify(histogram)}`)
|
|||
|
|
L.push(`[M1-c] 低熵字节占比=${report.s1.lowEntropyRatioPct}%`)
|
|||
|
|
L.push(`[M1-d] 低熵窗口段=${runs.length} 段字节total=${report.m1d.runBytesTotal} min=${report.m1d.runBytesMin} max=${report.m1d.runBytesMax}`)
|
|||
|
|
L.push(`[M1-d] 段尺寸直方图=${JSON.stringify(report.m1d.sizeHistogram)}`)
|
|||
|
|
L.push(`[M1-d] 最大10段=${JSON.stringify(report.m1d.topRuns)}`)
|
|||
|
|
if (s2 !== null) {
|
|||
|
|
L.push(`[S2] 内容数=${s2.contents.length} 单块内容=${s2.singleBlockContents} 低熵内容=${s2.lowEntropyContents.length} 覆盖字节=${s2.coveredBytes}`)
|
|||
|
|
for (const c of s2.contents) {
|
|||
|
|
L.push(` #${c.order} bytes=${c.bytes} blocks=${c.blocks} Hmin=${c.min ?? '-'} low=${c.lowEntropyBlocks}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
process.stdout.write(L.join('\n') + '\n')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch((e) => { process.stderr.write(`overlay-entropy 失败: ${e.message}\n`); process.exit(1) })
|