software/hoardy-adb/./hoardy_adb/__main__.py

List contens of, unpack, and convert Android Backup files to TAR files and back, decrypt, encrypt, split them into by-app pieces, etc. Android Backup files are backup.ab, *.ab, *.adb, and similarly named files produced by adb backup, bmgr, and similar tools.

Files

Raw Source

Contents

#!/usr/bin/env python3
#
# This file is a part of `hoardy-adb` project.
#
# Copyright (c) 2018-2024 Jan Malakhovski <oxij@oxij.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import io
import os
import secrets
import re
import struct
import sys
import time
import typing as _t
import zlib

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.padding import PKCS7
from gettext import gettext, ngettext

from . import argparse_better as argparse
from .argparse_better import Namespace
from .exceptions import *
from . import tariter

__prog__ = "hoardy-adb"
BUFFER_SIZE = 16 * 1024 ** 2

class ReadProxy:
    def __init__(self, fobj : _t.Any, block_size : int) -> None:
        self._fobj = fobj
        self._block_size = block_size
        self._buffer = b""
        self._eof = False

    def _handle_eof(self) -> bytes:
        raise NotImplementedError

    def _handle_data(self, data : bytes) -> bytes:
        raise NotImplementedError

    def read(self, size : int = -1) -> bytes:
        while not self._eof and (size == -1 or len(self._buffer) < size):
            data = self._fobj.read(self._block_size)
            if len(data) == 0:
                self._buffer += self._handle_eof()
                self._eof = True
            else:
                self._buffer += self._handle_data(data)

        if len(self._buffer) == 0:
            return b""

        if size == -1 or len(self._buffer) == size:
            res = self._buffer
            self._buffer = b""
            return res
        else:
            res = self._buffer[:size]
            self._buffer = self._buffer[len(res):]
            return res

    def tell(self) -> int:
        return self._fobj.tell() # type: ignore

    def fileno(self) -> int:
        return self._fobj.fileno() # type: ignore

    def close(self) -> None:
        self._fobj.close()

class ReadPreprocessor(ReadProxy):
    def __init__(self, preprocessor : _t.Any, fobj : _t.Any, block_size : int) -> None:
        super().__init__(fobj, block_size)
        self._preprocessor = preprocessor

    def _handle_eof(self) -> bytes:
        return self._preprocessor.finalize() # type: ignore

    def _handle_data(self, data : bytes) -> bytes:
        return self._preprocessor.update(data) # type: ignore

class WritePreprocessor:
    def __init__(self, preprocessor : _t.Any, fobj : _t.Any) -> None:
        self._fobj = fobj
        self._preprocessor = preprocessor

    def write(self, data : bytes) -> None:
        self._fobj.write(self._preprocessor.update(data))

    def flush(self) -> None:
        self._fobj.write(self._preprocessor.finalize())
        self._fobj.flush()

    def close(self) -> None:
        self._fobj.close()

class Decompressor(ReadProxy):
    def __init__(self, fobj : _t.Any, block_size : int) -> None:
        super().__init__(fobj, block_size)
        self._decompressor = zlib.decompressobj(0)

    def _handle_eof(self) -> bytes:
        return self._decompressor.flush()

    def _handle_data(self, data : bytes) -> bytes:
        return self._decompressor.decompress(data)

class Compressor:
    def __init__(self, fobj : _t.Any) -> None:
        self._fobj = fobj
        self._compressor = zlib.compressobj()

    def write(self, data : bytes) -> None:
        self._fobj.write(self._compressor.compress(data))

    def flush(self) -> None:
        self._fobj.write(self._compressor.flush())
        self._fobj.flush()

    def close(self) -> None:
        self._fobj.close()

def androidKDF(length : int, salt : bytes, iterations : int, passphrase : bytes) -> bytes:
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA1(),
        length=length,
        salt=salt,
        iterations=iterations,
    )
    return kdf.derive(passphrase)

def make_mangled_key(master_key : bytes) -> bytes:
    # this is actually what Java does on implicit conversion from String
    # to Bytes: it smears the last bit into the next byte; inspired by a
    # similar, but less comprehensible, line in
    # https://github.com/xBZZZZ/abpy
    c = 255 << 8
    return "".join(chr(x | (0 if x < 128 else c)) for x in master_key).encode("utf8")

def getpass(prompt : str = "Passphrase: ") -> bytes:
    import termios
    with open("/dev/tty", "r+b", buffering=0) as tty:
        tty.write(b"Passphrase: ")
        old = termios.tcgetattr(tty)
        new = termios.tcgetattr(tty)
        new[3] = new[3] & ~termios.ECHO
        try:
            termios.tcsetattr(tty, termios.TCSADRAIN, new)
            data = tty.readline()
            tty.write(b"\n")
        finally:
            termios.tcsetattr(tty, termios.TCSADRAIN, old)

        if data[-2:] == b"\r\n": data = data[:-2]
        elif data[-1:] == b"\n": data = data[:-1]
        return data

def begin_input(cfg : Namespace, input_exts : _t.List[str]) -> None:
    if cfg.input_file == "-":
        cfg.basename = "backup"
        cfg.input = os.fdopen(0, "rb")
        return

    cfg.input_file = os.path.expanduser(cfg.input_file)

    root, ext = os.path.splitext(cfg.input_file)
    if ext in input_exts:
        cfg.basename = root
    else:
        cfg.basename = cfg.input_file

    try:
        cfg.input = open(cfg.input_file, "rb")
    except FileNotFoundError:
        raise CatastrophicFailure(gettext("file `%s` does not exists"), cfg.input_file)

    cfg.input_size = None
    if cfg.input.seekable():
        cfg.input_size = cfg.input.seek(0, io.SEEK_END)
        cfg.input.seek(0)

def get_passphrase(cfg_passphrase : str, cfg_passfile : str, basename : _t.Optional[str]) -> _t.Optional[bytes]:
    passphrase = None
    if cfg_passphrase is not None:
        passphrase = os.fsencode(cfg_passphrase)
    elif cfg_passfile is not None:
        try:
            with open(cfg_passfile, "rb") as f:
                passphrase = f.read()
        except FileNotFoundError:
            raise CatastrophicFailure(gettext("file `%s` does not exists"), cfg_passfile)
    elif basename:
        passfile = basename + ".passphrase.txt"
        try:
            with open(passfile, "rb") as f:
                passphrase = f.read()
        except FileNotFoundError:
            pass
    return passphrase

def begin_ab_input(cfg : Namespace, decompress : bool = True) -> None:
    begin_input(cfg, [".ab", ".adb"])

    passphrase = get_passphrase(cfg.passphrase, cfg.passfile,
                                cfg.basename if cfg.input_file != "-" else None)

    # The original backing up code: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/services/backup/java/com/android/server/backup/fullbackup/PerformAdbBackupTask.java
    def readline(what : str) -> bytes:
        data : bytes = cfg.input.readline()
        if data[-1:] == b"\n":
            data = data[:-1]
        else:
            raise CatastrophicFailure(gettext("%s: unable to parse header: %s"), cfg.input_file, what)
        return data

    def readint(what : str) -> int:
        data = readline(what)
        try:
            res = int(data)
        except Exception:
            raise CatastrophicFailure(gettext("%s: unable to parse header: %s"), cfg.input_file, what)
        return res

    def readhex(what : str) -> bytes:
        data = readline(what)
        try:
            res = bytes.fromhex(data.decode("ascii"))
        except Exception:
            raise CatastrophicFailure(gettext("%s: unable to parse header: %s"), cfg.input_file, what)
        return res

    magic = readline("magic")
    if magic != b"ANDROID BACKUP":
        raise CatastrophicFailure(gettext("%s: not an Android Backup file"), cfg.input_file)

    version = readint("version")
    if version < 1 or version > 5:
        raise CatastrophicFailure(gettext("%s: unknown Android Backup version: %s"), cfg.input_file, version)
    cfg.input_version = version

    compression = readint("compression")
    if compression not in [0, 1]:
        raise CatastrophicFailure(gettext("%s: unknown Android Backup compression: %s"), cfg.input_file, compression)
    cfg.input_compression = compression

    encryption = readline("encryption")
    cfg.input_encryption = encryption

    algo = encryption.upper()
    if algo == b"NONE":
        pass
    elif algo == b"AES-256":
        user_salt = readhex("user_salt")
        checksum_salt = readhex("checksum_salt")
        iterations = readint("iterations")
        user_iv = readhex("user_iv")
        user_blob = readhex("user_blob")

        if passphrase is None:
            passphrase = getpass()

        blob_key = androidKDF(32, user_salt, iterations, passphrase)

        decryptor = Cipher(algorithms.AES(blob_key), modes.CBC(user_iv)).decryptor()
        unpadder = PKCS7(128).unpadder()
        try:
            data = decryptor.update(user_blob) + decryptor.finalize()
            decrypted_blob = unpadder.update(data) + unpadder.finalize()
        except:
            raise CatastrophicFailure(gettext("%s: failed to decrypt, wrong passphrase?"), cfg.input_file)

        state = {"data": decrypted_blob}

        def readb(want : int) -> bytes:
            blob = state["data"]
            length = struct.unpack("B", blob[:1])[0]
            if length != want:
                raise CatastrophicFailure(gettext("%s: failed to decrypt, wrong passphrase?"), cfg.input_file)
            data = blob[1:length + 1]
            blob = blob[length + 1:]
            state["data"] = blob
            return data

        master_iv = readb(16)
        master_key = readb(32)
        checksum = readb(32)

        mangled_master_key = make_mangled_key(master_key)
        ok_checksum = cfg.ignore_checksum
        for key in [mangled_master_key, master_key]:
            our_checksum = androidKDF(32, checksum_salt, iterations, key)
            if checksum == our_checksum:
                ok_checksum = True
                break

        if not ok_checksum:
            raise CatastrophicFailure(gettext("%s: bad Android Backup checksum, wrong passphrase?"), cfg.input_file)

        decryptor = Cipher(algorithms.AES(master_key), modes.CBC(master_iv)).decryptor()
        cfg.input = ReadPreprocessor(decryptor, cfg.input, BUFFER_SIZE)

        unpadder = PKCS7(128).unpadder()
        cfg.input = ReadPreprocessor(unpadder, cfg.input, BUFFER_SIZE)
    else:
        raise CatastrophicFailure(gettext("%s: unknown Android Backup encryption: %s"), cfg.input_file, algo)

    if decompress and compression == 1:
        cfg.input = Decompressor(cfg.input, BUFFER_SIZE)

def begin_output_encryption(cfg : Namespace) -> None:
    if cfg.encrypt:
        cfg.output_passphrase_bytes = get_passphrase(cfg.output_passphrase, cfg.output_passfile, None)
        if cfg.output_passphrase_bytes is None:
            raise CatastrophicFailure(gettext("you are trying to `--encrypt` with no `--output-passphrase` or `--output-passfile` specified"))

def begin_output(cfg : Namespace, output_ext : str) -> None:
    if cfg.output_file is None:
        if cfg.input_file != "-":
            cfg.output_file = cfg.basename + output_ext
        else:
            cfg.output_file = "-"

    if cfg.output_file == "-":
        cfg.output = os.fdopen(1, "wb")
        cfg.report = False # let's not clutter the tty when inside a pipe
        return

    cfg.output_file = os.path.expanduser(cfg.output_file)
    try:
        cfg.output = open(cfg.output_file, "xb")
    except FileExistsError:
        raise CatastrophicFailure(gettext("file `%s` already exists"), cfg.output_file)

    if cfg.report:
        sys.stderr.write("Writing output to `%s`..." % (cfg.output_file,))
        sys.stderr.flush()

def begin_ab_header(cfg : Namespace, output : _t.Any, output_version : int) -> _t.Any:
    output_compression = 1 if cfg.compress else 0
    output_encryption = b"AES-256" if cfg.encrypt else b"none"
    output.write(b"ANDROID BACKUP\n%d\n%d\n%s\n" % (output_version, output_compression, output_encryption))
    if cfg.encrypt:
        user_salt = secrets.token_bytes(cfg.salt_bytes)
        checksum_salt = secrets.token_bytes(cfg.salt_bytes)
        iterations = cfg.iterations
        user_iv = secrets.token_bytes(16)

        master_iv = secrets.token_bytes(16)
        master_key = secrets.token_bytes(32)

        key = make_mangled_key(master_key)
        checksum = androidKDF(32, checksum_salt, iterations, key)

        plain_blob = \
            struct.pack("B", 16) + master_iv + \
            struct.pack("B", 32) + master_key + \
            struct.pack("B", 32) + checksum

        blob_key = androidKDF(32, user_salt, iterations, cfg.output_passphrase_bytes)
        encryptor = Cipher(algorithms.AES(blob_key), modes.CBC(user_iv)).encryptor()
        padder = PKCS7(128).padder()

        padded_blob = padder.update(plain_blob) + padder.finalize()
        user_blob = encryptor.update(padded_blob) + encryptor.finalize()

        enc_header = \
            user_salt.hex().upper() + "\n" + \
            checksum_salt.hex().upper() + "\n" + \
            str(iterations) + "\n" + \
            user_iv.hex().upper() + "\n" + \
            user_blob.hex().upper() + "\n"

        output.write(enc_header.encode("ascii"))

        encryptor = Cipher(algorithms.AES(master_key), modes.CBC(master_iv)).encryptor()
        output = WritePreprocessor(encryptor, output)

        padder = PKCS7(128).padder()
        output = WritePreprocessor(padder, output)
    if cfg.compress:
        output = Compressor(output)
    return output

