Skip to content

Instantly share code, notes, and snippets.

@Cryolitia
Created April 24, 2026 09:48
Show Gist options
  • Select an option

  • Save Cryolitia/6a68087c590e5693156a4e5cc3be8aca to your computer and use it in GitHub Desktop.

Select an option

Save Cryolitia/6a68087c590e5693156a4e5cc3be8aca to your computer and use it in GitHub Desktop.
Convert 512-byte sector disk images to 4K logical-sector images while preserving partition tables, metadata, and partition contents
#! /usr/bin/env nix-shell
#! nix-shell -i bash -p bash coreutils util-linux parted gptfdisk jq
# SPDX-FileCopyrightText: 2026 Cryolitia
# SPDX-License-Identifier: MIT
set -euo pipefail
SECTOR_SIZE=4096
TRANSFER_BLOCK_SIZE=4M
ALIGN_MIB=1
ALIGN_BYTES=$((ALIGN_MIB * 1024 * 1024))
declare -a PART_NUMS=()
declare -a PART_START_BYTES=()
declare -a PART_SIZES=()
declare -a PART_END_BYTES=()
declare -a PART_FS_TYPES=()
declare -a NEW_START_BYTES=()
declare -a NEW_END_BYTES=()
declare -a NEW_SIZES=()
declare -a NEW_START_LBAS=()
declare -a NEW_END_LBAS=()
declare -a GPT_NAMES=()
declare -a GPT_TYPE_GUIDS=()
declare -a GPT_UNIQUE_GUIDS=()
declare -a MBR_TYPE_CODES=()
declare -a MBR_BOOTABLES=()
MBR_DISK_ID=""
INPUT_IMG=""
OUTPUT_IMG=""
FORCE=0
ANALYZE_ONLY=0
COMPUTED_OUTPUT_SIZE=""
SOURCE_DEV=""
OUTPUT_LOOP=""
ANALYZE_LOOP=""
INPUT_SECTOR_SIZE=512
cleanup() {
local status=$?
if [[ -n "$OUTPUT_LOOP" ]]; then
losetup -d "$OUTPUT_LOOP" >/dev/null 2>&1 || true
fi
if [[ -n "$ANALYZE_LOOP" ]]; then
losetup -d "$ANALYZE_LOOP" >/dev/null 2>&1 || true
fi
if [[ $status -ne 0 ]]; then
printf 'Error: script failed with status %s\n' "$status" >&2
fi
}
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage:
convert-sector-img.sh -i INPUT_IMG -o OUTPUT_IMG [--force]
convert-sector-img.sh -i INPUT_IMG --analyze-only
Convert a 512-byte logical-sector raw disk image into a 4K logical-sector raw
disk image while preserving partition contents.
Options:
-i, --input Input raw disk image
-o, --output Output raw disk image
--analyze-only
Read-only analysis of the input image and proposed 4K layout
--force Overwrite output image if it exists
-h, --help Show this help
Notes:
- GPT input stays GPT output and keeps partition name/type GUID/unique GUID.
- MBR input stays MBR output.
- MBR images with extended or logical partitions are rejected.
- Partition contents are copied byte-for-byte and verified with sha256.
- Copy and verification display progress with dd status=progress.
- Conversion requires root privileges for 4K loop device setup.
- Analyze mode auto-detects 512-byte vs 4K logical sector layouts.
EOF
}
require_commands() {
local cmd
for cmd in fdisk parted sfdisk sgdisk jq dd truncate sha256sum stat numfmt losetup; do
command -v "$cmd" >/dev/null 2>&1 || {
printf 'Error: required command not found: %s\n' "$cmd" >&2
exit 1
}
done
}
align_up() {
local value=$1
local alignment=$2
printf '%s\n' $(( ((value + alignment - 1) / alignment) * alignment ))
}
format_bytes() {
numfmt --to=iec --suffix=B "$1"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-i|--input)
[[ $# -ge 2 ]] || { printf 'Error: missing value for %s\n' "$1" >&2; exit 1; }
INPUT_IMG=$2
shift 2
;;
-o|--output)
[[ $# -ge 2 ]] || { printf 'Error: missing value for %s\n' "$1" >&2; exit 1; }
OUTPUT_IMG=$2
shift 2
;;
--force)
FORCE=1
shift
;;
--analyze-only)
ANALYZE_ONLY=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Error: unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
[[ -n "$INPUT_IMG" ]] || { printf 'Error: input image is required\n' >&2; exit 1; }
[[ -f "$INPUT_IMG" ]] || { printf 'Error: input image not found: %s\n' "$INPUT_IMG" >&2; exit 1; }
if [[ $ANALYZE_ONLY -eq 1 ]]; then
[[ -z "$OUTPUT_IMG" ]] || { printf 'Error: --analyze-only does not accept --output\n' >&2; exit 1; }
else
[[ -n "$OUTPUT_IMG" ]] || { printf 'Error: output image is required\n' >&2; exit 1; }
if [[ "$INPUT_IMG" -ef "$OUTPUT_IMG" ]]; then
printf 'Error: input and output image must be different files\n' >&2
exit 1
fi
fi
if [[ $ANALYZE_ONLY -eq 0 && -e "$OUTPUT_IMG" && $FORCE -ne 1 ]]; then
printf 'Error: output image exists, use --force to overwrite: %s\n' "$OUTPUT_IMG" >&2
exit 1
fi
}
ensure_privileges() {
if [[ $ANALYZE_ONLY -eq 1 || $EUID -eq 0 ]]; then
return
fi
command -v sudo >/dev/null 2>&1 || {
printf 'Error: conversion requires root privileges; rerun with sudo\n' >&2
exit 1
}
printf 'Requesting sudo for 4K loop device operations...\n' >&2
exec sudo "$0" "$@"
}
reset_partition_state() {
PART_NUMS=()
PART_START_BYTES=()
PART_SIZES=()
PART_END_BYTES=()
PART_FS_TYPES=()
NEW_START_BYTES=()
NEW_END_BYTES=()
NEW_SIZES=()
NEW_START_LBAS=()
NEW_END_LBAS=()
GPT_NAMES=()
GPT_TYPE_GUIDS=()
GPT_UNIQUE_GUIDS=()
MBR_TYPE_CODES=()
MBR_BOOTABLES=()
MBR_DISK_ID=""
COMPUTED_OUTPUT_SIZE=""
}
can_probe_4k_loop() {
if [[ $EUID -eq 0 ]]; then
return 0
fi
command -v sudo >/dev/null 2>&1 || return 1
sudo -n true >/dev/null 2>&1
}
probe_layout_metrics() {
local device=$1
local label line start_field end_field size_field total_size=0 part_count=0 max_end=0
label=$(parted -m -s "$device" print 2>/dev/null | awk -F: 'NR==2 { print $6 }') || return 1
case "$label" in
gpt|msdos) ;;
*) return 1 ;;
esac
while IFS= read -r line; do
[[ $line == [0-9]*:* ]] || continue
IFS=: read -r _ start_field end_field size_field _ <<<"$line"
part_count=$((part_count + 1))
total_size=$((total_size + ${size_field%B}))
if (( ${end_field%B} > max_end )); then
max_end=${end_field%B}
fi
done < <(parted -m -s "$device" unit B print 2>/dev/null)
(( part_count > 0 )) || return 1
printf '%s:%s:%s:%s\n' "$label" "$part_count" "$total_size" "$max_end"
}
select_analysis_source() {
local raw_metrics raw_label raw_count raw_total raw_end
local loop_metrics loop_label loop_count loop_total loop_end
SOURCE_DEV=$INPUT_IMG
INPUT_SECTOR_SIZE=512
raw_metrics=$(probe_layout_metrics "$INPUT_IMG") || {
printf 'Error: failed to read partition layout from input image\n' >&2
exit 1
}
IFS=: read -r raw_label raw_count raw_total raw_end <<<"$raw_metrics"
if ! can_probe_4k_loop; then
return
fi
ANALYZE_LOOP=$(losetup --find --show --sector-size "$SECTOR_SIZE" "$INPUT_IMG" 2>/dev/null || true)
[[ -n "$ANALYZE_LOOP" ]] || return 0
loop_metrics=$(probe_layout_metrics "$ANALYZE_LOOP" 2>/dev/null || true)
[[ -n "$loop_metrics" ]] || return 0
IFS=: read -r loop_label loop_count loop_total loop_end <<<"$loop_metrics"
if (( loop_total > raw_total || loop_end > raw_end )); then
SOURCE_DEV=$ANALYZE_LOOP
INPUT_SECTOR_SIZE=$SECTOR_SIZE
fi
}
detect_label_type() {
local label
label=$(parted -m -s "$SOURCE_DEV" print | awk -F: 'NR==2 { print $6 }')
case "$label" in
gpt|msdos)
printf '%s\n' "$label"
;;
*)
printf 'Error: unsupported partition table type: %s\n' "$label" >&2
exit 1
;;
esac
}
collect_common_partitions() {
local line number start_field end_field size_field fs_field start_bytes end_bytes size_bytes
while IFS= read -r line; do
[[ $line == [0-9]*:* ]] || continue
IFS=: read -r number start_field end_field size_field fs_field _ <<<"$line"
start_bytes=${start_field%B}
end_bytes=${end_field%B}
size_bytes=${size_field%B}
PART_NUMS+=("$number")
PART_START_BYTES+=("$start_bytes")
PART_END_BYTES+=("$end_bytes")
PART_SIZES+=("$size_bytes")
PART_FS_TYPES+=("$fs_field")
done < <(parted -m -s "$SOURCE_DEV" unit B print)
[[ ${#PART_NUMS[@]} -gt 0 ]] || { printf 'Error: no partitions found in input image\n' >&2; exit 1; }
}
collect_gpt_metadata() {
local part_num info_line type_guid unique_guid name
for part_num in "${PART_NUMS[@]}"; do
info_line=$(sgdisk -i "$part_num" "$SOURCE_DEV")
type_guid=$(printf '%s\n' "$info_line" | awk -F': ' '/Partition GUID code:/ { split($2, a, " "); print a[1] }')
unique_guid=$(printf '%s\n' "$info_line" | awk -F': ' '/Partition unique GUID:/ { print $2 }')
name=$(printf '%s\n' "$info_line" | awk -F': ' '/Partition name:/ { gsub(/^\047|\047$/, "", $2); print $2 }')
[[ -n "$type_guid" ]] || { printf 'Error: failed to read GPT type GUID for partition %s\n' "$part_num" >&2; exit 1; }
[[ -n "$unique_guid" ]] || { printf 'Error: failed to read GPT unique GUID for partition %s\n' "$part_num" >&2; exit 1; }
GPT_TYPE_GUIDS+=("$type_guid")
GPT_UNIQUE_GUIDS+=("$unique_guid")
GPT_NAMES+=("$name")
done
}
collect_mbr_metadata() {
local json part_count i type start size bootable part_type
json=$(sfdisk --json "$SOURCE_DEV")
part_count=$(printf '%s\n' "$json" | jq '.partitiontable.partitions | length')
MBR_DISK_ID=$(printf '%s\n' "$json" | jq -r '.partitiontable.id // ""')
[[ "$part_count" -eq "${#PART_NUMS[@]}" ]] || {
printf 'Error: sfdisk partition count mismatch\n' >&2
exit 1
}
for ((i = 0; i < part_count; i++)); do
type=$(printf '%s\n' "$json" | jq -r ".partitiontable.partitions[$i].type // \"\"")
start=$(printf '%s\n' "$json" | jq -r ".partitiontable.partitions[$i].start // 0")
size=$(printf '%s\n' "$json" | jq -r ".partitiontable.partitions[$i].size // 0")
bootable=$(printf '%s\n' "$json" | jq -r ".partitiontable.partitions[$i].bootable // false")
part_type=$(printf '%s' "$type" | tr '[:upper:]' '[:lower:]')
case "$part_type" in
5|0x5|05|0x05|f|0xf|0f|0x0f|85|0x85)
printf 'Error: MBR extended/logical partitions are not supported\n' >&2
exit 1
;;
esac
if [[ "$start" -eq 0 || "$size" -eq 0 ]]; then
printf 'Error: invalid MBR metadata for partition %s\n' "${PART_NUMS[$i]}" >&2
exit 1
fi
MBR_TYPE_CODES+=("$type")
MBR_BOOTABLES+=("$bootable")
done
}
compute_new_layout() {
local label_type=$1
local input_size current_offset new_start new_end new_size i final_size
input_size=$(stat -c '%s' "$INPUT_IMG")
current_offset=$ALIGN_BYTES
for ((i = 0; i < ${#PART_NUMS[@]}; i++)); do
new_start=$(align_up "$current_offset" "$ALIGN_BYTES")
new_size=$(align_up "${PART_SIZES[$i]}" "$SECTOR_SIZE")
new_end=$((new_start + new_size - 1))
if (( new_start % SECTOR_SIZE != 0 )); then
printf 'Error: computed partition %s start is not 4K aligned\n' "${PART_NUMS[$i]}" >&2
exit 1
fi
NEW_START_BYTES+=("$new_start")
NEW_END_BYTES+=("$new_end")
NEW_SIZES+=("$new_size")
NEW_START_LBAS+=("$((new_start / SECTOR_SIZE))")
NEW_END_LBAS+=("$((new_end / SECTOR_SIZE))")
current_offset=$((new_end + 1))
done
final_size=$(align_up "$current_offset" "$ALIGN_BYTES")
if [[ "$label_type" == "gpt" ]]; then
final_size=$(align_up $((final_size + 33 * SECTOR_SIZE)) "$ALIGN_BYTES")
fi
if (( final_size < input_size )); then
final_size=$(align_up "$input_size" "$ALIGN_BYTES")
fi
COMPUTED_OUTPUT_SIZE=$final_size
}
create_output_image() {
local output_size=$1
if [[ -e "$OUTPUT_IMG" ]]; then
rm -f "$OUTPUT_IMG"
fi
truncate -s "$output_size" "$OUTPUT_IMG"
}
attach_output_loop() {
[[ -z "$OUTPUT_LOOP" ]] || return 0
OUTPUT_LOOP=$(losetup --find --show --sector-size "$SECTOR_SIZE" "$OUTPUT_IMG")
}
run_sfdisk() {
local stderr_file
local output
stderr_file=$(mktemp)
if ! output=$(sfdisk --no-reread "$OUTPUT_LOOP" 2>"$stderr_file"); then
cat "$stderr_file" >&2
rm -f "$stderr_file"
return 1
fi
if [[ -s "$stderr_file" ]]; then
if ! grep -q '^重新读取分区表失败' "$stderr_file" && ! grep -q '^Re-reading the partition table failed' "$stderr_file"; then
cat "$stderr_file" >&2
fi
fi
rm -f "$stderr_file"
printf '%s' "$output"
}
build_gpt() {
local i part_num name type_guid unique_guid start_lba end_lba
sgdisk --clear "$OUTPUT_LOOP" >/dev/null
for ((i = 0; i < ${#PART_NUMS[@]}; i++)); do
part_num=${PART_NUMS[$i]}
name=${GPT_NAMES[$i]}
type_guid=${GPT_TYPE_GUIDS[$i]}
unique_guid=${GPT_UNIQUE_GUIDS[$i]}
start_lba=${NEW_START_LBAS[$i]}
end_lba=${NEW_END_LBAS[$i]}
sgdisk \
--new="${part_num}:${start_lba}:${end_lba}" \
--typecode="${part_num}:${type_guid}" \
--change-name="${part_num}:${name}" \
--partition-guid="${part_num}:${unique_guid}" \
"$OUTPUT_LOOP" >/dev/null
done
sgdisk --verify "$OUTPUT_LOOP" >/dev/null
}
build_mbr() {
local script="label: dos\nunit: sectors\n"
local i start_lba size_lba line type bootable
if [[ -n "$MBR_DISK_ID" && "$MBR_DISK_ID" != "null" ]]; then
script+="label-id: ${MBR_DISK_ID}\n"
fi
for ((i = 0; i < ${#PART_NUMS[@]}; i++)); do
start_lba=${NEW_START_LBAS[$i]}
size_lba=$((NEW_SIZES[$i] / SECTOR_SIZE))
type=${MBR_TYPE_CODES[$i]}
bootable=${MBR_BOOTABLES[$i]}
line="start=${start_lba}, size=${size_lba}, type=${type}"
if [[ "$bootable" == "true" ]]; then
line+=" , bootable"
fi
script+="${line}\n"
done
printf '%b' "$script" | run_sfdisk >/dev/null
}
copy_partition_data() {
local i
for ((i = 0; i < ${#PART_NUMS[@]}; i++)); do
printf 'Copying partition %s/%s\n' "$((i + 1))" "${#PART_NUMS[@]}"
dd \
if="$INPUT_IMG" \
of="$OUTPUT_IMG" \
bs="$TRANSFER_BLOCK_SIZE" \
iflag=skip_bytes,count_bytes \
oflag=seek_bytes \
skip="${PART_START_BYTES[$i]}" \
count="${PART_SIZES[$i]}" \
seek="${NEW_START_BYTES[$i]}" \
conv=notrunc \
status=progress
done
}
verify_common_metadata() {
local label_type=$1
local line idx=0 number start_field size_field start_bytes size_bytes fs_field
while IFS= read -r line; do
[[ $line == [0-9]*:* ]] || continue
IFS=: read -r number start_field _ size_field fs_field _ <<<"$line"
start_bytes=${start_field%B}
size_bytes=${size_field%B}
[[ "$number" == "${PART_NUMS[$idx]}" ]] || { printf 'Error: output partition numbering mismatch\n' >&2; exit 1; }
[[ "$start_bytes" == "${NEW_START_BYTES[$idx]}" ]] || { printf 'Error: output partition start mismatch for partition %s\n' "$number" >&2; exit 1; }
[[ "$size_bytes" == "${NEW_SIZES[$idx]}" ]] || { printf 'Error: output partition size mismatch for partition %s\n' "$number" >&2; exit 1; }
if (( start_bytes % SECTOR_SIZE != 0 )); then
printf 'Error: output partition %s start is not 4K aligned\n' "$number" >&2
exit 1
fi
((idx += 1))
done < <(parted -m -s "$OUTPUT_LOOP" unit B print)
[[ $idx -eq ${#PART_NUMS[@]} ]] || { printf 'Error: output partition count mismatch\n' >&2; exit 1; }
if [[ "$label_type" == "gpt" ]]; then
sgdisk --verify "$OUTPUT_LOOP" >/dev/null
fi
}
verify_gpt_metadata() {
local i part_num info_line type_guid unique_guid name
for ((i = 0; i < ${#PART_NUMS[@]}; i++)); do
part_num=${PART_NUMS[$i]}
info_line=$(sgdisk -i "$part_num" "$OUTPUT_LOOP")
type_guid=$(printf '%s\n' "$info_line" | awk -F': ' '/Partition GUID code:/ { split($2, a, " "); print a[1] }')
unique_guid=$(printf '%s\n' "$info_line" | awk -F': ' '/Partition unique GUID:/ { print $2 }')
name=$(printf '%s\n' "$info_line" | awk -F': ' '/Partition name:/ { gsub(/^\047|\047$/, "", $2); print $2 }')
[[ "$type_guid" == "${GPT_TYPE_GUIDS[$i]}" ]] || { printf 'Error: GPT type GUID mismatch for partition %s\n' "$part_num" >&2; exit 1; }
[[ "$unique_guid" == "${GPT_UNIQUE_GUIDS[$i]}" ]] || { printf 'Error: GPT unique GUID mismatch for partition %s\n' "$part_num" >&2; exit 1; }
[[ "$name" == "${GPT_NAMES[$i]}" ]] || { printf 'Error: GPT name mismatch for partition %s\n' "$part_num" >&2; exit 1; }
done
}
verify_mbr_metadata() {
local json part_count i type bootable
json=$(sfdisk --json "$OUTPUT_LOOP")
part_count=$(printf '%s\n' "$json" | jq '.partitiontable.partitions | length')
[[ "$part_count" -eq "${#PART_NUMS[@]}" ]] || { printf 'Error: output MBR partition count mismatch\n' >&2; exit 1; }
for ((i = 0; i < part_count; i++)); do
type=$(printf '%s\n' "$json" | jq -r ".partitiontable.partitions[$i].type // \"\"")
bootable=$(printf '%s\n' "$json" | jq -r ".partitiontable.partitions[$i].bootable // false")
[[ "$type" == "${MBR_TYPE_CODES[$i]}" ]] || { printf 'Error: MBR type mismatch for partition %s\n' "${PART_NUMS[$i]}" >&2; exit 1; }
[[ "$bootable" == "${MBR_BOOTABLES[$i]}" ]] || { printf 'Error: MBR boot flag mismatch for partition %s\n' "${PART_NUMS[$i]}" >&2; exit 1; }
done
}
sha256_partition_region() {
local image=$1
local start=$2
local size=$3
dd if="$image" bs="$TRANSFER_BLOCK_SIZE" iflag=skip_bytes,count_bytes skip="$start" count="$size" status=progress \
| sha256sum | awk '{print $1}'
}
verify_partition_content() {
local i input_hash output_hash
for ((i = 0; i < ${#PART_NUMS[@]}; i++)); do
printf 'Verifying partition %s/%s (input)\n' "$((i + 1))" "${#PART_NUMS[@]}"
input_hash=$(sha256_partition_region "$INPUT_IMG" "${PART_START_BYTES[$i]}" "${PART_SIZES[$i]}")
printf 'Verifying partition %s/%s (output)\n' "$((i + 1))" "${#PART_NUMS[@]}"
output_hash=$(sha256_partition_region "$OUTPUT_IMG" "${NEW_START_BYTES[$i]}" "${PART_SIZES[$i]}")
[[ "$input_hash" == "$output_hash" ]] || {
printf 'Error: content hash mismatch for partition %s\n' "${PART_NUMS[$i]}" >&2
exit 1
}
done
}
print_partition_layout() {
local i
printf 'Partitions:\n'
for ((i = 0; i < ${#PART_NUMS[@]}; i++)); do
printf ' %s: %s -> %s, size %s' \
"${PART_NUMS[$i]}" \
"$(format_bytes "${PART_START_BYTES[$i]}")" \
"$(format_bytes "${NEW_START_BYTES[$i]}")" \
"$(format_bytes "${PART_SIZES[$i]}")"
if [[ "${NEW_SIZES[$i]}" != "${PART_SIZES[$i]}" ]]; then
printf ', padded to %s' "$(format_bytes "${NEW_SIZES[$i]}")"
fi
printf '\n'
done
}
print_analysis() {
local label_type=$1
printf 'Input image: %s\n' "$INPUT_IMG"
printf 'Label type: %s\n' "$label_type"
printf 'Input sector: %s\n' "$INPUT_SECTOR_SIZE"
printf 'Target sector:%s\n' "$SECTOR_SIZE"
printf 'Output size: %s\n' "$(format_bytes "$COMPUTED_OUTPUT_SIZE")"
printf 'Mode: analyze-only\n\n'
print_partition_layout
}
print_summary() {
local label_type=$1
printf 'Input image: %s\n' "$INPUT_IMG"
printf 'Output image: %s\n' "$OUTPUT_IMG"
printf 'Label type: %s\n' "$label_type"
printf 'Input sector: %s\n' "$INPUT_SECTOR_SIZE"
printf 'Target sector:%s\n' "$SECTOR_SIZE"
printf 'Output size: %s\n\n' "$(format_bytes "$COMPUTED_OUTPUT_SIZE")"
print_partition_layout
printf '\nVerification: OK\n'
}
main() {
local label_type
parse_args "$@"
require_commands
ensure_privileges "$@"
reset_partition_state
SOURCE_DEV=$INPUT_IMG
if [[ $ANALYZE_ONLY -eq 1 ]]; then
select_analysis_source
fi
label_type=$(detect_label_type)
collect_common_partitions
case "$label_type" in
gpt)
collect_gpt_metadata
;;
msdos)
collect_mbr_metadata
;;
esac
compute_new_layout "$label_type"
if [[ $ANALYZE_ONLY -eq 1 ]]; then
print_analysis "$label_type"
return
fi
create_output_image "$COMPUTED_OUTPUT_SIZE"
attach_output_loop
case "$label_type" in
gpt)
build_gpt
;;
msdos)
build_mbr
;;
esac
copy_partition_data
verify_common_metadata "$label_type"
case "$label_type" in
gpt)
verify_gpt_metadata
;;
msdos)
verify_mbr_metadata
;;
esac
verify_partition_content
print_summary "$label_type"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment