#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-only
#
# lsinitcpio - dump the contents of an initramfs image
#

shopt -s extglob globstar

_list='--list'
_optcolor=1
_f_functions=functions

declare -A bsdcpio_options=(
    [list]='--list'
    [input]='-i'
    [quiet]='--quiet'
)

usage() {
    cat <<USAGE
lsinitcpio %VERSION%
usage: ${0##*/} [action] [options] <initramfs>

  Actions:
   -a, --analyze        analyze contents of image
   -c, --config         show configuration file image was built with
   -l, --list           list contents of the image (default)
   -x, --extract        extract image to disk

  Options:
   -h, --help           display this help
   -n, --nocolor        disable colorized output
   -V, --version        display version information
   -v, --verbose        more verbose output
       --cpio           list or extract only the main CPIO image
       --early          list or extract only the early CPIO image

USAGE
}

version() {
    cat <<EOF
lsinitcpio %VERSION%
EOF
}

decomp() {
    local image="$1" offset="$2"
    if (( offset > 0 )); then
        dd if="$image" skip=$((offset / 512)) 2>/dev/null | ${_compress:-cat} ${_compress:+-cd}
    else
        ${_compress:-cat} ${_compress:+-cd} "$image"
    fi
}

# shellcheck source=functions
. "$_f_functions"

# override the die method from functions
die() {
    error "$@"
    exit 1
}

size_to_human() {
    awk -v size="$1" '
    BEGIN {
        suffix[1] = "B"
        suffix[2] = "KiB"
        suffix[3] = "MiB"
        suffix[4] = "GiB"
        suffix[5] = "TiB"
        count = 1

        while (size > 1024) {
            size /= 1024
            count++
        }

        sizestr = sprintf("%.2f", size)
        sub(/\.?0+$/, "", sizestr)
        printf("%s %s", sizestr, suffix[count])
    }'
}

detect_filetype() {
    local file="$1" offset="$2" bytes

    bytes="$(od -An -t x1 -N6 -j "$offset" "$file" | tr -dc '[:alnum:]')"

    case $bytes in
        '303730373031')
            # no compression
            echo
            return
            ;;
    esac

    compression="$(detect_compression "$file" "$offset")"

    if [[ -n "$compression" ]]; then
        echo "$compression"
        return
    fi

    # still nothing? hrmm, maybe the user goofed and it's a kernel
    if kver "$1" >/dev/null; then
        die "'%s' is a kernel image, not an initramfs image!" "$file"
    fi

    # out of ideas, we're done here.
    die "Unexpected file type. Are you sure '%s' is an initramfs?" "$file"
}

detect_early_img() {
    local early=0
    # early cpio archives can have a file called early_cpio in them
    early="$(LC_ALL=C.UTF-8 bsdtar -xOf "$1" early_cpio 2>/dev/null)"
    if [[ -z "$early" ]]; then
        # but not all do
        if LC_ALL=C.UTF-8 bsdtar -tf "$1" 'kernel/*/microcode/*.bin' &>/dev/null; then
            early=1
        fi
    fi
    printf '%d' "$early"
}

skip_early_img() {
    local -i offset
    local bytes

    # find last entry of the early cpio archive, marked by an entry with the name TRAILER!!!
    offset="$(grep -Fabom1 'TRAILER!!!' "$1" | cut -d: -f1)" || return 1

    # skip past the TRAILER!!! (10 chars) and to the next multiple of 512 bytes
    # we can do this in blocks of 512 because bsdtar/bsdcpio use blocksizes that are a multiple of 512
    offset="$(awk -v offset="$offset" '
    function ceil(v) {
        return (v == int(v)) ? v : int(v)+1
    }
    BEGIN {
        offset += 10
        printf "%d", ceil(offset / 512) * 512
    }')"


    # read a byte, check if it's NUL
    # if it's not, we've made it through the NULs
    # otherwise, skip ahead to the start of the next block and repeat
    bytes="$(od -An -t x1 -N1 -j "$offset" "$1" | tr -dc '[:alnum:]')" || return 1
    until [[ "$bytes" != '00' ]]; do
        offset=$(( offset + 512 ))
        bytes="$(od -An -t x1 -N1 -j "$offset" "$1" | tr -dc '[:alnum:]')" || return 1
    done
    printf '%d' "$offset"
}

