OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-inspector commits / efefd74

extract: copy files OUT of a Hyperdrive (read-only, works on locked stores)
New `extract <storage-path> --data-ptr <n> --out <dir>` command — reconstructs and writes a notebook drive's files (notes, media, metadata) to disk by reading raw blob blocks from RocksDB, like `cores`/`usage`. Works while a daemon holds the lock (--readonly). Useful for inspecting content before any reclaim. - lib/drive-extract.js: list file entries (drive-ls) -> resolve the blobs core (content-feed key match, else paired corePointer+1, else --blob-ptr override) -> one pass reading the needed blocks -> write each file preserving paths. - Blob math per hyperblobs: the concatenated blocks ARE the blob; byteOffset/ byteLength are cumulative core metadata, NOT a slice (the slicing bug emptied small single-block files and corrupted media). - Flags: --filter, --max-bytes, --limit, --dry-run, --blob-ptr, --readonly. Verified against the live hypernote store: extracted notebook (dataPtr 3) = byte-exact valid JSON notes + RIFF/JPEG images, 545.1 KB matching `ls`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
efefd7476cf7a149139ebf4ae80b60c29ae859d9
by plankatron <fleeky@gmail.com> · 2026-06-24T00:30:59.000Z

Changed files (2) +171 -0

bin/cli.js +46 -0
……@@ -7,6 +7,7 @@ import { enumerateCores } from '../lib/core-enumerator.js'
77import { analyzeCoreDiskUsage, formatAnalysis } from '../lib/deep-analyzer.js'
88import { walkHyperbeeBlocks, decodeNode, decodeDriveEntry, stripSubEncoder, decodeHeader } from '../lib/block-reader.js'
99import { listDriveFromDB, buildTree, formatTree, formatFlat } from '../lib/drive-ls.js'
10import { extractDrive } from '../lib/drive-extract.js'
1011import { corestoreDU, formatDU } from '../lib/du.js'
1112import b4a from 'b4a'
1213import { existsSync } from 'fs'
……@@ -32,11 +33,17 @@ Commands:
3233 ls <db-path> <dataPtr> List all files in a Hyperdrive (by RocksDB dataPointer)
3334 usage <db-path> Deep analysis: which cores consume how many bytes on disk
3435 blocks <storage-path> --key <hex> Read raw Hyperbee blocks, decode file entries
36 extract <storage-path> --data-ptr <n> --out <dir> Copy files OUT of a Hyperdrive (read-only)
3537
3638Options:
3739 --readonly Open databases in read-only mode (for locked DBs)
3840 --limit <n> Max keys to scan (default: 100000)
3941 --key <hex> Specify a Hypercore key
42 --data-ptr <n> Metadata core dataPointer (from "cores"/"usage") for extract
43 --out <dir> Destination directory for extract
44 --filter <substr> Only extract paths containing this string
45 --max-bytes <n> Skip files larger than this (e.g. 5M)
46 --dry-run List what would be extracted without writing
4047 --help Show this help
4148
4249Examples:
……@@ -44,6 +51,8 @@ Examples:
4451 hyper-inspector audit
4552 hyper-inspector cores ~/.config/hypernote/server-data
4653 hyper-inspector drive ~/.config/hypernote/server-data
54 hyper-inspector ls ~/.config/hypernote/server-data/db 3 --readonly
55 hyper-inspector extract ~/.config/hypernote/server-data --data-ptr 3 --out /tmp/dump --readonly
4756 hyper-inspector inspect ~/.config/pear/corestores/platform/db --readonly
4857 hyper-inspector apps ~/.config/pear/corestores/platform/db --readonly
4958`
……@@ -666,3 +675,40 @@ const KEY_TYPES_LABEL = {
666675 0x03: 'info',
667676 0x04: 'data'
668677}
678
679if (command === 'extract') {
680 const storagePath = resolve(args[1] || '')
681 const readOnly = args.includes('--readonly')
682 const dryRun = args.includes('--dry-run')
683 const dpIdx = args.indexOf('--data-ptr')
684 const dataPtr = dpIdx >= 0 ? parseInt(args[dpIdx + 1]) : NaN
685 const outIdx = args.indexOf('--out')
686 const outDir = outIdx >= 0 ? resolve(args[outIdx + 1]) : null
687 const filterIdx = args.indexOf('--filter')
688 const filter = filterIdx >= 0 ? args[filterIdx + 1] : null
689 const maxIdx = args.indexOf('--max-bytes')
690 const maxBytes = maxIdx >= 0 ? parseSize(args[maxIdx + 1]) : Infinity
691 const limitIdx = args.indexOf('--limit')
692 const limit = limitIdx >= 0 ? parseInt(args[limitIdx + 1]) : Infinity
693 const blobIdx = args.indexOf('--blob-ptr')
694 const blobDataPointer = blobIdx >= 0 ? parseInt(args[blobIdx + 1]) : null
695
696 if (!storagePath || isNaN(dataPtr) || (!outDir && !dryRun)) {
697 console.error('Usage: hyper-inspector extract <storage-path> --data-ptr <n> --out <dir> [--readonly] [--filter <s>] [--max-bytes <n>] [--dry-run]')
698 console.error(' --data-ptr: the metadata core dataPointer from "cores"/"usage" output')
699 process.exit(1)
700 }
701
702 console.log(`\nExtracting drive (dataPointer ${dataPtr}) from ${storagePath}`)
703 if (dryRun) console.log('(dry run — no files written)')
704
705 const res = await extractDrive(storagePath, dataPtr, outDir, { readOnly, filter, maxBytes, limit, dryRun, blobDataPointer })
706
707 if (res.note) console.log('Note: ' + res.note)
708 console.log(`Blobs core dataPointer: ${res.blobsDataPointer}`)
709 for (const f of res.files.slice(0, 200)) {
710 console.log(` ${String(f.status || '').padEnd(13)} ${formatBytes(f.bytes).padStart(10)} ${f.path}`)
711 }
712 console.log(`\n${dryRun ? 'Would write' : 'Wrote'} ${res.written} file(s), ${formatBytes(res.totalBytes)}${res.skipped ? `, skipped ${res.skipped}` : ''}`)
713 if (!dryRun && outDir) console.log(`Output: ${outDir}`)
714}
lib/drive-extract.js +125 -0
……@@ -0,0 +1,125 @@
1// Extract (copy out) files from a Hyperdrive stored in a corestore RocksDB.
2// Read-only — works on a LOCKED store (running daemon), like `cores`/`usage`.
3// Reconstructs each file from raw blob blocks; never writes to the store.
4import RocksDB from 'rocksdb-native'
5import b4a from 'b4a'
6import fs from 'fs'
7import path from 'path'
8import { enumerateCores } from './core-enumerator.js'
9import { listDriveFromDB } from './drive-ls.js'
10
11function encodeVarint (n) {
12 const bytes = []
13 while (n >= 0x80) { bytes.push((n & 0x7f) | 0x80); n >>>= 7 }
14 bytes.push(n & 0x7f)
15 return Buffer.from(bytes)
16}
17
18// Decode the trailing varint block index from a data-block key.
19function blockIndexFromKey (key, prefixLen) {
20 let pos = prefixLen, idx = 0, shift = 0
21 while (pos < key.length) {
22 const b = key[pos++]
23 idx |= (b & 0x7f) << shift
24 if ((b & 0x80) === 0) break
25 shift += 7
26 }
27 return idx
28}
29
30/**
31 * Extract files from the Hyperdrive whose metadata (Hyperbee) core is at
32 * `dbDataPointer`. Copies file content to `outDir`, preserving paths.
33 *
34 * @param {string} storagePath corestore root (the dir with a CORESTORE file; db/ inside)
35 * @param {number} dbDataPointer the metadata core's dataPointer (from `cores`/`usage`)
36 * @param {string} outDir destination directory
37 * @param {object} opts { readOnly, filter, maxBytes, limit, dryRun }
38 * @returns {Promise<{written, skipped, totalBytes, blobsDataPointer, files}>}
39 */
40export async function extractDrive (storagePath, dbDataPointer, outDir, opts = {}) {
41 const { readOnly = true, filter = null, maxBytes = Infinity, limit = Infinity, dryRun = false, blobDataPointer = null } = opts
42 const dbPath = path.join(storagePath, 'db')
43
44 // 1. file entries (path + blob coords) + (maybe) the content-feed core key
45 const { header, files } = await listDriveFromDB(dbPath, dbDataPointer, { readOnly })
46 let targets = files.filter(f => f.blob && f.blob.byteLength > 0)
47 if (filter) targets = targets.filter(f => f.path.includes(filter))
48 targets = targets.filter(f => f.blob.byteLength <= maxBytes).slice(0, limit)
49
50 // 2. resolve the blobs (content) core's dataPointer
51 const cores = await enumerateCores(dbPath, { readOnly })
52 let blobsDataPointer = null
53 let note = null
54 if (blobDataPointer != null) {
55 blobsDataPointer = blobDataPointer // explicit override
56 } else if (header && header.contentFeed) {
57 const hex = b4a.toString(header.contentFeed, 'hex')
58 const c = cores.find(x => x.publicKey === hex)
59 if (c) blobsDataPointer = c.dataPointer
60 else note = 'content feed core not found (' + hex.slice(0, 16) + '); pass --blob-ptr'
61 }
62 if (blobsDataPointer == null) {
63 // Fallback: Hyperdrive creates the blobs core paired right after the db
64 // core. Find the db core by its dataPointer, take the next corePointer.
65 const dbCore = cores.find(x => x.dataPointer === dbDataPointer)
66 if (dbCore) {
67 const paired = cores.find(x => x.corePointer === dbCore.corePointer + 1)
68 if (paired) { blobsDataPointer = paired.dataPointer; note = note || 'blobs core resolved by pairing (corePointer ' + paired.corePointer + ')' }
69 }
70 }
71 if (blobsDataPointer == null) {
72 return { written: 0, skipped: targets.length, totalBytes: 0, blobsDataPointer: null, files: [], note: note || 'could not resolve blobs core — pass --blob-ptr <n>' }
73 }
74
75 // 3. collect the union of needed block indices, then one pass to read them
76 const needed = new Set()
77 for (const f of targets) {
78 for (let i = f.blob.blockOffset; i < f.blob.blockOffset + f.blob.blockLength; i++) needed.add(i)
79 }
80
81 const blockMap = new Map()
82 if (needed.size && !dryRun) {
83 const db = new RocksDB(dbPath, { readOnly, columnFamilies: ['corestore'] })
84 await db.ready()
85 const cs = db.columnFamily('corestore')
86 const prefix = Buffer.concat([encodeVarint(4), encodeVarint(blobsDataPointer), encodeVarint(3)])
87 const end = Buffer.concat([encodeVarint(4), encodeVarint(blobsDataPointer), encodeVarint(4)])
88 const iter = cs.iterator({ gte: prefix, lt: end })
89 await new Promise((resolve, reject) => {
90 iter.on('data', ({ key, value }) => {
91 const idx = blockIndexFromKey(key, prefix.length)
92 if (value && needed.has(idx)) blockMap.set(idx, Buffer.from(value))
93 })
94 iter.on('end', resolve)
95 iter.on('error', reject)
96 })
97 await db.close()
98 }
99
100 // 4. reassemble each file and write it out
101 let written = 0, skipped = 0, totalBytes = 0
102 const out = []
103 for (const f of targets) {
104 const parts = []
105 let ok = true
106 for (let i = f.blob.blockOffset; i < f.blob.blockOffset + f.blob.blockLength; i++) {
107 const b = blockMap.get(i)
108 if (!b) { ok = false; break }
109 parts.push(b)
110 }
111 if (!ok) { skipped++; out.push({ path: f.path, bytes: f.blob.byteLength, status: 'missing-blocks' }); continue }
112 // hyperblobs stores one blob per block-range; the concatenated blocks ARE
113 // the blob (byteOffset/byteLength are cumulative core metadata, NOT a slice).
114 const content = Buffer.concat(parts)
115 const dest = path.join(outDir, f.path.replace(/^\/+/, ''))
116 if (!dryRun) {
117 fs.mkdirSync(path.dirname(dest), { recursive: true })
118 fs.writeFileSync(dest, content)
119 }
120 written++; totalBytes += content.length
121 out.push({ path: f.path, bytes: content.length, dest, status: dryRun ? 'would-write' : 'written' })
122 }
123
124 return { written, skipped, totalBytes, blobsDataPointer, files: out, note }
125}