#!/usr/bin/env bash # shellcheck shell=bash set -Eeuo pipefail : <<'DOC' =============================================================================== This script is a forensic inspection tool for ext2/3/4 partition images that have been rescued with ddrescue or similar methods. It allows the user to map and inspect specific regions of an image by either disk sector (LBA) or ext filesystem block number. =============================================================================== This script is not POSIX compliant and uses bash-specific features =============================================================================== I coached ChatGPT to generate this script based on my requirements and style guidance. The development was iterative, with one unit of functionality added at a time, followed by the addition of guardrails, getopts, usage, shellcheck and polish. =============================================================================== Notes on POSIX vs. Bash-Specific Features ------------------------------------------------------------------------------- This script currently requires Bash and GNU userland tools for full functionality. The following elements are not POSIX, grouped by type: Bash language features: - set -o pipefail and set -E (not POSIX, Bash/Ksh extensions) - Arrays and array expansion: VIEW=(hexdump -C), "${VIEW[@]}" - [[ ... ]] test syntax and =~ regex - Arithmetic evaluation: (( ... )) - Process substitution: tee >( zeros_or_not ... ) - printf '%q' (shell escaping, Bash extension) External utilities and flags (GNU/BSD specific): - stat (GNU: stat -Lc %s, BSD: stat -f%z); not POSIX - dd flags: iflag=skip_bytes,count_bytes, status=none (GNU only) - cmp -n N (limit compare to N bytes, GNU extension) - hexdump -C (canonical output, not POSIX mandated) - od -Ax (BSD/GNU form; POSIX form is od -A x -t x1 -v) - dumpe2fs (and future debugfs) are ext2/3/4 tools, not POSIX Likely POSIX-OK: - set -e and set -u are POSIX compliant - awk, sed, grep, printf with used options are POSIX - $(( ... )) arithmetic expansion is POSIX - here-docs (including quoted delimiters) are POSIX - command -v is POSIX In short: - The script is Bash-only today. - Porting to strict /bin/sh would require replacing arrays, [[ ... ]], (( ... )), process substitution, and GNU-specific dd/stat. =============================================================================== DOC # ===== usage & small helpers ===== usage() { cat >&2 <<'EOF' Usage: ext_block_inspector.sh -i --fs-block= [--fs-block-size=] [--sector-bytes=] ext_block_inspector.sh -i --lba= --part-start= [--sector-bytes=] Options: -i, --image= Path to partition image (required) --fs-block= Filesystem block index (0-based; 1:1 with image; ddrescuelog -b ) --lba= Disk LBA (512-byte sectors by default) --part-start= Partition start LBA on original disk (required with --lba) --sector-bytes= LBA sector size. Default: 512 --fs-block-size= filesystem block size override. Default reads from image. -h, --help Show this help Note: long options must be used with '=' (e.g. --fs-block=123), not space separation. Note: current version supports ext2/3/4 filesystems but could be expanded to support others. EOF exit 2 } die() { printf '%s\n' "$*" >&2; exit 1; } warn() { printf 'Warning: %s\n' "$*" >&2; } note() { printf '%s\n' "$*" >&2; } is_uint() { [[ "${1-}" =~ ^[0-9]+$ ]]; } # ===== deps & viewer ===== ensure_deps() { local need=(dd awk grep printf dumpe2fs cmp tee stat stdbuf) local ok=1 for bin in "${need[@]}"; do command -v "$bin" >/dev/null 2>&1 || { printf 'Missing dependency: %s\n' "$bin" >&2; ok=0; } done if ! command -v hexdump >/dev/null 2>&1; then command -v od >/dev/null 2>&1 || { printf 'Missing dependency: hexdump or od\n' >&2; ok=0; } fi (( ok == 1 )) || exit 127 } # Note: uses a small array; keep Bash shebang. set_viewer() { if command -v hexdump >/dev/null 2>&1; then VIEW=(hexdump -C) else VIEW=(od -Ax -tx1 -v) fi } print_view_cmd() { # Show exactly what will execute (shell-safe quoting) printf '[cmd] %s\n' "$(printf '%q ' "${VIEW[@]}")" >&2 } # ===== image introspection (globals) ===== IMG="" IMG_SIZE=0 SECTOR_BYTES="512" FS_BS="" # optional override; auto-detected otherwise FS_BS_ACTUAL="" FIRST_BLOCK="0" BLKCNT=0 PER_BLK=0 # sectors per filesystem block init_image() { local img=${1:?image path required} [[ -r "$img" ]] || die "Image not readable: $img" # Ensure stable parsing from dumpe2fs output export LC_ALL=C # image size (GNU/BSD stat) if IMG_SIZE=$(stat -Lc %s "$img" 2>/dev/null); then :; else IMG_SIZE=$(stat -f%z "$img"); fi FS_BS_ACTUAL=$(dumpe2fs -h "$img" 2>/dev/null | awk -F': *' '/Block size:/ {gsub(/[^0-9]/,"",$2); print $2}') FIRST_BLOCK=$(dumpe2fs -h "$img" 2>/dev/null | awk -F': *' '/First block:/ {gsub(/[^0-9]/,"",$2); print $2}') BLKCNT=$(dumpe2fs -h "$img" 2>/dev/null | awk -F': *' '/Block count:/ {gsub(/[^0-9]/,"",$2); print $2}') [[ -n "$FS_BS_ACTUAL" && -n "$BLKCNT" ]] || die 'Could not read filesystem parameters from image' if [[ -n "$FS_BS" && "$FS_BS" != "$FS_BS_ACTUAL" ]]; then warn "FS_BS override ($FS_BS) differs from image block size ($FS_BS_ACTUAL). Using image value." fi FS_BS="$FS_BS_ACTUAL" (( FS_BS % SECTOR_BYTES == 0 )) || die "FS_BS ($FS_BS) is not a multiple of sector bytes ($SECTOR_BYTES)" PER_BLK=$(( FS_BS / SECTOR_BYTES )) } # ===== parked debugfs (no-op for now) ===== debugfs_probe() { :; } # ===== zero-detect & viewer wrapper ===== zeros_or_not() { # stdin is the byte stream; $1=label text, $2=byte count local label=${1:?label required} local nbytes=${2:?byte-count required} # Bound the comparison to avoid infinite /dev/zero length mismatch if cmp -s -n "$nbytes" - /dev/zero; then printf '[zeros] all-zero (%s)\n' "$label" else printf '[zeros] non-zero (%s)\n' "$label" fi } preview_and_zero() { # stdin is the byte stream; $1=byte-count, $2=label local nbytes=${1:?byte-count required} local label=${2:?label required} # One tee fork to zero-check; main stream to hex viewer, then stdbuf to mitigate stdout and stderr interleaving tee >( zeros_or_not "$label" "$nbytes" 1>&2 ) | stdbuf -o "$nbytes" "${VIEW[@]}" } # ===== read by filesystem block (1:1 with image) ===== read_from_fsblock() { local img=${1:?} fs_block=${2:?} is_uint "$fs_block" || die 'FS block must be a non-negative integer' init_image "$img" set_viewer (( fs_block >= 0 && fs_block < BLKCNT )) || die "FS_BLOCK $fs_block out of range [0..$((BLKCNT-1))]" local block_off=$(( fs_block * FS_BS )) (( block_off + FS_BS <= IMG_SIZE )) || die "Requested range [$block_off..$((block_off+FS_BS))) exceeds image size $IMG_SIZE" note "IMG=$img" note "IMG_SIZE=$IMG_SIZE SECTOR_BYTES=$SECTOR_BYTES FS_BS=$FS_BS PER_BLK=$PER_BLK" note "First block=$FIRST_BLOCK Block count=$BLKCNT" note "FS_BLOCK=$fs_block offset=$block_off" print_view_cmd note "[read] ${FS_BS}-byte filesystem block FS_BLOCK=$fs_block (offset=$block_off)" dd if="$img" bs="$FS_BS" skip="$fs_block" count=1 status=none | preview_and_zero "$FS_BS" "${FS_BS} bytes" } # ===== read by disk LBA + partition start ===== read_from_lba() { local img=${1:?} lba=${2:?} part_start=${3:?} is_uint "$lba" || die 'LBA must be a decimal integer' is_uint "$part_start" || die 'PART_START_LBA must be a decimal integer' init_image "$img" set_viewer local sect_in_part=$(( lba - part_start )) (( sect_in_part >= 0 )) || die "LBA $lba is before partition start $part_start" local byte_off=$(( sect_in_part * SECTOR_BYTES )) local fs_block=$(( sect_in_part / PER_BLK )) local intra_sec=$(( sect_in_part % PER_BLK )) (( byte_off + SECTOR_BYTES <= IMG_SIZE )) || die "Requested byte range [$byte_off..$((byte_off+SECTOR_BYTES))) exceeds image size $IMG_SIZE" (( fs_block >= 0 && fs_block < BLKCNT )) || die "FS_BLOCK $fs_block out of range [0..$((BLKCNT-1))]" note "IMG=$img" note "IMG_SIZE=$IMG_SIZE SECTOR_BYTES=$SECTOR_BYTES FS_BS=$FS_BS PER_BLK=$PER_BLK" note "First block=$FIRST_BLOCK Block count=$BLKCNT" note "DISK_LBA=$lba PART_START_LBA=$part_start sect_in_part=$sect_in_part" note "FS_BLOCK=$fs_block sector_in_4k=$intra_sec byte_off=$byte_off" print_view_cmd # 512-byte sector note "[read] 512 bytes at sector_in_partition=$sect_in_part (byte_off=$byte_off)" dd if="$img" iflag=skip_bytes,count_bytes skip="$byte_off" count="$SECTOR_BYTES" status=none | preview_and_zero "$SECTOR_BYTES" "512 bytes" # Whole FS block local block_off=$(( fs_block * FS_BS )) note "[read] ${FS_BS}-byte filesystem block FS_BLOCK=$fs_block (offset=$block_off)" dd if="$img" bs="$FS_BS" skip="$fs_block" count=1 status=none | preview_and_zero "$FS_BS" "${FS_BS} bytes" # The 512 bytes within that block for this LBA note "[read] 512 bytes within FS_BLOCK=$fs_block at sector_in_4k=$intra_sec" dd if="$img" bs="$FS_BS" skip="$fs_block" count=1 status=none | dd bs="$SECTOR_BYTES" skip="$intra_sec" count=1 status=none | preview_and_zero "$SECTOR_BYTES" "512 bytes" } # ===== option parsing (getopts with long options) ===== # Should be POSIX compatible # Thank you: https://stackoverflow.com/users/519360/adam-katz # https://stackoverflow.com/a/28466267/490487 IMG="" LBA="" PART_START="" FS_BLOCK="" SECTOR_BYTES="512" FS_BS="" while getopts hi:l:p:B:s:k:-: opt; do if [[ "$opt" == "-" ]]; then opt=${OPTARG%%=*} OPTARG=${OPTARG#"$opt"} OPTARG=${OPTARG#=} fi case "$opt" in h|help) usage ;; i|image) IMG=${OPTARG:-} ;; l|lba) LBA=${OPTARG:-} ;; p|part-start) PART_START=${OPTARG:-} ;; B|fs-block) FS_BLOCK=${OPTARG:-} ;; s|sector-bytes) SECTOR_BYTES=${OPTARG:-} ;; k|fs-block-size) FS_BS=${OPTARG:-} ;; # optional override; auto-detected by default \?) usage ;; *) printf 'Illegal option --%s\n' "$opt" >&2; usage ;; esac done shift $((OPTIND-1)) ensure_deps set_viewer # ===== dispatch ===== if [[ -n "$FS_BLOCK" ]]; then [[ -n "$IMG" ]] || usage read_from_fsblock "$IMG" "$FS_BLOCK" exit 0 fi if [[ -n "$LBA" || -n "$PART_START" ]]; then [[ -n "$IMG" && -n "$LBA" && -n "$PART_START" ]] || usage read_from_lba "$IMG" "$LBA" "$PART_START" exit 0 fi usage