|
#!/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 <image> --fs-block=<N> [--fs-block-size=<bytes>] [--sector-bytes=<bytes>] |
|
ext_block_inspector.sh -i <image> --lba=<DISK_LBA> --part-start=<PART_START_LBA> [--sector-bytes=<bytes>] |
|
|
|
Options: |
|
-i, --image=<PATH> Path to partition image (required) |
|
--fs-block=<N> Filesystem block index (0-based; 1:1 with image; ddrescuelog -b <FS_BS>) |
|
--lba=<N> Disk LBA (512-byte sectors by default) |
|
--part-start=<N> Partition start LBA on original disk (required with --lba) |
|
--sector-bytes=<BYTES> LBA sector size. Default: 512 |
|
--fs-block-size=<BYTES> 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 |