analyze_image() {
    local -a binaries explicitmod modules foundhooks hooks imagename
    local kernver ratio columns image="$1" offset="$2"
    columns="$(tput cols)"

    workdir="$(mktemp -d --tmpdir lsinitcpio.XXXXXX)"
    trap 'rm -rf "$workdir"' EXIT

    # fallback in case tput failed us
    columns="${columns:-80}"

    # instead of reading the size from the inode, insist on reading the entire
    # image to ensure that it's in the cache when we decompress. This avoids
    # variance in timing and makes the time spent reading from storage roughly
    # constant.
    zsize="$(dd if="$image" skip=$((offset / 512)) 2>/dev/null | wc -c)"

    # calculate compression ratio
    if [[ -n "$_compress" ]]; then
        decomptime="$(TIMEFORMAT=%R; { time decomp "$image" "$offset" >/dev/null; } 2>&1)"
        fullsize="$(decomp "$image" "$offset" | wc -c)"
        ratio=".$(( zsize * 1000 / fullsize % 1000 ))"
    fi

    # decompress the image since we need to read from it multiple times.
    decomp "$image" "$offset" | bsdtar -C "$workdir" -xf - || die 'Failed to decompress image'

    modules=("$workdir/usr/lib/modules"/*/kernel/**/*.ko*)
    if [[ -f "${modules[0]}" ]]; then
        IFS=/ read -r _ _ _ kernver _ <<<"${modules[0]#$workdir/}"
        modules=("${modules[@]##*/}")
        modules=("${modules[@]%.ko*}")
    else
        unset modules
    fi

    foundhooks=("$workdir"/hooks/*)
    if [[ -f "${foundhooks[0]}" ]]; then
        foundhooks=("${foundhooks[@]##*/}")
    else
        unset foundhooks
    fi

    mapfile -t binaries < <(LC_ALL=C.UTF-8 find "$workdir/usr/bin" -type f -printf %f\\n)

    read -r version <"$workdir/VERSION"

    # shellcheck disable=SC1090,SC1091
    . "$workdir/config"

    explicitmod=("${MODULES[@]}")

    # print results
    imagename=("$image")
    [[ -L "$image" ]] && imagename+=(" -> $(readlink -e "$image")")
    msg 'Image: %s%s' "${imagename[@]}"
    [[ -n "$version" ]] && msg 'Created with mkinitcpio %s' "$version"
    msg 'Kernel: %s' "${kernver:-unknown}"
    if (( offset > 0 )); then
        # the zero-indexed offset of the main image is the same as the size of the early image
        msg 'Early CPIO: %s' "$(size_to_human "$offset")"
    else
        msg 'Early CPIO: none'
    fi
    msg 'Size: %s' "$(size_to_human "$zsize")"

    if [[ -n "$_compress" ]]; then
        msg 'Compressed with: %s' "$_compress"
        msg2 'Uncompressed size: %s (%s ratio)' "$(size_to_human "$fullsize")" "$ratio"
        msg2 'Estimated decompression time: %ss' "$decomptime"
    fi
    printf '\n'

    if (( ${#modules[*]} )); then
        msg 'Included modules:'
        for mod in "${modules[@]}"; do
            printf '  %s' "$mod"
            in_array "${mod//_/-}" "${explicitmod[@]//_/-}" && printf ' [explicit]'
            printf '\n'
        done | LC_ALL=C.UTF-8 sort | column -c"${columns}"
        printf '\n'
    fi

    msg 'Included binaries:'
    printf '  %s\n' "${binaries[@]}" | LC_ALL=C.UTF-8 sort | column -c"${columns}"
    printf '\n'

    if [[ -n "$EARLYHOOKS" ]]; then
        msg 'Early hook run order:'
        printf '  %s\n' "$EARLYHOOKS"
        printf '\n'
    fi

    # shellcheck disable=SC2128
    if [[ -n "$HOOKS" ]]; then
        msg 'Hook run order:'
        printf '  %s\n' "$HOOKS"
        printf '\n'
    fi

    if [[ -n "$LATEHOOKS" ]]; then
        msg 'Late hook run order:'
        printf '  %s\n' "$LATEHOOKS"
        printf '\n'
    fi

    if [[ -n "$CLEANUPHOOKS" ]]; then
        msg 'Cleanup hook run order:'
        printf '  %s\n' "$CLEANUPHOOKS"
        printf '\n'
    fi

    if [[ -n "$EMERGENCYHOOKS" ]]; then
        msg 'Emergency hook run order:'
        printf '  %s\n' "$EMERGENCYHOOKS"
        printf '\n'
    fi
}

_opt_short='achlnVvx'
_opt_long=('analyze' 'config' 'cpio' 'early' 'help' 'list' 'nocolor' 'version' 'verbose' 'extract')

parseopts "$_opt_short" "${_opt_long[@]}" -- "$@" || exit
set -- "${OPTRET[@]}"
unset _opt_short _opt_long OPTRET

while :; do
    case $1 in
        -a | --analyze)
            _optanalyze=1
            ;;
        -c | --config)
            _optshowconfig=1
            ;;
        --cpio)
            _optcpio=1
            ;;
        --early)
            _optearly=1
            ;;
        -h | --help)
            usage
            exit 0
            ;;
        -l | --list)
            _optlistcontents=1
            ;;
        -n | --nocolor)
            _optcolor=0
            ;;
        -V | --version)
            version
            exit 0
            ;;
        -v | --verbose)
            bsdcpio_options['verbose']='--verbose'
            ;;
        -x | --extract)
            unset 'bsdcpio_options[list]'
            ;;
        --)
            shift
            break 2
            ;;
    esac
    shift