def begin_ab_output(cfg : Namespace, output_ext : str, output_version : int) -> None:
    begin_output_encryption(cfg)
    begin_output(cfg, output_ext)
    cfg.output = begin_ab_header(cfg, cfg.output, output_version)

prev_percent = None
def report_progress(cfg : Namespace) -> None:
    if not cfg.report: return

    global prev_percent
    percent = 100 * cfg.input.tell() / cfg.input_size
    if prev_percent == percent: return
    prev_percent = percent

    sys.stderr.write("\r\033[KWriting output to `%s`... %d%%" % (cfg.output_file, percent))
    sys.stderr.flush()

def copy_input_to_output(cfg : Namespace, report : bool = True) -> None:
    while True:
        data = cfg.input.read(BUFFER_SIZE)
        if data == b"": break
        cfg.output.write(data)
        if report:
            report_progress(cfg)

def finish_input(cfg : Namespace) -> None:
    cfg.input.close()

def finish_output(cfg : Namespace) -> None:
    cfg.output.flush()
    cfg.output.close()

    if cfg.report:
        sys.stderr.write("\r\033[K")
        sys.stderr.flush()

def str_ftype(ftype : bytes) -> str:
    if ftype == b"\x00" or ftype == b"0":
        return "-"
    elif ftype == b"1":
        return "h"
    elif ftype == b"2":
        return "l"
    elif ftype == b"3":
        return "c"
    elif ftype == b"4":
        return "b"
    elif ftype == b"5":
        return "d"
    elif ftype == b"6":
        return "f"
    else:
        raise CatastrophicFailure(gettext("unknown TAR header file type: %s"), repr(ftype))

def str_modes(mode : int) -> str:
    mode_ = oct(mode)[2:]
    if len(mode_) > 3:
        mode_ = mode_[-3:]
    mode = int(mode_, 8)

    res = ""
    rwx = ["r", "w", "x"]
    n = 0
    for b in bin(mode)[2:]:
        if b == "0":
            res += "-"
        else:
            res += rwx[n % 3]
        n += 1
    return res

def str_uidgid(uid : int, gid : int, uname : str, gname : str) -> str:
    res = ""
    if uname != "":
        res += uname
    else:
        res += str(uid)
    res += "/"
    if gname != "":
        res += gname
    else:
        res += str(gid)

    return res.ljust(12)

def str_size(x : int) -> str:
    return str(x).rjust(8)

def str_mtime(x : int) -> str:
    return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(x))

def ab_ls(cfg : Namespace) -> None:
    begin_ab_input(cfg)
    print("# Android Backup, version: %d, compression: %d, encryption: %s" % (cfg.input_version, cfg.input_compression, cfg.input_encryption.decode("ascii", "ignore")))
    for h in tariter.iter_tar_headers(cfg.input):
        print(str_ftype(h.ftype) + str_modes(h.mode),
              str_uidgid(h.uid, h.gid, h.uname, h.gname),
              str_size(h.size), str_mtime(h.mtime), h.path)
    finish_input(cfg)

def ab_strip(cfg : Namespace) -> None:
    if cfg.keep_compression:
        begin_ab_input(cfg, False)
        begin_output(cfg, ".stripped.ab")
        cfg.output.write(b"ANDROID BACKUP\n%d\n%d\nnone\n" % (cfg.input_version, cfg.input_compression))
        copy_input_to_output(cfg)
    else:
        begin_ab_input(cfg)
        begin_ab_output(cfg, ".stripped.ab", cfg.input_version)
        copy_input_to_output(cfg)
    finish_input(cfg)
    finish_output(cfg)

