| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724 |
- // Package orphancheck 取证工具: 找出 VRTX 文件里"既不在空闲链、也不被任何结构引用"的孤儿页,
- // 并按页类型出直方图 + 样例 hex。
- //
- // 两套口径并排输出:
- //
- // - analyzer 口径: 与 analyzer/audit.go 的 runAuditStructural 同一批可达根
- // (表页链 / schema 扩展链 / 未恢复 WAL 链 / 每表 data-heap-index 链),
- // 不含 blob 链 —— 它就是 analyzer 报的 "44 页既不属于任何表/索引, 也不在空闲链上" 的数字。
- // - blob 感知口径: 在上面基础上, 用引擎只读打开库、全表扫出行内 BlobRef,
- // 把 blob 链页 (Type=9) 也计入可达 —— 剩余才是真正的泄漏页。
- //
- // 两口径的差集 = "analyzer 误报的孤儿" (活 blob 页)。若 blob 感知口径仍有孤儿,
- // 按类型直方图指认泄漏路径。
- //
- // 所有页布局常量与 analyzer/main.go 逐一对照 (那边由 layout_assert.go 编译期钉在
- // DB/core 的真实结构体上); 本包不 import analyzer (它是 package main), 常量独立维护。
- package orphancheck
- import (
- "encoding/binary"
- "encoding/hex"
- "fmt"
- "os"
- "sort"
- "strings"
- "git.ycl.pub/yl32940/TsDB2/DB/core"
- "git.ycl.pub/yl32940/TsDB2/DB/core/types"
- )
- const (
- pgsz = 16384
- hdrsz = 4096
- phdr = 20
- pgHead = 0
- pgTable = 1
- pgData = 2
- pgHeap = 3
- pgIMeta = 4
- pgIData = 5
- pgOver = 6
- pgWAL = 7
- pgSExt = 8
- pgBlob = 9
- // PageHdr 字段偏移
- phNext = 12
- phUse = 16
- // 头页字段偏移 (core/header_page.go v11 布局, 编译期断言钉死)
- hdrNextFree = 12
- hdrTablePage = 48
- hdrFreeStart = 56
- hdrFreeEnd = 60
- hdrFreeCount = 64
- hdrFirstWAL = 68
- // 表页/表记录 (TableInfo=104B, TableNameSize=48)
- tinfoSZ = 104
- tiFlag = 48
- tiHPCnt = 52
- tiFHeap = 64
- tiFData = 72
- tiAlloc = 82
- tiFIdx = 88
- sextHdr = 4
- recOff = phdr + sextHdr
- flagTomb = 0x80
- // IndexMeta 记录 (100B, IndexNameSize=64)
- metaSZ = 100
- imNameSz = 64
- imType = 64
- imFcnt = 65
- imFlds = 66
- imEP = 84
- imFlag = 96
- imFlagDel = 0x80
- imFlagUniq = 0x01
- idxHdr = 32
- ixTyp = 20
- ixELen = 22
- ixCnt = 24
- ixLeaf = 28
- // 索引类型 (page/base_page.go)
- itTime = 1
- itPrimary = 2
- itStr = 3
- itCompose = 4
- itBTree = 5
- itHash = 6
- // Hash 索引元数据/桶页偏移 (analyzer/audit.go)
- hmMagic = 32
- hmDir = 48
- hashMagic = 0x54484833
- hashSlot = 12
- hashBktHd = 28
- )
- func le16(b []byte) uint16 { return binary.LittleEndian.Uint16(b) }
- func le32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) }
- func be32(b []byte) uint32 { return binary.BigEndian.Uint32(b) }
- // PageInfo 一个孤儿页的取证信息。
- type PageInfo struct {
- ID uint32
- TypeByte byte
- UseSize uint32
- Next uint32
- Head64 []byte // 页头 64 字节样例
- }
- // ChainInfo 一条 blob 链的取证信息。
- type ChainInfo struct {
- Table string
- PKID uint64
- FirstPage uint32
- Pages int
- BrokenAt uint32 // 链中途断掉/成环/越界时的页号 (0=完整)
- }
- // Result 取证结果。
- type Result struct {
- File string
- TotalPages int
- NextFreePage uint32
- FreeCount int
- FreeDeclared uint32
- FreeVerdict string
- WALPending int // 头页 FirstWALPage != 0 时的未恢复 WAL 链页数 (只读打开跳过恢复)
- Notes []string
- ReachableNoBlob int // analyzer 口径可达页数 (含页 1 / 空闲页不计)
- BlobPages int // blob 链页总数 (含与结构页重叠数)
- // AnalyzerOrphans: 与 analyzer 报数完全同口径的孤儿 (不含 blob 链可达性)
- AnalyzerOrphans []PageInfo
- AnalyzerByType map[string]int
- AnalyzerWalLeft int
- // Orphans: blob 感知口径的真正孤儿
- Orphans []PageInfo
- ByType map[string]int
- WalLeft int
- Samples map[string][]PageInfo // 每类型最多 N 个样例 (头 64B)
- Chains []ChainInfo
- BrokenChains int
- }
- // TypeName 页类型字节 -> 名字 (9=BlobIndex 是 analyzer 不认识的, 这里显式命名)。
- func TypeName(t byte) string {
- switch t {
- case pgHead:
- return "Free(0)"
- case pgTable:
- return "Table(1)"
- case pgData:
- return "Data(2)"
- case pgHeap:
- return "Heap(3)"
- case pgIMeta:
- return "IdxMeta(4)"
- case pgIData:
- return "IdxData(5)"
- case pgOver:
- return "Overflow(6,deprecated)"
- case pgWAL:
- return "WAL(7)"
- case pgSExt:
- return "SchemaExt(8)"
- case pgBlob:
- return "BlobIndex(9)"
- }
- return fmt.Sprintf("?(%d)", t)
- }
- type walker struct {
- f *os.File
- total int
- claim map[uint32]string // pid -> role
- cross [][2]string // 重复认领记录 (诊断)
- }
- // page 读一页。每次新分配 16KB —— 调用方拿到的切片在下一次 page() 之前有效。
- // 取证工具是 one-shot, 不需要 analyzer 那种复用缓冲 (那里要在热循环里省 GC);
- // 换来的正确性: 链 walk 里随时再读子页/元页, 复用单缓冲会被覆盖 (aliasing bug)。
- func (w *walker) page(pid uint32) ([]byte, error) {
- b := make([]byte, pgsz)
- if _, err := w.f.ReadAt(b, hdrsz+int64(pid-1)*pgsz); err != nil {
- return nil, err
- }
- return b, nil
- }
- // take 认领一页; 重复认领记入 cross (同角色不算)。
- func (w *walker) take(pid uint32, role string) bool {
- if pid == 0 {
- return false
- }
- if r, ok := w.claim[pid]; ok {
- if r != role {
- w.cross = append(w.cross, [2]string{r, role})
- }
- return false
- }
- w.claim[pid] = role
- return true
- }
- // chain 沿 NextPage 走链并认领 (带越界/成环保护)。
- func (w *walker) chain(start uint32, role string) int {
- n := 0
- seen := map[uint32]bool{}
- for pid := start; pid != 0; {
- if pid < 2 || int(pid) > w.total || seen[pid] {
- return n
- }
- seen[pid] = true
- if !w.take(pid, role) {
- return n
- }
- n++
- b, err := w.page(pid)
- if err != nil {
- return n
- }
- pid = le32(b[phNext : phNext+4])
- }
- return n
- }
- // idxDataPages 收下一页行存储索引叶条目引用的 DataPageID (与 analyzer 同逻辑)。
- func (w *walker) idxDataPages(b []byte, it byte) int {
- if b[0] != pgIData {
- return 0
- }
- cn := int(le32(b[ixCnt : ixCnt+4]))
- el := int(le16(b[ixELen : ixELen+2]))
- if el <= 4 {
- el = 12
- }
- refOff := el - 4
- if it == itPrimary {
- refOff = 8
- }
- if refOff < 0 || refOff+4 > el {
- return 0
- }
- n := 0
- for i := 0; i < cn; i++ {
- o := idxHdr + i*el
- if o+el > len(b) {
- break
- }
- if p := be32(b[o+refOff : o+refOff+4]); p != 0 {
- if w.take(p, "data(idx)") {
- n++
- }
- }
- }
- return n
- }
- func isRowStore(it byte) bool { return it == itTime || it == itPrimary }
- func isBTreeIdx(it byte) bool { return it == itPrimary || it == itBTree || it == itStr || it == itCompose }
- // indexPages 枚举一个索引占用的全部页 (移植 analyzer/audit.go indexPages)。
- func (w *walker) indexPages(it byte, entry uint32) (n, dp int) {
- if entry == 0 || int(entry) > w.total {
- return 0, 0
- }
- switch {
- case it == itHash:
- meta, err := w.page(entry)
- if err != nil || le32(meta[hmMagic:hmMagic+4]) != hashMagic {
- return 0, 0
- }
- w.take(entry, "index-meta")
- n++
- limit := phdr + ((pgsz-phdr)/hashSlot)*hashSlot
- for dir := le32(meta[hmDir : hmDir+4]); dir != 0; {
- if dir < 2 || int(dir) > w.total {
- break
- }
- if !w.take(dir, "index-dir") {
- break
- }
- n++
- b, err := w.page(dir)
- if err != nil {
- break
- }
- for o := phdr; o+hashSlot <= limit; o += hashSlot {
- if head := le32(b[o : o+4]); head != 0 {
- n += w.chain(head, "index-bucket")
- }
- }
- dir = le32(b[phNext : phNext+4])
- }
- return n, 0
- case isBTreeIdx(it):
- queue := []uint32{entry}
- seen := map[uint32]bool{}
- for len(queue) > 0 {
- pid := queue[0]
- queue = queue[1:]
- if seen[pid] {
- continue
- }
- seen[pid] = true
- if pid < 2 || int(pid) > w.total {
- continue
- }
- if !w.take(pid, "index-node") {
- continue
- }
- n++
- b, err := w.page(pid)
- if err != nil || b[0] != pgIData {
- continue
- }
- if b[ixLeaf] == 1 {
- if isRowStore(it) {
- dp += w.idxDataPages(b, it)
- }
- for nx := le32(b[phNext : phNext+4]); nx != 0; {
- if nx < 2 || int(nx) > w.total || seen[nx] {
- break
- }
- seen[nx] = true
- if !w.take(nx, "index-node") {
- break
- }
- n++
- nb, err := w.page(nx)
- if err != nil {
- break
- }
- if isRowStore(it) {
- dp += w.idxDataPages(nb, it)
- }
- nx = le32(nb[phNext : phNext+4])
- }
- continue
- }
- cn := int(le32(b[ixCnt : ixCnt+4]))
- el := int(le16(b[ixELen : ixELen+2]))
- if el <= 4 {
- el = 12
- }
- for i := 0; i < cn; i++ {
- o := idxHdr + i*el
- if o+el > len(b) {
- break
- }
- if child := be32(b[o+el-4 : o+el]); child != 0 {
- queue = append(queue, child)
- }
- }
- }
- return n, dp
- default:
- n = w.chain(entry, "index-chain")
- return n, 0
- }
- }
- // tableInfo 结构走查所需的最小表信息 (不含 schema)。
- type tableInfo struct {
- name string
- dp0 uint32
- hp0 uint32
- ip0 uint32
- index []idxInfo
- }
- type idxInfo struct {
- typ byte
- ep uint32
- }
- // parseTables 移植 analyzer pTabs 的表链/表记录/索引元数据解析 (跳过 schema 重建)。
- func (w *walker) parseTables() []tableInfo {
- var out []tableInfo
- hdr := make([]byte, hdrsz)
- if _, err := w.f.ReadAt(hdr, 0); err != nil {
- return out
- }
- for pid := le32(hdr[hdrTablePage : hdrTablePage+4]); pid != 0; {
- if pid < 2 || int(pid) > w.total {
- break
- }
- b, err := w.page(pid)
- if err != nil || b[0] != pgTable {
- break
- }
- us := le32(b[phUse : phUse+4])
- if us > pgsz {
- us = pgsz
- }
- for off := uint32(recOff); off+tinfoSZ <= us && off+tinfoSZ <= pgsz; {
- tb := b[off : off+tinfoSZ]
- alloc := uint32(le16(tb[tiAlloc : tiAlloc+2]))
- if tb[tiFlag]&flagTomb != 0 {
- off += tinfoSZ + alloc
- continue
- }
- t := tableInfo{
- name: strings.TrimRight(string(tb[:48]), "\x00"),
- dp0: le32(tb[tiFData : tiFData+4]),
- hp0: le32(tb[tiFHeap : tiFHeap+4]),
- ip0: le32(tb[tiFIdx : tiFIdx+4]),
- }
- // 索引元数据链
- for ip := t.ip0; ip != 0; {
- if ip < 2 || int(ip) > w.total {
- break
- }
- ib, err := w.page(ip)
- if err != nil || ib[0] != pgIMeta {
- break
- }
- iu := le32(ib[phUse : phUse+4])
- if iu > pgsz {
- iu = pgsz
- }
- for io := uint32(phdr); io+metaSZ <= iu && io+metaSZ <= pgsz; io += metaSZ {
- im := ib[io : io+metaSZ]
- if im[imFlag]&imFlagDel != 0 {
- continue
- }
- ep := le32(im[imEP : imEP+4])
- if ep != 0 {
- if pb, err := w.page(ep); err != nil || pb[0] != pgIData {
- ep = 0
- }
- }
- if ep != 0 {
- t.index = append(t.index, idxInfo{typ: im[imType], ep: ep})
- }
- }
- ip = le32(ib[phNext : phNext+4])
- }
- out = append(out, t)
- off += tinfoSZ + alloc
- }
- nx := le32(b[phNext : phNext+4])
- if nx == 0 || nx == pid {
- break
- }
- pid = nx
- }
- return out
- }
- // Options 取证选项。
- type Options struct {
- // EngineBlobScan 用引擎只读打开库扫行内 BlobRef (推荐; 只在副本上跑)。
- EngineBlobScan bool
- // SamplesPerType 每种类型的样例页数 (头 64B hex)。
- SamplesPerType int
- }
- // Run 对 dbPath 执行取证。调用方必须保证 dbPath 是副本 (本函数只读打开,
- // 但"只读打开"本身不适合直接在唯一原件上做)。
- func Run(dbPath string, opt Options) (*Result, error) {
- if opt.SamplesPerType <= 0 {
- opt.SamplesPerType = 3
- }
- f, err := os.Open(dbPath)
- if err != nil {
- return nil, err
- }
- defer f.Close()
- st, err := f.Stat()
- if err != nil {
- return nil, err
- }
- R := &Result{
- File: dbPath,
- TotalPages: int((st.Size() - hdrsz) / pgsz),
- ByType: map[string]int{},
- AnalyzerByType: map[string]int{},
- Samples: map[string][]PageInfo{},
- }
- if R.TotalPages < 0 {
- return nil, fmt.Errorf("文件 %s 不足 4KB 头页", dbPath)
- }
- hdr := make([]byte, hdrsz)
- if _, err := f.ReadAt(hdr, 0); err != nil {
- return nil, err
- }
- nf := le32(hdr[hdrNextFree : hdrNextFree+4])
- R.NextFreePage = nf
- freeHead := le32(hdr[hdrFreeStart : hdrFreeStart+4])
- R.FreeDeclared = le32(hdr[hdrFreeCount : hdrFreeCount+4])
- freeEnd := le32(hdr[hdrFreeEnd : hdrFreeEnd+4])
- w := &walker{f: f, total: R.TotalPages, claim: map[uint32]string{}}
- // 1) 空闲链
- freeSet := map[uint32]bool{}
- {
- seen := map[uint32]bool{}
- bad := 0
- var tail uint32
- for pid := freeHead; pid != 0; {
- if int(pid) > w.total || seen[pid] {
- bad++
- break
- }
- seen[pid] = true
- freeSet[pid] = true
- tail = pid
- b, err := w.page(pid)
- if err != nil {
- bad++
- break
- }
- pid = le32(b[phNext : phNext+4])
- }
- R.FreeCount = len(freeSet)
- switch {
- case bad > 0:
- R.FreeVerdict = "损坏 (成环/越界)"
- case uint32(R.FreeCount) != R.FreeDeclared:
- R.FreeVerdict = fmt.Sprintf("计数不符 (实走 %d, header 声明 %d)", R.FreeCount, R.FreeDeclared)
- case freeHead != 0 && freeEnd != tail:
- R.FreeVerdict = fmt.Sprintf("尾指针不符 (实走尾 %d, header 声明 %d)", tail, freeEnd)
- default:
- R.FreeVerdict = "OK"
- }
- }
- // 2) 结构可达 (analyzer 同口径)
- w.take(1, "reserved")
- w.chain(le32(hdr[hdrTablePage:hdrTablePage+4]), "table-page")
- // schema 扩展链 (表页第 1 页 PHDR 之后, 大端)
- if tp0 := le32(hdr[hdrTablePage : hdrTablePage+4]); tp0 != 0 {
- if b, err := w.page(tp0); err == nil && len(b) >= phdr+4 {
- w.chain(be32(b[phdr:phdr+4]), "schema-ext")
- }
- }
- // 未恢复 WAL 链 (只读打开引擎不会重放, 链页仍是活的可达根)
- if w0 := le32(hdr[hdrFirstWAL : hdrFirstWAL+4]); w0 != 0 {
- R.WALPending = w.chain(w0, "wal-pending")
- R.Notes = append(R.Notes, fmt.Sprintf("头页 FirstWALPage=%d (%d 页): 上次未干净关闭或只读跳过恢复, 页归属可能不完整", w0, R.WALPending))
- }
- tables := w.parseTables()
- for i := range tables {
- t := &tables[i]
- w.chain(t.dp0, "data:"+t.name)
- w.chain(t.hp0, "heap:"+t.name)
- w.chain(t.ip0, "imeta:"+t.name)
- for _, idx := range t.index {
- w.indexPages(idx.typ, idx.ep)
- }
- }
- R.ReachableNoBlob = len(w.claim)
- // 3) 孤儿: analyzer 口径 (此时 claim 里还没有 blob 链)
- snapNoBlob := make(map[uint32]string, len(w.claim))
- for pid, role := range w.claim {
- snapNoBlob[pid] = role
- }
- R.AnalyzerOrphans, R.AnalyzerByType, R.AnalyzerWalLeft = collectOrphans(f, w, nf, freeSet, snapNoBlob)
- // 4) blob 链可达 (引擎只读扫描行内 BlobRef)
- blobClaimed := 0
- if opt.EngineBlobScan {
- chains, notes, claimed := scanBlobChains(dbPath, tables, w)
- R.Chains = chains
- R.Notes = append(R.Notes, notes...)
- blobClaimed = claimed
- }
- // 5) 孤儿: blob 感知口径 (在结构可达上叠加 blob 链), 并按类型留样
- R.Orphans, R.ByType, R.WalLeft = collectOrphans(f, w, nf, freeSet, w.claim)
- R.BlobPages = blobClaimed
- for _, ci := range R.Chains {
- if ci.BrokenAt != 0 {
- R.BrokenChains++
- }
- }
- perType := map[string]int{}
- for _, p := range R.Orphans {
- tn := TypeName(p.TypeByte)
- if perType[tn] < opt.SamplesPerType {
- R.Samples[tn] = append(R.Samples[tn], p)
- perType[tn]++
- }
- }
- if len(w.cross) > 0 {
- R.Notes = append(R.Notes, fmt.Sprintf("有 %d 次跨角色重复认领 (一页多个主人), 详见交叉引用", len(w.cross)))
- }
- return R, nil
- }
- // collectOrphans 算 [2, NextFreePage) 内不在空闲链、不在 claim 集合里的页,
- // 返回 (全部孤儿, 按类型直方图, 其中 WAL 类型页数)。
- func collectOrphans(f *os.File, w *walker, nf uint32, freeSet map[uint32]bool,
- claim map[uint32]string) ([]PageInfo, map[string]int, int) {
- var out []PageInfo
- byType := map[string]int{}
- walLeft := 0
- buf := make([]byte, pgsz)
- for pid := uint32(2); int(pid) < int(nf) && int(pid) <= w.total; pid++ {
- if _, ok := claim[pid]; ok {
- continue
- }
- if freeSet[pid] {
- continue
- }
- pi := PageInfo{ID: pid}
- if _, err := f.ReadAt(buf, hdrsz+int64(pid-1)*pgsz); err == nil {
- pi.TypeByte = buf[0]
- pi.UseSize = le32(buf[phUse : phUse+4])
- pi.Next = le32(buf[phNext : phNext+4])
- pi.Head64 = append([]byte(nil), buf[:64]...)
- }
- out = append(out, pi)
- byType[TypeName(pi.TypeByte)]++
- if pi.TypeByte == pgWAL {
- walLeft++
- }
- }
- return out, byType, walLeft
- }
- // scanBlobChains 引擎只读打开库, 全表扫出行内 BlobRef, 走链认领 blob 页。
- // 返回 (链清单, 备注, 认领页数)。任何引擎错误都不致命 —— 降级为"未走 blob 链"。
- func scanBlobChains(dbPath string, tables []tableInfo, w *walker) ([]ChainInfo, []string, int) {
- var notes []string
- db, err := core.OpenWithOptions(dbPath, core.OpenOptions{ReadOnly: true})
- if err != nil {
- return nil, []string{fmt.Sprintf("引擎只读打开失败, 跳过 blob 链走查: %v", err)}, 0
- }
- defer db.Close()
- before := len(w.claim)
- var chains []ChainInfo
- broken := 0
- refs := 0
- for i := range tables {
- t := &tables[i]
- tbl, ok := db.FindTable(t.name)
- if !ok {
- notes = append(notes, fmt.Sprintf("表 %s: 引擎 FindTable 失败, 该表 blob 列未走查", t.name))
- continue
- }
- perPK := map[uint64][]types.BlobRef{}
- scanErr := tbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
- for _, fv := range vals {
- if ref, ok := fv.Value.(types.BlobRef); ok && !ref.Empty() {
- perPK[pkID] = append(perPK[pkID], ref)
- }
- }
- return true
- })
- if scanErr != nil {
- notes = append(notes, fmt.Sprintf("表 %s: 扫描失败 (%v), blob 列可能漏走", t.name, scanErr))
- }
- for pkID, rs := range perPK {
- for _, ref := range rs {
- refs++
- ci := ChainInfo{Table: t.name, PKID: pkID, FirstPage: ref.FirstPageID}
- seen := map[uint32]bool{}
- for p := ref.FirstPageID; p != 0; {
- if p < 2 || int(p) > w.total || seen[p] {
- ci.BrokenAt = p
- broken++
- break
- }
- seen[p] = true
- ci.Pages++
- w.take(p, "blob:"+t.name)
- b, err := w.page(p)
- if err != nil {
- ci.BrokenAt = p
- broken++
- break
- }
- if b[0] != pgBlob {
- ci.BrokenAt = p
- broken++
- break
- }
- p = le32(b[phNext : phNext+4])
- }
- chains = append(chains, ci)
- }
- }
- }
- if refs == 0 {
- notes = append(notes, "全库行内没有任何非空 BlobRef (无 blob 列或无 blob 数据)")
- }
- if broken > 0 {
- notes = append(notes, fmt.Sprintf("有 %d 条 blob 链中途断裂/类型不符/越界 —— 链页泄漏或行内引用损坏", broken))
- }
- return chains, notes, len(w.claim) - before
- }
- // SampleHex 把样例页的头 64B 格式化成 hex 行。
- func SampleHex(p PageInfo) string {
- return hex.Dump(p.Head64)
- }
- // SortedTypes 直方图按类型名排序输出。
- func SortedTypes(m map[string]int) []string {
- ks := make([]string, 0, len(m))
- for k := range m {
- ks = append(ks, k)
- }
- sort.Strings(ks)
- return ks
- }
|