Initial remote commit
This commit is contained in:
215
ext4db.py
Executable file
215
ext4db.py
Executable file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite persistence layer for the ext4 recovery pipeline.
|
||||
|
||||
Schema:
|
||||
filesystem_meta – superblock geometry + scan parameters
|
||||
inodes – per-inode metadata (mode, timestamps, status, …)
|
||||
dir_entries – directory name → child inode mappings
|
||||
scanned_groups – which block groups have been fully scanned (for resume)
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS filesystem_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inodes (
|
||||
inum INTEGER PRIMARY KEY,
|
||||
grp INTEGER NOT NULL,
|
||||
mode INTEGER,
|
||||
itype INTEGER,
|
||||
uid INTEGER,
|
||||
gid INTEGER,
|
||||
size INTEGER,
|
||||
atime INTEGER,
|
||||
ctime INTEGER,
|
||||
mtime INTEGER,
|
||||
dtime INTEGER,
|
||||
links INTEGER,
|
||||
flags INTEGER,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dir_entries (
|
||||
parent_inum INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
child_inum INTEGER NOT NULL,
|
||||
ftype INTEGER,
|
||||
PRIMARY KEY (parent_inum, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scanned_groups (
|
||||
grp INTEGER PRIMARY KEY,
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inodes_itype ON inodes(itype);
|
||||
CREATE INDEX IF NOT EXISTS idx_inodes_status ON inodes(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_de_parent ON dir_entries(parent_inum);
|
||||
CREATE INDEX IF NOT EXISTS idx_de_child ON dir_entries(child_inum);
|
||||
"""
|
||||
|
||||
|
||||
def open_db(path):
|
||||
"""Open (or create) the recovery database. Returns a sqlite3.Connection."""
|
||||
db = sqlite3.connect(path)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.executescript(_SCHEMA)
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
# ── filesystem metadata ───────────────────────────────────────────────────────
|
||||
|
||||
def save_fs_meta(db, sb, device, backup_sb_block, zeroed_groups=0):
|
||||
meta = {
|
||||
'device': device,
|
||||
'backup_sb_block': backup_sb_block,
|
||||
'zeroed_groups': zeroed_groups,
|
||||
'inodes_count': sb['inodes_count'],
|
||||
'blocks_count': sb['blocks_count'],
|
||||
'blocks_per_group': sb['blocks_per_group'],
|
||||
'inodes_per_group': sb['inodes_per_group'],
|
||||
'inode_size': sb['inode_size'],
|
||||
'desc_size': sb['desc_size'],
|
||||
}
|
||||
db.executemany(
|
||||
"INSERT OR REPLACE INTO filesystem_meta VALUES (?, ?)",
|
||||
((k, str(v)) for k, v in meta.items()),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_fs_meta(db):
|
||||
"""Return filesystem_meta as a plain dict (all values are strings)."""
|
||||
rows = db.execute("SELECT key, value FROM filesystem_meta").fetchall()
|
||||
return {r['key']: r['value'] for r in rows}
|
||||
|
||||
|
||||
def get_fs_meta_int(db, key, default=0):
|
||||
row = db.execute("SELECT value FROM filesystem_meta WHERE key=?", (key,)).fetchone()
|
||||
return int(row['value']) if row else default
|
||||
|
||||
|
||||
# ── inode table ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _i64(v):
|
||||
"""Convert an unsigned Python int to SQLite-safe signed 64-bit integer.
|
||||
|
||||
Corrupted inodes can produce 64-bit values (e.g. size = size_lo | size_hi<<32)
|
||||
that exceed SQLite's signed INTEGER max (2^63-1) and cause OverflowError.
|
||||
"""
|
||||
v = int(v) & 0xFFFFFFFFFFFFFFFF
|
||||
return v - (1 << 64) if v >= (1 << 63) else v
|
||||
|
||||
|
||||
def save_inode(db, inum, grp, inode, status):
|
||||
db.execute(
|
||||
"""INSERT OR REPLACE INTO inodes
|
||||
(inum, grp, mode, itype, uid, gid, size, atime, ctime, mtime, dtime, links, flags, status)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
inum, grp,
|
||||
_i64(inode.get('mode', 0)),
|
||||
_i64(inode.get('type', 0)),
|
||||
_i64(inode.get('uid', 0)),
|
||||
_i64(inode.get('gid', 0)),
|
||||
_i64(inode.get('size', 0)),
|
||||
_i64(inode.get('atime', 0)),
|
||||
_i64(inode.get('ctime', 0)),
|
||||
_i64(inode.get('mtime', 0)),
|
||||
_i64(inode.get('dtime', 0)),
|
||||
_i64(inode.get('links', 0)),
|
||||
_i64(inode.get('flags', 0)),
|
||||
status,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_inode(db, inum):
|
||||
"""Return the inode row or None."""
|
||||
return db.execute("SELECT * FROM inodes WHERE inum=?", (inum,)).fetchone()
|
||||
|
||||
|
||||
def get_all_dir_inums(db, include_deleted=False):
|
||||
"""Return list of inode numbers for all directory inodes."""
|
||||
ITYPE_DIR = 0x4000
|
||||
if include_deleted:
|
||||
rows = db.execute(
|
||||
"SELECT inum FROM inodes WHERE itype=? AND status != 'unallocated'",
|
||||
(ITYPE_DIR,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT inum FROM inodes WHERE itype=? AND status='active'",
|
||||
(ITYPE_DIR,),
|
||||
).fetchall()
|
||||
return [r['inum'] for r in rows]
|
||||
|
||||
|
||||
# ── directory entries ─────────────────────────────────────────────────────────
|
||||
|
||||
def save_dir_entry(db, parent_inum, name, child_inum, ftype):
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO dir_entries (parent_inum, name, child_inum, ftype) VALUES (?,?,?,?)",
|
||||
(parent_inum, name, child_inum, ftype),
|
||||
)
|
||||
|
||||
|
||||
def get_dir_entries(db, parent_inum):
|
||||
"""Return dict of name -> (child_inum, ftype) for a directory."""
|
||||
rows = db.execute(
|
||||
"SELECT name, child_inum, ftype FROM dir_entries WHERE parent_inum=?",
|
||||
(parent_inum,),
|
||||
).fetchall()
|
||||
return {r['name']: (r['child_inum'], r['ftype']) for r in rows}
|
||||
|
||||
|
||||
def get_dotdot(db, inum):
|
||||
"""Return the parent inode number recorded in the .. entry, or None."""
|
||||
row = db.execute(
|
||||
"SELECT child_inum FROM dir_entries WHERE parent_inum=? AND name='..'",
|
||||
(inum,),
|
||||
).fetchone()
|
||||
return row['child_inum'] if row else None
|
||||
|
||||
|
||||
def get_dot(db, inum):
|
||||
"""Return the inode number recorded in the . entry, or None."""
|
||||
row = db.execute(
|
||||
"SELECT child_inum FROM dir_entries WHERE parent_inum=? AND name='.'",
|
||||
(inum,),
|
||||
).fetchone()
|
||||
return row['child_inum'] if row else None
|
||||
|
||||
|
||||
# ── scan progress ─────────────────────────────────────────────────────────────
|
||||
|
||||
def mark_group_scanned(db, grp):
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO scanned_groups VALUES (?, ?)",
|
||||
(grp, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
|
||||
def get_scanned_groups(db):
|
||||
"""Return set of already-scanned group numbers."""
|
||||
rows = db.execute("SELECT grp FROM scanned_groups").fetchall()
|
||||
return {r['grp'] for r in rows}
|
||||
|
||||
|
||||
# ── summary stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
def print_stats(db):
|
||||
total = db.execute("SELECT COUNT(*) FROM inodes").fetchone()[0]
|
||||
active = db.execute("SELECT COUNT(*) FROM inodes WHERE status='active'").fetchone()[0]
|
||||
dirs = db.execute("SELECT COUNT(*) FROM inodes WHERE itype=0x4000").fetchone()[0]
|
||||
scanned = db.execute("SELECT COUNT(*) FROM scanned_groups").fetchone()[0]
|
||||
dentries = db.execute("SELECT COUNT(*) FROM dir_entries").fetchone()[0]
|
||||
print(f" inodes scanned : {total:>10,} (active={active:,}, dirs={dirs:,})")
|
||||
print(f" dir entries : {dentries:>10,}")
|
||||
print(f" groups scanned : {scanned:>10,}")
|
||||
Reference in New Issue
Block a user