done

_image="$1"

if [[ -t 1 ]] && (( _optcolor )); then
    try_enable_color
fi

[[ -n "$_image" ]] || die 'No image specified (use -h for help)'
[[ -f "$_image" ]] || die "No such file: '%s'" "$_image"
[[ -r "$_image" ]] || die "Unable to read file: '%s'" "$_image"

case $(( _optanalyze + _optlistcontents + _optshowconfig )) in
    0)
        # default action when none specified
        _optlistcontents=1
        ;;
    [!1])
        die "Only one action may be specified at a time"
        ;;
esac

case $(( _optearly + _optcpio )) in
    0)
        # default to listing/extracting both images
        _optearly=1
        _optcpio=1
        ;;
    # if == 1, only early or main cpio will be extracted/listed
    # if >1, same as 0
esac

if (( $(detect_early_img "$_image") )); then
    _offset="$(skip_early_img "$_image")"
else
    _offset=0
    _optearly=0
fi

# read compression type of the cpio image
_compress="$(detect_filetype "$_image" "$_offset")" || exit

if (( _optanalyze )); then
    analyze_image "$_image" "$_offset"
elif (( _optshowconfig )); then
    decomp "$_image" "$_offset" | bsdtar xOf - buildconfig 2>/dev/null ||
        die 'Failed to extract config from image (mkinitcpio too old?)'
else
    if (( _optearly )); then
        bsdcpio "${bsdcpio_options[@]}" <"$_image"
    fi
    if (( _optcpio )); then
        decomp "$_image" "$_offset" | bsdcpio "${bsdcpio_options[@]}"
    fi
fi

# vim: set ft=sh ts=4 sw=4 et:
