Files
ubuntu-based-kiosk/iso/e2b-contig.sh
Claude d4f9233655 Add iso/e2b-contig.sh: make an ISO contiguous on a USB drive from Linux
Easy2Boot's UEFI menu returned straight to the menu for the new kiosk ISO
(a 3.5 GB copy on a well-used drive). Some multiboot modes need the ISO in
one unbroken piece, and Linux has no defragmenter for NTFS/FAT32/exFAT -
Easy2Boot's own Linux helpers are a 32-bit udefrag needing i386 libraries
and a FAT32-only perl script.

e2b-contig.sh rewrites just the one file, using only ordinary file
operations - never filesystem structures - so the worst case is "still
not contiguous", never a damaged drive:

1. fill the drive's free space with 4 MB filler files (plugs every gap;
   a plain copy, or fallocate where it exists at all, just fills the
   gaps again - measured on ntfs-3g: 7-8 pieces either way);
2. map the fillers (filefrag -e, which also works on ntfs-3g via FIBMAP)
   and find the longest physically unbroken run of single-piece ones;
3. free just enough of that run and write the copy - the only free space
   left - then delete the fillers;
4. verify the copy's SHA-256 against the original and rename it into
   place (same directory, so the data doesn't move).

If there's no run big enough while the original is on the drive,
--move-out parks it on this computer (checksummed), retries, and puts it
back verified if that still fails. --check only reports. Interrupts and
failures remove fillers and temp copies.

Tested on NTFS images through ntfs-3g: a 60 MB file in 7 pieces became 1
piece with identical content, no leftovers, free space unchanged (27 s for
~900 MB free); drives with no big enough free run (with and without
--move-out) report so and leave the original byte-identical, parked copy
cleaned up. FAT32/exFAT through their FUSE drivers can't report layout,
which the tool detects and stops on, naming the kernel driver to remount
with (vfat/exfat/ntfs3); the kernel drivers aren't available in the test
environment, so those paths are untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-09 18:42:28 +00:00

304 lines
12 KiB
Bash
Executable File

#!/bin/bash
################################################################################
# iso/e2b-contig.sh - Make a file on a USB drive contiguous, from Linux.
#
# Easy2Boot (and other multiboot loaders) can only boot some ISO files when
# they sit on the drive in one unbroken piece. A big ISO copied onto a
# well-used drive usually lands in several pieces, and Linux has no
# defragmenter for NTFS/FAT32/exFAT. This rewrites the one file so it is
# contiguous, using only ordinary file operations - it never writes
# filesystem structures directly, so the worst case is "still not
# contiguous", not a damaged drive.
#
# How: a plain copy just fills the drive's scattered free gaps again. So
# 1. fill the free space with temporary filler files (plugging every gap),
# 2. find the longest physically unbroken run among them and free exactly
# that run,
# 3. write the copy - the only free space left is that run,
# 4. delete the fillers, verify the copy's SHA-256, swap it in.
# This writes about as much data as the drive has free (progress shown) -
# minutes on USB 3, longer on USB 2 or a nearly empty big drive.
#
# If there's no unbroken run as big as the file while the original is still
# on the drive, --move-out first parks the original on this computer's disk
# (checksummed), deletes it from the drive to free its pieces, and retries.
#
# Usage:
# sudo iso/e2b-contig.sh [--check] [--move-out] [--temp-dir DIR] [--yes] FILE...
#
# --check Only report whether each file is contiguous.
# --move-out Allowed to park the original on this computer if needed
# (default temp dir: /var/tmp).
# --temp-dir D Where to park it.
# --yes Don't ask for confirmation.
#
# Works with NTFS (ntfs-3g or the ntfs3 kernel driver), FAT32 and exFAT -
# anything filefrag can map. Run as root (filefrag needs it on most of
# these filesystems).
################################################################################
set -uo pipefail
CHECK_ONLY=0
MOVE_OUT=0
ASSUME_YES=0
TEMP_DIR=/var/tmp
# Filler size. Small enough that fillers fit inside the drive's free gaps
# (a filler spanning two gaps can't be part of an unbroken run); kept in
# subfolders of 1000 because FAT32 caps the number of files per folder.
CHUNK_MB=4
die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "==> $*"; }
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_ONLY=1; shift ;;
--move-out) MOVE_OUT=1; shift ;;
--temp-dir) TEMP_DIR="$2"; shift 2 ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) sed -n '/^# Usage:/,/^################/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
-*) die "unknown option: $1" ;;
*) FILES+=("$1"); shift ;;
esac
done
(( ${#FILES[@]} )) || die "no file given - usage: sudo $0 [--check] FILE..."
[[ $EUID -eq 0 ]] || die "run with sudo (filefrag needs root on NTFS/FAT/exFAT)"
for cmd in filefrag findmnt blkid sha256sum dd awk df stat; do
command -v "$cmd" &>/dev/null || die "'$cmd' not found (packages: e2fsprogs, util-linux, coreutils)"
done
# extents FILE -> "physical_start physical_end" per extent (filesystem
# blocks), in file order. Empty output = couldn't map (unsupported).
extents() {
filefrag -e "$1" 2>/dev/null | awk '
/^ *[0-9]+:/ {
gsub(/\.\./, " "); gsub(/:/, " ")
# fields: idx logical_start logical_end physical_start physical_end length ...
print $4, $5
}'
}
# Block size filefrag reports in.
fs_block_size() {
filefrag -e "$1" 2>/dev/null | sed -n 's/.*(\([0-9]*\) blocks of \([0-9]*\) bytes).*/\2/p' | head -1
}
# pieces FILE -> number of physically separate pieces (adjacent extents
# count as one; filefrag splits long runs into several extents).
pieces() {
extents "$1" | awk 'NR==1 {n=1; prev=$2; next} { if ($1 != prev + 1) n++; prev=$2 } END { print n+0 }'
}
confirm() {
(( ASSUME_YES )) && return 0
local reply
read -r -p "$1 [y/N] " reply
[[ "${reply,,}" == y || "${reply,,}" == yes ]]
}
FILL_DIR=""
TMP_COPY=""
cleanup() {
[[ -n "$FILL_DIR" && -d "$FILL_DIR" ]] && rm -rf -- "$FILL_DIR"
[[ -n "$TMP_COPY" && -f "$TMP_COPY" ]] && rm -f -- "$TMP_COPY"
sync
}
trap cleanup EXIT
trap 'echo; echo "Interrupted - removing temporary files..."; exit 130' INT TERM
# fill_and_carve MOUNT NEED_BYTES -> 0 if it left exactly one free run of at
# least NEED_BYTES (fillers stay in $FILL_DIR until cleanup), 1 if no such run.
fill_and_carve() {
local mnt="$1" need="$2"
FILL_DIR="$mnt/.e2b-contig-fill.$$"
mkdir -p "$FILL_DIR" || return 1
local free_mb
free_mb=$(( $(df -B1M --output=avail "$mnt" | tail -1) ))
info "Filling $free_mb MB of free space with temporary files to plug the gaps..."
local n=0 written=0 fname
while :; do
n=$((n + 1))
fname="$FILL_DIR/d$(printf %04d $((n / 1000)))/f$(printf %07d $n)"
mkdir -p "${fname%/*}" 2>/dev/null || break
# dd stops (keeping what it wrote) when the drive is full.
dd if=/dev/zero of="$fname" bs=1M count="$CHUNK_MB" status=none 2>/dev/null
local rc=$?
local sz
sz=$(stat -c %s "$fname" 2>/dev/null || echo 0)
written=$((written + sz))
(( n % 25 == 0 )) && printf '\r %d / %d MB' "$((written / 1048576))" "$free_mb"
(( rc != 0 || sz < CHUNK_MB * 1048576 )) && break
done
printf '\r %d / %d MB\n' "$((written / 1048576))" "$free_mb"
sync
# Single-piece fillers sorted by physical position; the longest chain
# where each starts right after the previous ends is the best run.
local bs
bs=$(fs_block_size "$FILL_DIR/d0000/f0000001")
[[ -n "$bs" ]] || { echo " Couldn't map the filler files (filefrag unsupported here)."; return 1; }
# The map lives on this computer, not on the (now full) drive.
local f list
list=$(mktemp) || return 1
info "Mapping where the filler files landed..."
for f in "$FILL_DIR"/d*/f*; do
[[ -s "$f" ]] || continue
# One filefrag call per filler (there can be thousands): keep it only
# if it's a single piece, as "start end path".
extents "$f" | awk -v f="$f" '
NR == 1 { s = $1; e = $2; n = 1; next }
{ if ($1 == e + 1) e = $2; else n++ }
END { if (NR > 0 && n == 1) print s, e, f }' >> "$list"
done
local best
best=$(sort -n "$list" | awk -v bs="$bs" '
{ if (NR == 1 || $1 != prev_end + 1) { start_line = NR; run_bytes = 0; files = "" }
run_bytes += ($2 - $1 + 1) * bs; files = files " " $3; prev_end = $2
if (run_bytes > best_bytes) { best_bytes = run_bytes; best_files = files } }
END { print best_bytes + 0, best_files }')
rm -f -- "$list"
local best_bytes=${best%% *}
info "Longest unbroken free run found: $((best_bytes / 1048576)) MB (need $((need / 1048576)) MB)"
if (( best_bytes < need )); then
return 1
fi
# Free just enough of that run (from its start) for the file - keeping the
# rest plugged so the copy can't wander elsewhere.
local freed=0 g
for g in ${best#* }; do
(( freed >= need )) && break
freed=$((freed + $(stat -c %s "$g")))
rm -f -- "$g"
done
sync
return 0
}
# restore_parked PARKED FILE SUM - copy a parked original back onto the
# drive (not contiguous - just safe) and only delete the parked copy once
# the restored one verifies.
restore_parked() {
local parked="$1" file="$2" sum="$3"
info "Putting the original back..."
if cp -- "$parked" "$file" && [[ "$(sha256sum "$file" | cut -d' ' -f1)" == "$sum" ]]; then
rm -f -- "$parked"
echo " Original restored (unchanged, still in pieces)."
else
echo " !! Couldn't verify the restored file - the original is kept safe at $parked"
fi
}
# make_contiguous FILE
make_contiguous() {
local file="$1"
[[ -f "$file" ]] || { echo "$file: not a file"; return 1; }
file=$(realpath "$file")
local mnt fstype dev
read -r dev fstype mnt < <(findmnt -n -o SOURCE,FSTYPE,TARGET --target "$file")
local size
size=$(stat -c %s "$file")
local p
p=$(pieces "$file")
echo
echo "$file"
echo " drive: $dev ($fstype) mounted at $mnt, size $((size / 1048576)) MB"
if [[ -z "$(extents "$file")" ]]; then
echo " Can't read this file's layout through this driver ($fstype)."
# FUSE drivers (fusefat, exfat-fuse) can't map files; the kernel
# drivers can - and ntfs-3g usually can, but ntfs3 always can.
local real kdrv=""
real=$(blkid -o value -s TYPE "$dev" 2>/dev/null)
case "$real" in
ntfs) kdrv=ntfs3 ;;
exfat) kdrv=exfat ;;
vfat|msdos) kdrv=vfat ;;
esac
if [[ -n "$kdrv" ]]; then
echo " Remount it with the kernel's $kdrv driver and retry:"
echo " sudo umount $dev && sudo mount -t $kdrv $dev $mnt"
fi
return 1
fi
if [[ "$p" == 1 ]]; then
echo " Contiguous (1 piece) - nothing to do."
return 0
fi
echo " NOT contiguous: $p pieces."
(( CHECK_ONLY )) && return 2
confirm " Rewrite it in one piece? (temporarily fills the drive's free space)" || { echo " Skipped."; return 2; }
info "Checksumming the original..."
local sum
sum=$(sha256sum "$file" | cut -d' ' -f1)
local source="$file" parked=""
if ! fill_and_carve "$mnt" "$size"; then
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
if (( ! MOVE_OUT )); then
echo " Not enough unbroken free space with the original still on the drive."
echo " Re-run with --move-out to park the original on this computer meanwhile,"
echo " or free up space on the drive (move a few other large files off)."
return 1
fi
local avail
avail=$(( $(df -B1 --output=avail "$TEMP_DIR" | tail -1) ))
(( avail > size )) || { echo " Not enough room in $TEMP_DIR to park it ($((size / 1048576)) MB needed)."; return 1; }
parked="$TEMP_DIR/$(basename "$file").e2b-contig-parked"
info "Parking the original in $parked..."
cp -- "$file" "$parked" && [[ "$(sha256sum "$parked" | cut -d' ' -f1)" == "$sum" ]] \
|| { rm -f -- "$parked"; echo " Parking copy failed its checksum - nothing changed."; return 1; }
rm -f -- "$file"; sync
source="$parked"
if ! fill_and_carve "$mnt" "$size"; then
rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
echo " Still no unbroken run big enough."
restore_parked "$parked" "$file" "$sum"
echo " Free up space on the drive (it needs one unbroken $((size / 1048576)) MB area) and retry."
return 1
fi
fi
TMP_COPY="$(dirname "$file")/.e2b-contig.$$.tmp"
info "Writing the copy into the freed run..."
if ! dd if="$source" of="$TMP_COPY" bs=4M conv=fsync status=progress; then
echo " Copy failed."
rm -f -- "$TMP_COPY"; TMP_COPY=""; rm -rf -- "$FILL_DIR"; FILL_DIR=""; sync
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
return 1
fi
rm -rf -- "$FILL_DIR"; FILL_DIR=""
sync
local newp
newp=$(pieces "$TMP_COPY")
info "Verifying..."
if [[ "$(sha256sum "$TMP_COPY" | cut -d' ' -f1)" != "$sum" ]]; then
echo " !! The copy doesn't match the original's checksum - discarding it."
rm -f -- "$TMP_COPY"; TMP_COPY=""; sync
[[ -n "$parked" ]] && restore_parked "$parked" "$file" "$sum"
return 1
fi
if [[ "$newp" != 1 ]]; then
echo " The copy still came out in $newp pieces (was $p)."
fi
# Same directory, so a rename: the data stays where it was written.
mv -f -- "$TMP_COPY" "$file" && TMP_COPY=""
[[ -n "$parked" ]] && rm -f -- "$parked"
sync
if [[ "$(pieces "$file")" == 1 ]]; then
echo " ✓ Contiguous now (1 piece), checksum verified."
return 0
fi
echo " Checksum verified, but still $(pieces "$file") pieces - see the notes above."
return 1
}
rc=0
for f in "${FILES[@]}"; do
make_contiguous "$f" || rc=1
done
exit "$rc"