def write_tar(pax_header : _t.Optional[bytes], h : tariter.TarHeader, input : _t.Any, output : _t.Any) -> None:
    if pax_header is not None:
        output.write(pax_header)

    output.write(h.raw)
    fsize = h.size + h.leftovers
    while fsize > 0:
        data = input.read(min(fsize, BUFFER_SIZE))
        if len(data) == 0:
            raise tariter.ParsingError("unexpected EOF")
        fsize -= len(data)
        output.write(data)

def finish_tar(output : _t.Any) -> None:
    output.write(b"\0" * 1024)
    output.flush()
    output.close()

def ab_split(cfg : Namespace) -> None:
    begin_output_encryption(cfg)
    begin_ab_input(cfg)

    if cfg.prefix is None:
        dirname = os.path.dirname(cfg.basename)
        basename = os.path.basename(cfg.basename)
        cfg.prefix = os.path.join(dirname, "hoardy_adb_split_" + basename)

    print("# Android Backup, version: %d, compression: %d" % (cfg.input_version, cfg.input_compression))

    output : _t.Optional[_t.Any] = None
    fname : _t.Optional[str] = None
    app : _t.Optional[str] = None
    appnum = 0

    global_pax_header : _t.Optional[bytes] = None
    pax_header : _t.Optional[bytes] = None

    for h in tariter.yield_tar_headers(cfg.input):
        ftype = h.ftype
        if ftype == b"g":
            global_pax_header = h.raw
            pax_header = None
            continue
        elif ftype == b"x":
            pax_header = h.raw
            continue

        happ = "other"
        spath = h.path.split("/")
        if len(spath) > 2 and spath[0] == "apps":
            happ = spath[1]

        if app is None or happ != app:
            if output is not None:
                # finish the previous one
                finish_tar(output)
                appnum += 1

            app = happ
            fname = "%s_%03d_%s.ab" % (cfg.prefix, appnum, app)
            try:
                output = open(fname, "xb")
            except FileExistsError:
                raise CatastrophicFailure(gettext("file `%s` already exists"), fname)

            if cfg.report:
                sys.stderr.write("Writing `%s`...\n" % (fname,))
                sys.stderr.flush()

            output = begin_ab_header(cfg, output, cfg.input_version)
            if global_pax_header is not None:
                output.write(global_pax_header)

        write_tar(pax_header, h, cfg.input, output)
        pax_header = None

    if output is not None:
        # finish last
        finish_tar(output)

    finish_input(cfg)

def ab_merge(cfg : Namespace) -> None:
    cfg.output = None
    input_version = 0
    for input_file in cfg.input_files:
        cfg.input_file = input_file
        begin_ab_input(cfg)
        if cfg.output is None:
            input_version = cfg.input_version
            begin_ab_output(cfg, ".merged.ab", input_version)
        elif cfg.input_version != input_version:
            raise CatastrophicFailure(gettext("can't merge files with different Android Backup versions: `%s` is has version %d, but we are merging into version %d"), cfg.input_file, cfg.input_version, input_version)

        if cfg.report:
            sys.stderr.write("Merging `%s`...\n" % (input_file,))
            sys.stderr.flush()

        for h in tariter.yield_tar_headers(cfg.input):
            write_tar(None, h, cfg.input, cfg.output)

        finish_input(cfg)
    finish_tar(cfg.output)

def ab_unwrap(cfg : Namespace) -> None:
    begin_ab_input(cfg)
    begin_output(cfg, ".tar")
    copy_input_to_output(cfg)
    finish_input(cfg)
    finish_output(cfg)

def ab_wrap(cfg : Namespace) -> None:
    begin_input(cfg, [".tar"])
    begin_ab_output(cfg, ".ab", cfg.output_version)
    copy_input_to_output(cfg)
    finish_input(cfg)
    finish_output(cfg)

def add_examples(fmt : _t.Any) -> None:
    fmt.add_text("# Usage notes")

    fmt.add_text('Giving an encrypted `INPUT_AB_FILE` as input, not specifying `--passphrase` or `--passfile`, and not having a file named `{INPUT_AB_FILE with ".ab" or ".adb" extension replaced with ".passphrase.txt"}` in the same directory will cause the passphrase to be read interactively from the tty.')

    fmt.add_text("# Examples")

    fmt.start_section("List contents of an Android Backup file")
    fmt.add_code(f"{__prog__} ls backup.ab")
    fmt.end_section()

    fmt.start_section(f"Use `tar` util to list contents of an Android Backup file instead of running `{__prog__} ls`")
    fmt.add_code(f"{__prog__} unwrap backup.ab - | tar -tvf -")
    fmt.end_section()

    fmt.start_section("Extract contents of an Android Backup file")
    fmt.add_code(f"{__prog__} unwrap backup.ab - | tar -xvf -")
    fmt.end_section()

    fmt.start_section("Strip encryption and compression from an Android Backup file")
    fmt.add_code(f"""# equivalent
{__prog__} strip backup.ab backup.stripped.ab
{__prog__} strip backup.ab
""")
    fmt.add_code(f"""# equivalent
{__prog__} strip --passphrase secret backup.ab
{__prog__} strip -p secret backup.ab
""")
    fmt.add_code(f"""# with passphrase taken from a file
echo -n secret > backup.passphrase.txt
# equivalent
{__prog__} strip backup.ab
{__prog__} strip --passfile backup.passphrase.txt backup.ab
""")
    fmt.add_code(f"""# with a weird passphrase taken from a file
echo -ne "secret\\r\\n\\x00another line" > backup.passphrase.txt
{__prog__} strip backup.ab
""")
    fmt.end_section()

    fmt.start_section("Strip encryption but keep compression, if any")
    fmt.add_code(f"""# equivalent
{__prog__} strip --keep-compression backup.ab backup.stripped.ab
{__prog__} strip -k backup.ab
""")
    fmt.end_section()

    fmt.start_section("Strip encryption and compression from an Android Backup file and then re-compress using `xz`")
    fmt.add_code(f"""{__prog__} strip backup.ab - | xz --compress -9 - > backup.ab.xz
# ... and then convert to tar and list contents:
xzcat backup.ab.xz | {__prog__} unwrap - | tar -tvf -
""")
    fmt.end_section()

    fmt.start_section("Convert an Android Backup file into a TAR archive")
    fmt.add_code(f"""# equivalent
{__prog__} unwrap backup.ab backup.tar
{__prog__} unwrap backup.ab
""")
    fmt.end_section()

    fmt.start_section("Convert a TAR archive into an Android Backup file")
    fmt.add_code(f"""# equivalent
{__prog__} wrap --output-version=5 backup.tar backup.ab
{__prog__} wrap --output-version=5 backup.tar
""")
    fmt.end_section()

