Created
May 10, 2026 09:59
-
-
Save n0ctu/ec735b103f9c993d93c90154d1e665c2 to your computer and use it in GitHub Desktop.
Identify corrupted files in NFTS images using a gddrescue map-file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # find_affected_files.sh | |
| # | |
| # Identifies files affected by bad sectors in a ddrescue rescue. | |
| # Reads the ddrescue mapfile, locates each bad region within the image's | |
| # partitions, and uses ntfscluster (for NTFS) to map sectors to file paths. | |
| # | |
| # Usage: sudo ./find_affected_files.sh | |
| # | |
| # Requires: parted, ntfs-3g (provides ntfscluster), awk, bc | |
| set -euo pipefail | |
| # ---- Configuration ----------------------------------------------------------- | |
| IMAGE="/run/media/n0c/HDD07/sdb.img" | |
| MAPFILE="/home/n0c/temp/sdb_restore.log" | |
| OUTPUT="/home/n0c/temp/affected_files.log" | |
| WORKDIR="$(mktemp -d)" | |
| trap 'rm -rf "$WORKDIR"' EXIT | |
| # ---- Sanity checks ----------------------------------------------------------- | |
| if [[ $EUID -ne 0 ]]; then | |
| echo "ERROR: must be run as root (uses losetup and reads block devices)" >&2 | |
| exit 1 | |
| fi | |
| for cmd in parted ntfscluster losetup awk; do | |
| if ! command -v "$cmd" >/dev/null 2>&1; then | |
| echo "ERROR: required command not found: $cmd" >&2 | |
| echo " Install with: sudo apt install parted ntfs-3g util-linux" >&2 | |
| exit 1 | |
| fi | |
| done | |
| [[ -f "$IMAGE" ]] || { echo "ERROR: image not found: $IMAGE" >&2; exit 1; } | |
| [[ -f "$MAPFILE" ]] || { echo "ERROR: mapfile not found: $MAPFILE" >&2; exit 1; } | |
| echo "Image: $IMAGE" | |
| echo "Mapfile: $MAPFILE" | |
| echo "Output: $OUTPUT" | |
| echo | |
| # ---- Parse partition layout from the image ----------------------------------- | |
| echo "=== Partition layout ===" | |
| # parted -m gives machine-readable output: BYTE units, colon-separated | |
| # Format: NUMBER:START:END:SIZE:FILESYSTEM:NAME:FLAGS | |
| PARTED_OUT="$(parted -m -s "$IMAGE" unit B print 2>/dev/null || true)" | |
| echo "$PARTED_OUT" | |
| echo | |
| # Extract partition entries (skip header lines starting with BYT: or the image path) | |
| # Build an array: "num start end fstype" | |
| declare -a PART_NUM PART_START PART_END PART_FSTYPE | |
| while IFS=: read -r num start end size fstype name flags; do | |
| [[ -z "$num" || "$num" == "BYT" ]] && continue | |
| [[ "$num" =~ ^[0-9]+$ ]] || continue | |
| # Strip trailing 'B' from byte values | |
| start="${start%B}" | |
| end="${end%B}" | |
| PART_NUM+=("$num") | |
| PART_START+=("$start") | |
| PART_END+=("$end") | |
| PART_FSTYPE+=("$fstype") | |
| done < <(echo "$PARTED_OUT") | |
| if [[ ${#PART_NUM[@]} -eq 0 ]]; then | |
| echo "ERROR: no partitions detected in image" >&2 | |
| exit 1 | |
| fi | |
| # ---- Set up loop device for the image ---------------------------------------- | |
| echo "=== Setting up loop device ===" | |
| LOOPDEV="$(losetup --find --show --partscan --read-only "$IMAGE")" | |
| echo "Image attached as $LOOPDEV" | |
| echo "Partitions:" | |
| ls "${LOOPDEV}"p* 2>/dev/null || true | |
| echo | |
| cleanup_loop() { losetup -d "$LOOPDEV" 2>/dev/null || true; } | |
| trap 'cleanup_loop; rm -rf "$WORKDIR"' EXIT | |
| # ---- Extract bad regions from the mapfile ------------------------------------ | |
| # Mapfile data lines: <offset_hex> <size_hex> <status> | |
| # Status '-' = bad-sector. We also include '/' (non-trimmed) and '*' (non-scraped) | |
| # in case any remain, though after a completed run these are usually all '-' or '+'. | |
| BAD_REGIONS="$WORKDIR/bad_regions.txt" | |
| awk ' | |
| /^[[:space:]]*#/ { next } | |
| NF >= 3 && $1 ~ /^0x/ && ($3 == "-" || $3 == "/" || $3 == "*") { | |
| # Convert hex to decimal | |
| offset = strtonum($1) | |
| size = strtonum($2) | |
| printf "%d %d %s\n", offset, size, $3 | |
| } | |
| ' "$MAPFILE" > "$BAD_REGIONS" | |
| BAD_COUNT="$(wc -l < "$BAD_REGIONS")" | |
| echo "=== Bad regions in mapfile: $BAD_COUNT ===" | |
| if [[ "$BAD_COUNT" -eq 0 ]]; then | |
| echo "No bad regions found. Nothing to map." | |
| exit 0 | |
| fi | |
| echo | |
| # ---- For each bad region, find owning partition + map to file ---------------- | |
| > "$OUTPUT" | |
| { | |
| echo "# Files affected by bad sectors" | |
| echo "# Image: $IMAGE" | |
| echo "# Mapfile: $MAPFILE" | |
| echo "# Generated: $(date -Is)" | |
| echo "#" | |
| } >> "$OUTPUT" | |
| declare -A SEEN_FILES # dedup: path -> 1 | |
| UNMAPPED=0 | |
| OUTSIDE_PART=0 | |
| NON_NTFS=0 | |
| # Process each bad region | |
| while read -r offset size status; do | |
| end_offset=$(( offset + size - 1 )) | |
| # Find the partition containing this offset | |
| matched_part="" | |
| matched_idx=-1 | |
| for i in "${!PART_NUM[@]}"; do | |
| ps="${PART_START[$i]}" | |
| pe="${PART_END[$i]}" | |
| if (( offset >= ps && offset <= pe )); then | |
| matched_part="${PART_NUM[$i]}" | |
| matched_idx=$i | |
| break | |
| fi | |
| done | |
| if [[ -z "$matched_part" ]]; then | |
| OUTSIDE_PART=$(( OUTSIDE_PART + 1 )) | |
| continue | |
| fi | |
| fstype="${PART_FSTYPE[$matched_idx]}" | |
| part_dev="${LOOPDEV}p${matched_part}" | |
| if [[ "$fstype" != "ntfs" ]]; then | |
| NON_NTFS=$(( NON_NTFS + 1 )) | |
| continue | |
| fi | |
| [[ -b "$part_dev" ]] || { echo "WARN: partition device missing: $part_dev" >&2; continue; } | |
| # Offset within the partition (in bytes), then convert to a 512-byte sector | |
| part_start="${PART_START[$matched_idx]}" | |
| rel_offset=$(( offset - part_start )) | |
| sector=$(( rel_offset / 512 )) | |
| # ntfscluster --sector reports inode + filename for the given sector. | |
| # We sample the first sector of the bad region; bad regions are typically | |
| # small (one or a few sectors) and the file mapping won't change within them. | |
| if result="$(ntfscluster --sector "$sector" "$part_dev" 2>/dev/null)"; then | |
| # Extract any "Inode X /path/to/file" lines | |
| while IFS= read -r line; do | |
| # Match lines like: "Inode 12345 /Users/foo/bar.txt" | |
| if [[ "$line" =~ ^Inode\ [0-9]+\ (.+)$ ]]; then | |
| path="${BASH_REMATCH[1]}" | |
| if [[ -z "${SEEN_FILES[$path]:-}" ]]; then | |
| SEEN_FILES["$path"]=1 | |
| echo "$path" >> "$OUTPUT" | |
| fi | |
| fi | |
| done <<< "$result" | |
| else | |
| UNMAPPED=$(( UNMAPPED + 1 )) | |
| fi | |
| done < "$BAD_REGIONS" | |
| # ---- Summary ----------------------------------------------------------------- | |
| UNIQUE_COUNT="${#SEEN_FILES[@]}" | |
| echo | |
| echo "=== Summary ===" | |
| echo "Bad regions processed: $BAD_COUNT" | |
| echo "Outside any partition: $OUTSIDE_PART (likely free space / partition gaps)" | |
| echo "On non-NTFS partitions: $NON_NTFS (e.g., EFI, recovery)" | |
| echo "Could not be mapped to file: $UNMAPPED (likely free space within partition)" | |
| echo "Unique affected files: $UNIQUE_COUNT" | |
| echo | |
| echo "Result written to: $OUTPUT" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment