#!/usr/bin/env python3
"""
cum-ls - inspect a cum filesystem image
========================================
Usage: cum-ls <image>
       cum-ls --extract <image> <dest-dir>
       cum-ls --info <image>

Reads the on-disk format produced by mkfs.cum and prints / extracts it.
"""

import os
import sys
import struct

CUM_BLOCK_SIZE = 4096
CUM_SUPER_OFF = 8 * CUM_BLOCK_SIZE
CUM_MAX_NAME = 256
INODE_REC_SIZE = struct.calcsize('<I B B H I I Q I I I 256s')
SB_FIXED = 9 * 4 + 2 * 8 + 256  # 9 u32 + 2 u64 + 256 label

CUM_FT_FILE    = 0
CUM_FT_DIR     = 1
CUM_FT_SYMLINK = 2


def read_super(img):
    off = CUM_SUPER_OFF
    def u32(o): return struct.unpack_from('<I', img, off + o)[0]
    def u64(o): return struct.unpack_from('<Q', img, off + o)[0]
    sb = {
        'magic0': u32(0), 'magic1': u32(4), 'version': u32(8),
        'block_size': u32(12), 'super_off': u32(16),
        'inode_table_off': u32(20), 'data_off': u32(24),
        'total_blocks': u32(28), 'total_inodes': u32(32),
        'used_blocks': u32(36), 'used_inodes': u32(40),
        'next_inode': u32(44), 'root_inode': u32(48),
        'total_bytes': u64(60),
    }
    return sb


def read_inode(img, sb, ino):
    off = sb['inode_table_off'] + ino * INODE_REC_SIZE
    (ino_n, ftype, unused, mode, uid, gid, size, block, nlink, mtime,
     name) = struct.unpack_from('<I B B H I I Q I I I 256s', img, off)
    name = name.split(b'\0')[0].decode('utf-8', 'replace')
    return {'ino': ino_n, 'type': ftype, 'mode': mode, 'uid': uid,
            'gid': gid, 'size': size, 'block': block, 'nlink': nlink,
            'mtime': mtime, 'name': name}


def read_data(img, sb, rec):
    if rec['type'] == CUM_FT_DIR or rec['size'] == 0:
        return b''
    start = rec['block'] * CUM_BLOCK_SIZE
    return img[start:start + rec['size']]


def load_image(path):
    if not os.path.exists(path):
        sys.exit(f'cum-ls: {path}: no such image')
    with open(path, 'rb') as f:
        img = f.read()
    sb = read_super(img)
    if sb['magic0'] != 0x4343 or sb['magic1'] != 0x4D55:
        sys.exit('cum-ls: not a cum filesystem (bad magic)')
    return img, sb


def type_str(t):
    return {CUM_FT_FILE: 'f', CUM_FT_DIR: 'd', CUM_FT_SYMLINK: 'l'}.get(t, '?')


def cmd_info(img, sb):
    print('cum filesystem info:')
    print(f'  magic:           0x{sb["magic0"]:04x} 0x{sb["magic1"]:04x}')
    print(f'  version:         {sb["version"]}')
    print(f'  block size:      {sb["block_size"]}')
    print(f'  total blocks:    {sb["total_blocks"]}')
    print(f'  total bytes:     {sb["total_bytes"]} ({sb["total_bytes"]/1e6:.1f} MB)')
    print(f'  total inodes:    {sb["total_inodes"]}')
    print(f'  used inodes:     {sb["used_inodes"]}')
    print(f'  used blocks:     {sb["used_blocks"]}')
    print(f'  inode table off: {sb["inode_table_off"]}')
    print(f'  data region off: {sb["data_off"]}')
    print(f'  root inode:      {sb["root_inode"]}')


def cmd_list(img, sb):
    print('inode  type  size       name')
    print('-----  ----  ---------  ------------------')
    for ino in range(sb['total_inodes']):
        rec = read_inode(img, sb, ino)
        name = rec['name'] if rec['name'] else '/'
        print(f'{rec["ino"]:5d}  {type_str(rec["type"]):4s}  {rec["size"]:9d}  {name}')
    print('-----')
    print(f'{sb["total_inodes"]} inodes')


def cmd_extract(img, sb, dest):
    os.makedirs(dest, exist_ok=True)
    count = 0
    for ino in range(sb['total_inodes']):
        rec = read_inode(img, sb, ino)
        name = rec['name']
        if not name:
            continue
        out = os.path.join(dest, name)
        os.makedirs(os.path.dirname(out), exist_ok=True)
        if rec['type'] == CUM_FT_DIR:
            os.makedirs(out, exist_ok=True)
        elif rec['type'] == CUM_FT_SYMLINK:
            target = read_data(img, sb, rec)
            if os.path.lexists(out):
                os.unlink(out)
            os.symlink(target.decode('utf-8', 'replace'), out)
        else:
            data = read_data(img, sb, rec)
            with open(out, 'wb') as f:
                f.write(data)
            # restore exec bit from stored mode (file mode bits 0o111)
            try:
                os.chmod(out, rec['mode'] & 0o777)
            except OSError:
                pass
        count += 1
    print(f'cum-ls: extracted {count} entries to {dest}')


def main():
    args = sys.argv[1:]
    if not args:
        sys.exit('usage: cum-ls [--info|--extract <dest>] <image>')
    mode = 'list'
    dest = None
    if args[0] == '--info':
        mode = 'info'; args = args[1:]
    elif args[0] == '--extract':
        mode = 'extract'; dest = args[1]; args = args[2:]
    if not args:
        sys.exit('cum-ls: missing image path')
    image = args[0]

    img, sb = load_image(image)
    if mode == 'info':
        cmd_info(img, sb)
    elif mode == 'extract':
        cmd_extract(img, sb, dest)
    else:
        cmd_list(img, sb)


if __name__ == '__main__':
    main()