def make_argparser(real : bool = True) -> _t.Any:
    _ = gettext

    parser = argparse.BetterArgumentParser(
        prog=__prog__,
        description = _("""A handy Swiss-army-knife-like utility for manipulating Android Backup files (`backup.ab`, `*.ab`, `*.adb`) produced by `adb backup`, `bmgr`, and similar tools.

Android Backup file consists of a metadata header followed by a PAX-formatted TAR file (optionally) compressed with zlib (the only compressing Android Backup file format supports) and then (optionally) encrypted with AES-256 (the only encryption Android Backup file format supports).
""") + ("" if real else _("""
Below, all input decryption options apply to all subcommands taking Android Backup files as input(s) and all output encryption options apply to all subcommands producing Android Backup files as output(s).""")),
        additional_sections = [add_examples],
        allow_abbrev = False,
        add_help = False,
        add_version = True)
    parser.add_argument("-h", "--help", action="store_true", help=_("show this help message and exit"))
    parser.add_argument("--markdown", action="store_true", help=_("show help messages formatted in Markdown"))
    parser.set_defaults(func=None)

    def no_cmd(cfg : Namespace) -> None:
        parser.print_help(sys.stderr)
        parser.error(_("no subcommand specified"))
    parser.set_defaults(func=no_cmd)

    def add_pass(cmd : _t.Any) -> None:
        agrp = cmd.add_argument_group(_("input decryption passphrase"))
        grp = agrp.add_mutually_exclusive_group()
        grp.add_argument("-p", "--passphrase", type=str, help=_("passphrase for an encrypted `INPUT_AB_FILE`"))
        grp.add_argument("--passfile", type=str, help=_('a file containing the passphrase for an encrypted `INPUT_AB_FILE`; similar to `-p` option but the whole contents of the file will be used verbatim, allowing you to, e.g. use new line symbols or strange character encodings in there; default: guess based on `INPUT_AB_FILE` trying to replace ".ab" or ".adb" extension with ".passphrase.txt"'))

        agrp = cmd.add_argument_group(_("input decryption checksum verification"))
        agrp.add_argument("--ignore-checksum", action="store_true", help=_("ignore checksum field in `INPUT_AB_FILE`, useful when decrypting backups produced by weird Android firmwares"))

    def add_encpass(cmd : _t.Any) -> None:
        agrp = cmd.add_argument_group(_("output encryption passphrase"))
        grp = agrp.add_mutually_exclusive_group()
        grp.add_argument("--output-passphrase", type=str, help=_("passphrase for an encrypted `OUTPUT_AB_FILE`"))
        grp.add_argument("--output-passfile", type=str, help=_("a file containing the passphrase for an encrypted `OUTPUT_AB_FILE`"))

        agrp = cmd.add_argument_group(_("output encryption parameters"))
        agrp.add_argument("--output-salt-bytes", dest="salt_bytes", default=64, type=int, help=_("PBKDF2HMAC salt length in bytes; default: %(default)s"))
        agrp.add_argument("--output-iterations", dest="iterations", default=10000, type=int, help=_("PBKDF2HMAC iterations; default: %(default)s"))

    if not real:
        add_pass(parser)
        add_encpass(parser)

    subparsers = parser.add_subparsers(title="subcommands")

    def add_input(cmd : _t.Any) -> None:
        cmd.add_argument("input_file", metavar="INPUT_AB_FILE", type=str, help=_('an Android Backup file to be used as input, set to "-" to use standard input'))

    def add_output(cmd : _t.Any, extension : str) -> None:
        cmd.add_argument("output_file", metavar="OUTPUT_AB_FILE", nargs="?", default=None, type=str, help=_('file to write the output to, set to "-" to use standard output; default: "-" if `INPUT_TAR_FILE` is "-", otherwise replace ".ab" or ".adb" extension of `INPUT_TAR_FILE` with `%s`' % (extension,)))

    cmd = subparsers.add_parser("ls", aliases = ["list"],
                                help=_("list contents of an Android Backup file"),
                                description=_("List contents of an Android Backup file similar to how `tar -tvf` would do, but this will also show Android Backup file version, compression, and encryption parameters."))
    if real:
        add_pass(cmd)
    add_input(cmd)
    cmd.set_defaults(func=ab_ls)

    cmd = subparsers.add_parser("rewrap", aliases = ["strip", "ab2ab"],
                                help=_("strip or apply encyption and/or compression from/to an Android Backup file"),
                                description=_("""Convert a given Android Backup file into another Android Backup file with encyption and/or compression applied or stripped away.

Versioning parameters and the TAR file stored inside the input file are copied into the output file verbatim.

For instance, with this subcommand you can convert an encrypted and compressed Android Backup file into a simple unencrypted and uncompressed version of the same, or vice versa.
The former of which is useful if your Android firmware forces you to encrypt your backups but you store your backups on an encrypted media anyway and don't want to remember more passphrases than strictly necessary.
Or if you want to strip encryption and compression and re-compress using something better than zlib."""))
    if real:
        add_pass(cmd)
        add_encpass(cmd)
    grp = cmd.add_mutually_exclusive_group()
    grp.add_argument("-d", "--decompress", action="store_true", help=_("produce decompressed output; this is the default"))
    grp.add_argument("-k", "--keep-compression", action="store_true", help=_("copy compression flag and data from input to output verbatim; this will make the output into a compressed Android Backup file if the input Android Backup file is compressed; this is the fastest way to `strip`, since it just copies bytes around"))
    grp.add_argument("-c", "--compress", action="store_true", help=_(f"(re-)compress the output file; it will use higher compression level defaults than those used by Android; with this option enabled `{__prog__}` will be quite slow"))
    cmd.add_argument("-e", "--encrypt", action="store_true", help=_("(re-)encrypt the output file; on a modern CPU (with AES-NI) enabling this option costs almost nothing, on an old CPU it will be quite slow"))

    add_input(cmd)
    add_output(cmd, ".stripped.ab")
    cmd.set_defaults(func=ab_strip)

    cmd = subparsers.add_parser("split", aliases = ["ab2many"],
                                help=_("split a full-system Android Backup file into a bunch of per-app Android Backup files"),
                                description=_("""Split a full-system Android Backup file into a bunch of per-app Android Backup files.

Resulting per-app files can be given to `adb restore` to restore selected apps.

Also, if you do backups regularly, then splitting large Android Backup files like this and deduplicating per-app files between backups could save a lot of disk space.
"""))
    if real:
        add_pass(cmd)
        add_encpass(cmd)
    cmd.add_argument("-c", "--compress", action="store_true", help=_("compress per-app output files"))
    cmd.add_argument("-e", "--encrypt", action="store_true", help=_("encrypt per-app output files; when enabled, the `--output-passphrase`/`--output-passfile` and other `output encryption parameters` will be reused for all the generated files, but all encryption keys and salts will be unique"))
    cmd.add_argument("--prefix", type=str, help=_('file name prefix for output files; default: `hoardy_adb_split_backup` if `INPUT_AB_FILE` is "-", `hoardy_adb_split_<INPUT_AB_FILE without its ".ab" or ".adb" extension>` otherwise'))
    add_input(cmd)
    cmd.set_defaults(func=ab_split)


    cmd = subparsers.add_parser("merge", aliases = ["many2ab"],
                                help=_("merge a bunch of Android Backup files into one"),
                                description=_("""Merge many smaller Android Backup files into a single larger one.
A reverse operation to `split`.

This exists mostly for checking that `split` is not buggy.
"""))
    if real:
        add_pass(cmd)
        add_encpass(cmd)
    cmd.add_argument("-c", "--compress", action="store_true", help=_("compress the output file"))
    cmd.add_argument("-e", "--encrypt", action="store_true", help=_("encrypt the output file"))
    cmd.add_argument("input_files", metavar="INPUT_AB_FILE", nargs="+", type=str, help=_('Android Backup files to be used as inputs'))
    cmd.add_argument("output_file", metavar="OUTPUT_AB_FILE", type=str, help=_('file to write the output to'))
    cmd.set_defaults(func=ab_merge)

    cmd = subparsers.add_parser("unwrap", aliases = ["ab2tar"],
                                help=_("convert an Android Backup file into a TAR file"),
                                description=_("""Convert Android Backup file into a TAR file by stripping Android Backup header, decrypting and decompressing as necessary.

The TAR file stored inside the input file gets copied into the output file verbatim."""))
    if real: add_pass(cmd)
    add_input(cmd)
    cmd.add_argument("output_file", metavar="OUTPUT_TAR_FILE", nargs="?", default=None, type=str, help=_('file to write output to, set to "-" to use standard output; default: guess based on `INPUT_AB_FILE` while setting extension to `.tar`'))
    cmd.set_defaults(func=ab_unwrap)

    cmd = subparsers.add_parser("wrap", aliases = ["tar2ab"],
                                help=_("convert a TAR file into an Android Backup file"),
                                description=_(f"""Convert a TAR file into an Android Backup file by prepending Android Backup header, compressing and encrypting as requested.

The input TAR file gets copied into the output file verbatim.

Note that unwrapping a `.ab` file, unpacking the resulting `.tar`, editing the resulting files, packing them back with GNU `tar` utility, running `{__prog__} wrap`, and then running `adb restore` on the resulting file will probably crash your Android device (phone or whatever) because the Android-side code restoring from the backup expects the data in the packed TAR to be in a certain order and have certain PAX headers, which GNU `tar` will not produce.

So you should only use this on files previously produced by `{__prog__} unwrap` or if you know what it is you are doing.
"""))
    if real:
        add_encpass(cmd)
    cmd.add_argument("-c", "--compress", action="store_true", help=_("compress the output file"))
    cmd.add_argument("-e", "--encrypt", action="store_true", help=_("encrypt the output file"))
    cmd.add_argument("--output-version", type=int, required=True, help=_("Android Backup file version to use; required"))
    cmd.add_argument("input_file", metavar="INPUT_TAR_FILE", type=str, help=_('a TAR file to be used as input, set to "-" to use standard input'))
    add_output(cmd, ".ab")
    cmd.set_defaults(func=ab_wrap)

    return parser

def main() -> None:
    parser = make_argparser()
    cfg = parser.parse_args(sys.argv[1:])

    if cfg.help:
        if cfg.markdown:
            parser = make_argparser(False)
            parser.set_formatter_class(argparse.MarkdownBetterHelpFormatter)
            print(parser.format_help(1024))
        else:
            print(parser.format_help())
        sys.exit(0)

    if sys.stderr.isatty():
        cfg.report = True
    else:
        cfg.report = False

    try:
        cfg.func(cfg)
    except KeyboardInterrupt:
        print("Interrupted.", file=sys.stderr)
        sys.exit(1)
    except CatastrophicFailure as exc:
        print(str(exc), file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()