// Command rescueusb is a pre-flight bootability verifier for ISO disc images.
//
// Before an image is written to a USB stick, three questions matter: did the
// download arrive intact, is the image actually bootable, and on which kind of
// firmware. rescueusb answers all three by reading the raw bytes of the image:
// it hashes the file and compares against published checksums, and it decodes
// the El Torito Boot Record Volume Descriptor and the boot catalog it points
// at - the structures that make an ISO bootable at all - including verifying
// the boot catalog's own 16-bit validation checksum.
//
// The tool is strictly read-only. It opens images with os.Open, never
// os.Create, and it does not touch block devices.
package main

import (
	"bufio"
	"crypto/md5"
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"hash"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"text/tabwriter"
)

const (
	// sectorSize is the ISO 9660 logical sector size. Volume descriptors and
	// the El Torito boot catalog are always addressed in 2048-byte sectors.
	sectorSize = 2048
	// pvdSector holds the first volume descriptor (byte offset 32768).
	pvdSector = 16
	// bootRecordSector is where the El Torito Boot Record Volume Descriptor is
	// placed in practice: sector 17, byte offset 34816.
	bootRecordSector = 17
	// maxDescriptors bounds the volume descriptor set scan.
	maxDescriptors = 64
	// catalogEntrySize is the fixed size of every boot catalog entry.
	catalogEntrySize = 32
	// maxCatalogEntries bounds boot catalog parsing so a corrupt image cannot
	// make the tool loop or allocate without limit.
	maxCatalogEntries = 256
	// virtualSector is the 512-byte "virtual sector" unit El Torito uses for
	// its sector count field.
	virtualSector = 512
	// hashChunk is the streaming read size used when hashing an image.
	hashChunk = 1 << 20
	// maxChecksumFile bounds how much of a checksum file is read.
	maxChecksumFile = 4 << 20

	elToritoID = "EL TORITO SPECIFICATION"

	platX86  = 0x00
	platPPC  = 0x01
	platMac  = 0x02
	platEFI  = 0xEF
	bootable = 0x88
)

// ---------------------------------------------------------------------------
// Shared Techlosoft CLI helpers
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Exit codes
// ---------------------------------------------------------------------------

// exitCode is returned by a command that produced a complete report but wants
// a non-zero exit status: a checksum mismatch, a failing pre-flight check, or
// an image that will not boot. main prints nothing extra for it.
type exitCode int

func (e exitCode) Error() string { return fmt.Sprintf("exit status %d", int(e)) }

const (
	// codeProblem means the report is valid but the answer is "no".
	codeProblem exitCode = 2
)

// ---------------------------------------------------------------------------
// Raw image access
// ---------------------------------------------------------------------------

// image is an open disc image file. Every read is bounds-checked against the
// real file size so that a truncated or hostile file yields an error rather
// than a panic.
type image struct {
	f    *os.File
	name string
	size int64
}

func openImage(name string) (*image, error) {
	f, err := os.Open(name)
	if err != nil {
		return nil, err
	}
	st, err := f.Stat()
	if err != nil {
		f.Close()
		return nil, err
	}
	if st.IsDir() {
		f.Close()
		return nil, fmt.Errorf("%s: is a directory, not a disc image", name)
	}
	if !st.Mode().IsRegular() {
		f.Close()
		return nil, fmt.Errorf("%s: not a regular file; rescueusb reads image files, not devices", name)
	}
	return &image{f: f, name: name, size: st.Size()}, nil
}

func (im *image) Close() error { return im.f.Close() }

func (im *image) readAt(off int64, n int) ([]byte, error) {
	if off < 0 || n < 0 {
		return nil, fmt.Errorf("invalid read of %d bytes at offset %d", n, off)
	}
	if off > im.size || int64(n) > im.size-off {
		return nil, fmt.Errorf("read of %d bytes at offset %d runs past the end of the file (file is %d bytes)", n, off, im.size)
	}
	buf := make([]byte, n)
	if _, err := io.ReadFull(io.NewSectionReader(im.f, off, int64(n)), buf); err != nil {
		return nil, err
	}
	return buf, nil
}

func le16(b []byte, off int) uint16 { return binary.LittleEndian.Uint16(b[off:]) }
func le32(b []byte, off int) uint32 { return binary.LittleEndian.Uint32(b[off:]) }

// strField trims the space padding ISO 9660 uses for its text fields.
func strField(b []byte, off, n int) string {
	if off+n > len(b) {
		return ""
	}
	return strings.TrimRight(string(b[off:off+n]), " \x00")
}

// ---------------------------------------------------------------------------
// ISO 9660 primary volume descriptor (only the few fields we need)
// ---------------------------------------------------------------------------

type volumeInfo struct {
	Present    bool
	VolumeID   string
	SystemID   string
	BlockSize  int64
	SpaceSize  uint32
	DeclaredSz int64
}

// readVolume decodes just enough of the Primary Volume Descriptor to name the
// volume and learn how large it claims to be. Directory walking is out of
// scope for this tool.
func (im *image) readVolume() (volumeInfo, error) {
	var v volumeInfo
	if im.size < int64(pvdSector+1)*sectorSize {
		return v, fmt.Errorf("%s: too small to be an ISO 9660 image: %d bytes (%s); a volume descriptor must exist at offset %d",
			im.name, im.size, humanBytes(im.size), pvdSector*sectorSize)
	}
	for i := 0; i < maxDescriptors; i++ {
		off := int64(pvdSector+i) * sectorSize
		if off+sectorSize > im.size {
			break
		}
		d, err := im.readAt(off, sectorSize)
		if err != nil {
			return v, fmt.Errorf("%s: %w", im.name, err)
		}
		if string(d[1:6]) != "CD001" {
			if i == 0 {
				return v, fmt.Errorf("%s: not an ISO 9660 image: no \"CD001\" signature at offset %d", im.name, pvdSector*sectorSize)
			}
			break
		}
		switch d[0] {
		case 1: // Primary Volume Descriptor
			v.Present = true
			v.SystemID = strField(d, 8, 32)
			v.VolumeID = strField(d, 40, 32)
			v.SpaceSize = le32(d, 80)
			v.BlockSize = int64(le16(d, 128))
			if v.BlockSize > 0 {
				v.DeclaredSz = int64(v.SpaceSize) * v.BlockSize
			}
			return v, nil
		case 255: // set terminator
			return v, fmt.Errorf("%s: ISO 9660 volume descriptor set has no Primary Volume Descriptor", im.name)
		}
	}
	return v, fmt.Errorf("%s: ISO 9660 volume descriptor set has no Primary Volume Descriptor", im.name)
}

// ---------------------------------------------------------------------------
// El Torito
// ---------------------------------------------------------------------------

// bootRecord is the El Torito Boot Record Volume Descriptor.
type bootRecord struct {
	Present      bool   `json:"present"`
	Sector       int64  `json:"sector"`
	Offset       int64  `json:"offset"`
	Type         int    `json:"descriptor_type"`
	StandardID   string `json:"standard_id"`
	Version      int    `json:"version"`
	BootSystemID string `json:"boot_system_id"`
	IsElTorito   bool   `json:"el_torito"`
	CatalogLBA   uint32 `json:"catalog_lba"`
	CatalogOff   int64  `json:"catalog_offset"`
}

// validationEntry is the first 32 bytes of the boot catalog. Its 16-bit
// checksum is what proves the catalog was written deliberately and intact.
type validationEntry struct {
	Offset     int64  `json:"offset"`
	HeaderID   int    `json:"header_id"`
	PlatformID int    `json:"platform_id"`
	Platform   string `json:"platform"`
	IDString   string `json:"id_string"`
	Checksum   uint16 `json:"checksum_word"`
	Key55      int    `json:"key_byte_55"`
	KeyAA      int    `json:"key_byte_aa"`
	KeyOK      bool   `json:"key_bytes_ok"`
	Sum        uint16 `json:"sum_mod_65536"`
	Valid      bool   `json:"checksum_valid"`
}

// catalogEntry is a default/initial entry or a section entry.
type catalogEntry struct {
	Kind        string `json:"kind"`
	Offset      int64  `json:"offset"`
	Section     int    `json:"section"`
	PlatformID  int    `json:"platform_id"`
	Platform    string `json:"platform"`
	BootInd     int    `json:"boot_indicator"`
	Bootable    bool   `json:"bootable"`
	MediaType   int    `json:"media_type"`
	Media       string `json:"media"`
	MediaFlags  string `json:"media_flags,omitempty"`
	LoadSegment uint16 `json:"load_segment"`
	LoadSegEff  uint32 `json:"load_segment_effective"`
	SystemType  int    `json:"system_type"`
	SectorCount uint16 `json:"sector_count"`
	ImageBytes  int64  `json:"image_bytes"`
	LoadRBA     uint32 `json:"load_rba"`
	LoadOffset  int64  `json:"load_offset"`
	InRange     bool   `json:"load_offset_within_file"`
	SelCriteria int    `json:"selection_criteria,omitempty"`
}

// sectionHeader introduces a group of section entries for one platform.
type sectionHeader struct {
	Index      int    `json:"index"`
	Offset     int64  `json:"offset"`
	HeaderID   int    `json:"header_id"`
	Final      bool   `json:"final"`
	PlatformID int    `json:"platform_id"`
	Platform   string `json:"platform"`
	NumEntries int    `json:"entry_count"`
	IDString   string `json:"id_string"`
}

// bootAnalysis is the whole El Torito picture for one image.
type bootAnalysis struct {
	Record     bootRecord       `json:"boot_record"`
	Validation *validationEntry `json:"validation_entry,omitempty"`
	Sections   []sectionHeader  `json:"section_headers"`
	Entries    []catalogEntry   `json:"entries"`
	Notes      []string         `json:"notes,omitempty"`
	CatalogErr string           `json:"catalog_error,omitempty"`
}

func platformName(id int) string {
	switch id {
	case platX86:
		return "80x86 (BIOS)"
	case platPPC:
		return "PowerPC"
	case platMac:
		return "Mac"
	case platEFI:
		return "EFI (UEFI)"
	default:
		return "unknown"
	}
}

func mediaName(t int) string {
	switch t {
	case 0:
		return "no emulation"
	case 1:
		return "1.2 MB floppy emulation"
	case 2:
		return "1.44 MB floppy emulation"
	case 3:
		return "2.88 MB floppy emulation"
	case 4:
		return "hard disk emulation (drive 80h)"
	default:
		return "reserved/unknown"
	}
}

// findBootRecord looks for the El Torito Boot Record Volume Descriptor. It is
// specified to live in the volume descriptor set and in practice always sits
// at sector 17; that slot is checked first, then the rest of the set.
func (im *image) findBootRecord() (bootRecord, error) {
	var br bootRecord
	order := []int64{bootRecordSector}
	for i := int64(pvdSector); i < pvdSector+maxDescriptors; i++ {
		if i != bootRecordSector {
			order = append(order, i)
		}
	}
	for _, sec := range order {
		off := sec * sectorSize
		if off+sectorSize > im.size {
			continue
		}
		d, err := im.readAt(off, sectorSize)
		if err != nil {
			continue
		}
		if string(d[1:6]) != "CD001" || d[0] != 0 {
			continue
		}
		br.Present = true
		br.Sector = sec
		br.Offset = off
		br.Type = int(d[0])
		br.StandardID = string(d[1:6])
		br.Version = int(d[6])
		br.BootSystemID = strField(d, 7, 32)
		br.IsElTorito = br.BootSystemID == elToritoID
		br.CatalogLBA = le32(d, 0x47)
		br.CatalogOff = int64(br.CatalogLBA) * sectorSize
		return br, nil
	}
	return br, nil
}

// checksum16 sums every 16-bit little-endian word of the 32-byte validation
// entry. El Torito requires the total to be zero modulo 0x10000; the checksum
// field itself is chosen by the mastering tool to make that true.
func checksum16(b []byte) uint16 {
	var sum uint16
	for i := 0; i+1 < len(b); i += 2 {
		sum += le16(b, i)
	}
	return sum
}

// analyzeBoot decodes the boot record and, if present, the whole boot catalog.
// Structural damage inside the catalog is recorded as an error string on the
// analysis rather than returned, so callers still get the boot record report.
func (im *image) analyzeBoot() (*bootAnalysis, error) {
	br, err := im.findBootRecord()
	if err != nil {
		return nil, err
	}
	a := &bootAnalysis{Record: br}
	if !br.Present {
		if im.size < int64(bootRecordSector+1)*sectorSize {
			a.Notes = append(a.Notes, fmt.Sprintf(
				"the file is %d bytes and ends before sector %d is complete (that sector spans offsets %d-%d), so a boot record could not be there even if the original image had one",
				im.size, bootRecordSector, bootRecordSector*sectorSize, (bootRecordSector+1)*sectorSize-1))
		}
		return a, nil
	}
	if !br.IsElTorito {
		a.Notes = append(a.Notes, fmt.Sprintf("boot record found at sector %d but its boot system identifier is %q, not %q; this is not an El Torito boot record",
			br.Sector, br.BootSystemID, elToritoID))
		return a, nil
	}
	if br.CatalogLBA == 0 {
		a.CatalogErr = "the boot record points at boot catalog LBA 0, which cannot be right"
		return a, nil
	}
	if br.CatalogOff+catalogEntrySize > im.size {
		a.CatalogErr = fmt.Sprintf("boot catalog at LBA %d (offset %d) lies past the end of the file (%d bytes) - the image is truncated",
			br.CatalogLBA, br.CatalogOff, im.size)
		return a, nil
	}

	// Read as much of the catalog as the file actually holds, capped.
	want := int64(maxCatalogEntries * catalogEntrySize)
	if avail := im.size - br.CatalogOff; avail < want {
		want = avail
	}
	cat, err := im.readAt(br.CatalogOff, int(want))
	if err != nil {
		a.CatalogErr = err.Error()
		return a, nil
	}

	ve := cat[0:catalogEntrySize]
	v := &validationEntry{
		Offset:     br.CatalogOff,
		HeaderID:   int(ve[0]),
		PlatformID: int(ve[1]),
		IDString:   strings.TrimRight(string(ve[4:28]), " \x00"),
		Checksum:   le16(ve, 28),
		Key55:      int(ve[30]),
		KeyAA:      int(ve[31]),
	}
	v.Platform = platformName(v.PlatformID)
	v.KeyOK = ve[30] == 0x55 && ve[31] == 0xAA
	v.Sum = checksum16(ve)
	v.Valid = v.Sum == 0 && v.KeyOK && v.HeaderID == 1
	a.Validation = v
	if v.HeaderID != 1 {
		a.Notes = append(a.Notes, fmt.Sprintf("validation entry header ID is %d, expected 1", v.HeaderID))
	}
	if !v.KeyOK {
		a.Notes = append(a.Notes, fmt.Sprintf("validation entry key bytes are 0x%02X 0x%02X, expected 0x55 0xAA", v.Key55, v.KeyAA))
	}
	if v.Sum != 0 {
		a.Notes = append(a.Notes, fmt.Sprintf("validation entry 16-bit checksum does not settle to zero (sum is 0x%04X) - the boot catalog is corrupt", v.Sum))
	}

	// The default (initial) entry follows the validation entry and inherits
	// the validation entry's platform.
	if int64(2*catalogEntrySize) <= int64(len(cat)) {
		e := decodeEntry(cat[catalogEntrySize:2*catalogEntrySize], br.CatalogOff+catalogEntrySize, im.size, false)
		e.Kind = "default"
		e.PlatformID = v.PlatformID
		e.Platform = v.Platform
		a.Entries = append(a.Entries, e)
	} else {
		a.CatalogErr = "the file ends before the boot catalog's default entry"
		return a, nil
	}

	// Everything after that is section headers and their section entries.
	pos := 2 * catalogEntrySize
	sectionIdx := 0
	remaining := 0
	curPlat := 0
	done := false
	for iter := 0; iter < maxCatalogEntries && pos+catalogEntrySize <= len(cat) && !done; iter++ {
		b := cat[pos : pos+catalogEntrySize]
		off := br.CatalogOff + int64(pos)
		pos += catalogEntrySize
		switch {
		case b[0] == 0x00:
			// End of catalog.
			done = true
		case b[0] == 0x90 || b[0] == 0x91:
			sectionIdx++
			sh := sectionHeader{
				Index:      sectionIdx,
				Offset:     off,
				HeaderID:   int(b[0]),
				Final:      b[0] == 0x91,
				PlatformID: int(b[1]),
				Platform:   platformName(int(b[1])),
				NumEntries: int(le16(b, 2)),
				IDString:   strings.TrimRight(string(b[4:32]), " \x00"),
			}
			a.Sections = append(a.Sections, sh)
			remaining = sh.NumEntries
			curPlat = sh.PlatformID
			if remaining > maxCatalogEntries {
				a.Notes = append(a.Notes, fmt.Sprintf("section header %d claims %d entries, which is implausible; only the entries that fit are read", sectionIdx, remaining))
				remaining = maxCatalogEntries
			}
			if remaining == 0 && sh.Final {
				done = true
			}
		case b[0] == 0x44:
			// Section entry extension - annotates the preceding entry.
			a.Notes = append(a.Notes, fmt.Sprintf("section entry extension (0x44) at offset %d", off))
		default:
			if remaining <= 0 {
				a.Notes = append(a.Notes, fmt.Sprintf("unexpected catalog entry 0x%02X at offset %d; stopping catalog scan", b[0], off))
				done = true
				break
			}
			e := decodeEntry(b, off, im.size, true)
			e.Kind = "section"
			e.Section = sectionIdx
			e.PlatformID = curPlat
			e.Platform = platformName(curPlat)
			a.Entries = append(a.Entries, e)
			remaining--
			if remaining == 0 && len(a.Sections) > 0 && a.Sections[len(a.Sections)-1].Final {
				done = true
			}
		}
	}
	return a, nil
}

// decodeEntry decodes one 32-byte initial or section entry.
func decodeEntry(b []byte, off, fileSize int64, section bool) catalogEntry {
	e := catalogEntry{
		Offset:      off,
		BootInd:     int(b[0]),
		Bootable:    b[0] == bootable,
		LoadSegment: le16(b, 2),
		SystemType:  int(b[4]),
		SectorCount: le16(b, 6),
		LoadRBA:     le32(b, 8),
	}
	mt := int(b[1])
	if section {
		e.MediaType = mt & 0x0F
		var flags []string
		if mt&0x20 != 0 {
			flags = append(flags, "continuation entry follows")
		}
		if mt&0x40 != 0 {
			flags = append(flags, "image contains ATAPI driver")
		}
		if mt&0x80 != 0 {
			flags = append(flags, "image contains SCSI driver")
		}
		e.MediaFlags = strings.Join(flags, ", ")
		e.SelCriteria = int(b[12])
	} else {
		e.MediaType = mt
	}
	e.Media = mediaName(e.MediaType)
	e.LoadSegEff = uint32(e.LoadSegment)
	if e.LoadSegment == 0 {
		// El Torito: a load segment of 0 means the traditional 0x7C0.
		e.LoadSegEff = 0x07C0
	}
	e.ImageBytes = int64(e.SectorCount) * virtualSector
	e.LoadOffset = int64(e.LoadRBA) * sectorSize
	e.InRange = e.LoadOffset >= 0 && e.LoadOffset < fileSize
	return e
}

// verdict summarises which firmwares can boot the image.
type verdict struct {
	BIOS     bool   `json:"bios_bootable"`
	UEFI     bool   `json:"uefi_bootable"`
	Other    bool   `json:"other_platform_bootable"`
	Catalog  bool   `json:"catalog_present"`
	Checksum bool   `json:"catalog_checksum_valid"`
	Summary  string `json:"verdict"`
	BIOSWhy  string `json:"bios_reason"`
	UEFIWhy  string `json:"uefi_reason"`
}

func (a *bootAnalysis) verdict() verdict {
	var v verdict
	v.Catalog = a.Validation != nil
	if a.Validation != nil {
		v.Checksum = a.Validation.Valid
	}
	v.BIOSWhy = "no bootable entry for platform 0x00 (80x86)"
	v.UEFIWhy = "no bootable entry for platform 0xEF (EFI)"
	for _, e := range a.Entries {
		if !e.Bootable {
			continue
		}
		switch e.PlatformID {
		case platX86:
			v.BIOS = true
			v.BIOSWhy = fmt.Sprintf("%s entry at offset %d, %s, %d virtual sectors from LBA %d",
				e.Kind, e.Offset, e.Media, e.SectorCount, e.LoadRBA)
		case platEFI:
			v.UEFI = true
			v.UEFIWhy = fmt.Sprintf("%s entry at offset %d (platform 0xEF), %s, %d virtual sectors from LBA %d",
				e.Kind, e.Offset, e.Media, e.SectorCount, e.LoadRBA)
		default:
			v.Other = true
		}
	}
	switch {
	case !a.Record.Present:
		v.Summary = "NOT BOOTABLE - no El Torito boot record; this is a plain data image"
	case !a.Record.IsElTorito:
		v.Summary = "NOT BOOTABLE - a boot record exists but it is not an El Torito boot record"
	case a.Validation == nil:
		v.Summary = "NOT BOOTABLE - the boot catalog could not be read"
	case v.BIOS && v.UEFI:
		v.Summary = "BOOTABLE on both legacy BIOS and UEFI systems"
	case v.BIOS:
		v.Summary = "BOOTABLE on legacy BIOS only - UEFI-only machines will not boot this image"
	case v.UEFI:
		v.Summary = "BOOTABLE on UEFI only - legacy BIOS machines will not boot this image"
	case v.Other:
		v.Summary = "BOOTABLE only on a non-x86 platform (PowerPC/Mac)"
	default:
		v.Summary = "NOT BOOTABLE - a boot catalog exists but it holds no bootable entry"
	}
	if v.Catalog && !v.Checksum {
		v.Summary += "; WARNING: the boot catalog validation checksum is INVALID"
	}
	return v
}

// ---------------------------------------------------------------------------
// Hashing and checksum files
// ---------------------------------------------------------------------------

// hashImage streams the file once, feeding every requested digest in parallel.
func hashImage(path string, wantMD5 bool) (sha, md string, size int64, err error) {
	f, err := os.Open(path)
	if err != nil {
		return "", "", 0, err
	}
	defer f.Close()
	st, err := f.Stat()
	if err != nil {
		return "", "", 0, err
	}
	if st.IsDir() {
		return "", "", 0, fmt.Errorf("%s: is a directory, not a disc image", path)
	}
	s := sha256.New()
	writers := []io.Writer{s}
	var m hash.Hash
	if wantMD5 {
		m = md5.New()
		writers = append(writers, m)
	}
	if _, err := io.CopyBuffer(io.MultiWriter(writers...), f, make([]byte, hashChunk)); err != nil {
		return "", "", 0, fmt.Errorf("reading %s: %w", path, err)
	}
	sha = hex.EncodeToString(s.Sum(nil))
	if m != nil {
		md = hex.EncodeToString(m.Sum(nil))
	}
	return sha, md, st.Size(), nil
}

// checksumRecord is one parsed line of a published checksum file.
type checksumRecord struct {
	Algorithm string
	Hash      string
	Filename  string
	Line      int
}

func algorithmForLength(n int) string {
	switch n {
	case 32:
		return "md5"
	case 40:
		return "sha1"
	case 64:
		return "sha256"
	case 128:
		return "sha512"
	}
	return ""
}

func isHex(s string) bool {
	if s == "" {
		return false
	}
	for _, c := range s {
		switch {
		case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
		default:
			return false
		}
	}
	return true
}

// parseChecksumFile understands the two formats distributions actually
// publish: the GNU coreutils form "<hash>  <filename>" (the second space is a
// "*" in binary mode) and the BSD tagged form "SHA256 (<filename>) = <hash>".
func parseChecksumFile(path string) ([]checksumRecord, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	var out []checksumRecord
	sc := bufio.NewScanner(io.LimitReader(f, maxChecksumFile))
	sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
	line := 0
	for sc.Scan() {
		line++
		t := strings.TrimSpace(sc.Text())
		if t == "" || strings.HasPrefix(t, "#") {
			continue
		}
		// BSD tagged form.
		if i := strings.Index(t, " ("); i > 0 && strings.Contains(t, ") = ") {
			tag := strings.ToLower(strings.TrimSpace(t[:i]))
			rest := t[i+2:]
			j := strings.Index(rest, ") = ")
			if j < 0 {
				continue
			}
			name := rest[:j]
			h := strings.TrimSpace(rest[j+4:])
			if isHex(h) {
				alg := strings.ReplaceAll(tag, "-", "")
				out = append(out, checksumRecord{Algorithm: alg, Hash: strings.ToLower(h), Filename: name, Line: line})
			}
			continue
		}
		// GNU coreutils form.
		fields := strings.Fields(t)
		if len(fields) < 2 || !isHex(fields[0]) {
			continue
		}
		alg := algorithmForLength(len(fields[0]))
		if alg == "" {
			continue
		}
		name := strings.TrimSpace(t[len(fields[0]):])
		name = strings.TrimLeft(name, " \t")
		name = strings.TrimPrefix(name, "*")
		out = append(out, checksumRecord{Algorithm: alg, Hash: strings.ToLower(fields[0]), Filename: name, Line: line})
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("reading %s: %w", path, err)
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("%s: no checksum lines found; expected \"<hash>  <filename>\" lines", path)
	}
	return out, nil
}

// selectRecords picks the checksum lines that refer to the image being
// checked, matching on base name and falling back to case-insensitive.
func selectRecords(recs []checksumRecord, target string) []checksumRecord {
	base := filepath.Base(target)
	var exact, ci []checksumRecord
	for _, r := range recs {
		rb := filepath.Base(strings.TrimSpace(r.Filename))
		if rb == base {
			exact = append(exact, r)
		} else if strings.EqualFold(rb, base) {
			ci = append(ci, r)
		}
	}
	if len(exact) > 0 {
		return exact
	}
	return ci
}

// ---------------------------------------------------------------------------
// JSON shapes
// ---------------------------------------------------------------------------

type hashCheckJSON struct {
	Algorithm string `json:"algorithm"`
	Expected  string `json:"expected"`
	Actual    string `json:"actual"`
	Source    string `json:"source"`
	Result    string `json:"result"`
}

type verifyJSON struct {
	File      string          `json:"file"`
	FileSize  int64           `json:"file_size"`
	FileHuman string          `json:"file_size_human"`
	SHA256    string          `json:"sha256"`
	MD5       string          `json:"md5,omitempty"`
	Checks    []hashCheckJSON `json:"checks"`
	Result    string          `json:"result"`
	OK        bool            `json:"ok"`
}

type bootJSON struct {
	File      string `json:"file"`
	FileSize  int64  `json:"file_size"`
	FileHuman string `json:"file_size_human"`
	ISO9660   bool   `json:"iso9660"`
	VolumeID  string `json:"volume_id"`
	*bootAnalysis
	verdict
	OK bool `json:"ok"`
}

type checkJSON struct {
	Name   string `json:"name"`
	Status string `json:"status"`
	Detail string `json:"detail"`
}

type preflightJSON struct {
	File      string      `json:"file"`
	FileSize  int64       `json:"file_size"`
	FileHuman string      `json:"file_size_human"`
	VolumeID  string      `json:"volume_id"`
	Checks    []checkJSON `json:"checks"`
	Passed    int         `json:"passed"`
	Warned    int         `json:"warned"`
	Failed    int         `json:"failed"`
	Verdict   string      `json:"verdict"`
	OK        bool        `json:"ok"`
}

func writeJSON(v any) error {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	return enc.Encode(v)
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	fs.Usage = func() {}
	return fs
}

// parseArgs reorders flags after positionals, then parses.
func parseArgs(fs *flag.FlagSet, args []string, valueFlags map[string]bool) error {
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usageTo(os.Stdout)
			os.Exit(0)
		}
		return err
	}
	return nil
}

func cmdVerify(args []string) error {
	fs := newFlagSet("verify")
	wantSHA := fs.String("sha256", "", "expected SHA-256 hash")
	wantMD5 := fs.String("md5", "", "expected MD5 hash")
	sumFile := fs.String("checksum-file", "", "published checksum file to check against")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args, map[string]bool{"sha256": true, "md5": true, "checksum-file": true}); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("verify takes exactly one argument: <file.iso>")
	}
	target := rest[0]

	expSHA := strings.ToLower(strings.TrimSpace(*wantSHA))
	expMD5 := strings.ToLower(strings.TrimSpace(*wantMD5))
	if expSHA != "" && (len(expSHA) != 64 || !isHex(expSHA)) {
		return fmt.Errorf("--sha256 value %q is not a 64-character hex SHA-256 hash", *wantSHA)
	}
	if expMD5 != "" && (len(expMD5) != 32 || !isHex(expMD5)) {
		return fmt.Errorf("--md5 value %q is not a 32-character hex MD5 hash", *wantMD5)
	}

	var fileRecs []checksumRecord
	var fileNotes []string
	if *sumFile != "" {
		all, err := parseChecksumFile(*sumFile)
		if err != nil {
			return err
		}
		fileRecs = selectRecords(all, target)
		if len(fileRecs) == 0 {
			if len(all) == 1 {
				fileRecs = all
				fileNotes = append(fileNotes, fmt.Sprintf("%s names %q, not %q; it holds a single entry so it is used anyway",
					*sumFile, all[0].Filename, filepath.Base(target)))
			} else {
				names := make([]string, 0, len(all))
				for _, r := range all {
					names = append(names, r.Filename)
				}
				return fmt.Errorf("%s has no entry for %q (it lists: %s)", *sumFile, filepath.Base(target), strings.Join(names, ", "))
			}
		}
	}

	needMD5 := expMD5 != ""
	for _, r := range fileRecs {
		if r.Algorithm == "md5" {
			needMD5 = true
		}
	}

	sha, md, size, err := hashImage(target, needMD5)
	if err != nil {
		return err
	}

	var checks []hashCheckJSON
	add := func(alg, exp, act, src string) {
		res := "MISMATCH"
		if exp == act {
			res = "MATCH"
		}
		checks = append(checks, hashCheckJSON{Algorithm: alg, Expected: exp, Actual: act, Source: src, Result: res})
	}
	if expSHA != "" {
		add("sha256", expSHA, sha, "--sha256")
	}
	if expMD5 != "" {
		add("md5", expMD5, md, "--md5")
	}
	for _, r := range fileRecs {
		src := fmt.Sprintf("%s line %d", *sumFile, r.Line)
		switch r.Algorithm {
		case "sha256":
			add("sha256", r.Hash, sha, src)
		case "md5":
			add("md5", r.Hash, md, src)
		default:
			fileNotes = append(fileNotes, fmt.Sprintf("%s line %d is a %s hash; rescueusb checks SHA-256 and MD5 only, so it was skipped",
				*sumFile, r.Line, strings.ToUpper(r.Algorithm)))
		}
	}

	result := "NOT CHECKED"
	ok := true
	if len(checks) > 0 {
		result = "MATCH"
		for _, c := range checks {
			if c.Result != "MATCH" {
				result = "MISMATCH"
				ok = false
			}
		}
	}

	if *asJSON {
		j := verifyJSON{
			File: target, FileSize: size, FileHuman: humanBytes(size),
			SHA256: sha, MD5: md, Checks: checks, Result: result, OK: ok,
		}
		if j.Checks == nil {
			j.Checks = []hashCheckJSON{}
		}
		if err := writeJSON(j); err != nil {
			return err
		}
		if !ok {
			return codeProblem
		}
		return nil
	}

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintf(w, "File:\t%s\n", target)
	fmt.Fprintf(w, "File size:\t%d bytes (%s)\n", size, humanBytes(size))
	fmt.Fprintf(w, "SHA-256:\t%s\n", sha)
	if md != "" {
		fmt.Fprintf(w, "MD5:\t%s\n", md)
	}
	if err := w.Flush(); err != nil {
		return err
	}
	for _, n := range fileNotes {
		fmt.Printf("\nnote: %s\n", n)
	}
	if len(checks) == 0 {
		fmt.Printf("\nRESULT: NOT CHECKED - no expected hash was supplied.\n")
		fmt.Printf("        Pass --sha256 <hash>, --md5 <hash>, or --checksum-file <file> to compare\n")
		fmt.Printf("        this image against the values the publisher announced.\n")
		return nil
	}
	fmt.Println()
	for _, c := range checks {
		fmt.Printf("%-9s %s\n", strings.ToUpper(c.Algorithm)+":", c.Result)
		fmt.Printf("  expected  %s  (from %s)\n", c.Expected, c.Source)
		fmt.Printf("  computed  %s\n", c.Actual)
	}
	fmt.Println()
	if ok {
		fmt.Printf("RESULT: MATCH - every supplied checksum agrees with this file.\n")
		return nil
	}
	fmt.Printf("RESULT: MISMATCH - this file is NOT the one the checksum describes.\n")
	fmt.Printf("        Do not write it to a USB stick. Download it again.\n")
	return codeProblem
}

func cmdBoot(args []string) error {
	fs := newFlagSet("boot")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args, nil); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("boot takes exactly one argument: <file.iso>")
	}
	im, err := openImage(rest[0])
	if err != nil {
		return err
	}
	defer im.Close()

	vol, volErr := im.readVolume()
	a, err := im.analyzeBoot()
	if err != nil {
		return err
	}
	if !vol.Present && !a.Record.Present && volErr != nil {
		// Nothing recognisable at all: report the ISO 9660 problem, which is
		// the more useful message.
		return volErr
	}
	v := a.verdict()
	ok := (v.BIOS || v.UEFI || v.Other) && (!v.Catalog || v.Checksum)

	if *asJSON {
		j := bootJSON{
			File: im.name, FileSize: im.size, FileHuman: humanBytes(im.size),
			ISO9660: vol.Present, VolumeID: vol.VolumeID,
			bootAnalysis: a, verdict: v, OK: ok,
		}
		if j.Sections == nil {
			j.Sections = []sectionHeader{}
		}
		if j.Entries == nil {
			j.Entries = []catalogEntry{}
		}
		if err := writeJSON(j); err != nil {
			return err
		}
		if !ok {
			return codeProblem
		}
		return nil
	}

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintf(w, "File:\t%s\n", im.name)
	fmt.Fprintf(w, "File size:\t%d bytes (%s)\n", im.size, humanBytes(im.size))
	if vol.Present {
		fmt.Fprintf(w, "Volume identifier:\t%s\n", vol.VolumeID)
	} else if volErr != nil {
		fmt.Fprintf(w, "ISO 9660:\t%v\n", volErr)
	}
	if err := w.Flush(); err != nil {
		return err
	}

	fmt.Printf("\nBOOT RECORD VOLUME DESCRIPTOR\n")
	if !a.Record.Present {
		fmt.Printf("  none - no volume descriptor of type 0 in the descriptor set\n")
		fmt.Printf("  (sector %d, offset %d, is where El Torito puts it)\n", bootRecordSector, bootRecordSector*sectorSize)
	} else {
		w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintf(w, "  Location:\tsector %d (offset %d)\n", a.Record.Sector, a.Record.Offset)
		fmt.Fprintf(w, "  Descriptor type:\t%d (boot record)\n", a.Record.Type)
		fmt.Fprintf(w, "  Standard identifier:\t%q\n", a.Record.StandardID)
		fmt.Fprintf(w, "  Version:\t%d\n", a.Record.Version)
		mark := "  <- not El Torito"
		if a.Record.IsElTorito {
			mark = ""
		}
		fmt.Fprintf(w, "  Boot system identifier:\t%q%s\n", a.Record.BootSystemID, mark)
		fmt.Fprintf(w, "  Boot catalog pointer:\tLBA %d (offset %d)\n", a.Record.CatalogLBA, a.Record.CatalogOff)
		if err := w.Flush(); err != nil {
			return err
		}
	}

	if a.CatalogErr != "" {
		fmt.Printf("\nBOOT CATALOG\n  unreadable: %s\n", a.CatalogErr)
	}
	if a.Validation != nil {
		v := a.Validation
		fmt.Printf("\nBOOT CATALOG VALIDATION ENTRY (offset %d)\n", v.Offset)
		w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintf(w, "  Header ID:\t%d\n", v.HeaderID)
		fmt.Fprintf(w, "  Platform ID:\t0x%02X (%s)\n", v.PlatformID, v.Platform)
		fmt.Fprintf(w, "  ID string:\t%q\n", v.IDString)
		keyState := "OK"
		if !v.KeyOK {
			keyState = "WRONG (expected 0x55 0xAA)"
		}
		fmt.Fprintf(w, "  Key bytes:\t0x%02X 0x%02X  %s\n", v.Key55, v.KeyAA, keyState)
		fmt.Fprintf(w, "  Checksum word:\t0x%04X\n", v.Checksum)
		sumState := "VALID"
		if v.Sum != 0 {
			sumState = "INVALID"
		}
		fmt.Fprintf(w, "  Sum of 16 LE words:\t0x%04X  %s (must be 0x0000)\n", v.Sum, sumState)
		if err := w.Flush(); err != nil {
			return err
		}
	}

	// Print each section header immediately before the entries it introduces,
	// which is the order they occupy in the catalog.
	printSection := func(sh sectionHeader) error {
		kind := "more headers follow"
		if sh.Final {
			kind = "final section header"
		}
		fmt.Printf("\nSECTION HEADER %d (offset %d)\n", sh.Index, sh.Offset)
		sw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintf(sw, "  Header ID:\t0x%02X (%s)\n", sh.HeaderID, kind)
		fmt.Fprintf(sw, "  Platform ID:\t0x%02X (%s)\n", sh.PlatformID, sh.Platform)
		fmt.Fprintf(sw, "  Entries:\t%d\n", sh.NumEntries)
		if sh.IDString != "" {
			fmt.Fprintf(sw, "  ID string:\t%q\n", sh.IDString)
		}
		return sw.Flush()
	}

	printed := 0
	for _, e := range a.Entries {
		if e.Kind == "default" {
			fmt.Printf("\nDEFAULT / INITIAL ENTRY (offset %d)\n", e.Offset)
		} else {
			for printed < e.Section && printed < len(a.Sections) {
				if err := printSection(a.Sections[printed]); err != nil {
					return err
				}
				printed++
			}
			fmt.Printf("\nSECTION %d ENTRY (offset %d)\n", e.Section, e.Offset)
		}
		w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		bootState := "no"
		if e.Bootable {
			bootState = "yes"
		}
		fmt.Fprintf(w, "  Bootable:\t%s (boot indicator 0x%02X)\n", bootState, e.BootInd)
		fmt.Fprintf(w, "  Platform:\t0x%02X (%s)\n", e.PlatformID, e.Platform)
		fmt.Fprintf(w, "  Boot media type:\t%d (%s)\n", e.MediaType, e.Media)
		if e.MediaFlags != "" {
			fmt.Fprintf(w, "  Media flags:\t%s\n", e.MediaFlags)
		}
		seg := ""
		if e.LoadSegment == 0 {
			seg = " (0 means the default 0x07C0)"
		}
		fmt.Fprintf(w, "  Load segment:\t0x%04X%s\n", e.LoadSegment, seg)
		fmt.Fprintf(w, "  System type:\t0x%02X\n", e.SystemType)
		fmt.Fprintf(w, "  Sector count:\t%d virtual sectors (%d bytes at %d bytes each)\n", e.SectorCount, e.ImageBytes, virtualSector)
		rangeNote := ""
		if !e.InRange {
			rangeNote = "  <- past the end of this file"
		}
		fmt.Fprintf(w, "  Load RBA:\tLBA %d (offset %d)%s\n", e.LoadRBA, e.LoadOffset, rangeNote)
		if err := w.Flush(); err != nil {
			return err
		}
	}

	// Any section header that declared no entries still deserves a mention.
	for printed < len(a.Sections) {
		if err := printSection(a.Sections[printed]); err != nil {
			return err
		}
		printed++
	}

	for _, n := range a.Notes {
		fmt.Printf("\nnote: %s\n", n)
	}

	fmt.Printf("\nVERDICT\n")
	w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintf(w, "  Legacy BIOS boot:\t%s\t%s\n", yesNo(v.BIOS), v.BIOSWhy)
	fmt.Fprintf(w, "  UEFI boot:\t%s\t%s\n", yesNo(v.UEFI), v.UEFIWhy)
	if err := w.Flush(); err != nil {
		return err
	}
	fmt.Printf("\n  %s\n", v.Summary)
	if !ok {
		return codeProblem
	}
	return nil
}

func yesNo(b bool) string {
	if b {
		return "yes"
	}
	return "no"
}

// parseSize accepts a plain byte count or a size with a unit suffix, so that
// "--target-size 8G" and "--target-size 8000000000" both work.
func parseSize(s string) (int64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, errors.New("empty size")
	}
	mult := int64(1)
	up := strings.ToUpper(t)
	suffixes := []struct {
		s string
		m int64
	}{
		{"KIB", 1 << 10}, {"MIB", 1 << 20}, {"GIB", 1 << 30}, {"TIB", 1 << 40},
		{"KB", 1000}, {"MB", 1000 * 1000}, {"GB", 1000 * 1000 * 1000}, {"TB", 1000 * 1000 * 1000 * 1000},
		{"K", 1 << 10}, {"M", 1 << 20}, {"G", 1 << 30}, {"T", 1 << 40},
		{"B", 1},
	}
	for _, sf := range suffixes {
		if strings.HasSuffix(up, sf.s) {
			mult = sf.m
			t = strings.TrimSpace(t[:len(t)-len(sf.s)])
			break
		}
	}
	n, err := strconv.ParseFloat(t, 64)
	if err != nil {
		return 0, fmt.Errorf("%q is not a size; use a byte count such as 8000000000, or a suffix such as 8G", s)
	}
	if n < 0 {
		return 0, fmt.Errorf("%q is negative", s)
	}
	return int64(n * float64(mult)), nil
}

func cmdPreflight(args []string) error {
	fs := newFlagSet("preflight")
	targetSize := fs.String("target-size", "", "size in bytes of the USB device the image is destined for")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := parseArgs(fs, args, map[string]bool{"target-size": true}); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("preflight takes exactly one argument: <file.iso>")
	}
	var target int64 = -1
	if *targetSize != "" {
		n, err := parseSize(*targetSize)
		if err != nil {
			return fmt.Errorf("--target-size: %w", err)
		}
		target = n
	}

	im, err := openImage(rest[0])
	if err != nil {
		return err
	}
	defer im.Close()

	var checks []checkJSON
	add := func(name, status, detail string) {
		checks = append(checks, checkJSON{Name: name, Status: status, Detail: detail})
	}

	// 1. Image size.
	if im.size == 0 {
		add("Image size", "FAIL", "the file is empty (0 bytes)")
	} else {
		add("Image size", "PASS", fmt.Sprintf("%d bytes (%s)", im.size, humanBytes(im.size)))
	}

	// 2. Whole number of 2048-byte sectors.
	rem := im.size % sectorSize
	if im.size == 0 {
		add("Sector alignment", "FAIL", "0 bytes is not a usable image")
	} else if rem == 0 {
		add("Sector alignment", "PASS",
			fmt.Sprintf("%d bytes = %d whole sectors of %d bytes, remainder 0", im.size, im.size/sectorSize, sectorSize))
	} else {
		add("Sector alignment", "FAIL",
			fmt.Sprintf("%d bytes = %d x %d + %d; the remainder of %d byte(s) means the file is not a whole number of 2048-byte sectors, which almost always means the download was cut short",
				im.size, im.size/sectorSize, sectorSize, rem, rem))
	}

	// 3. ISO 9660 volume.
	vol, volErr := im.readVolume()
	if volErr != nil {
		add("ISO 9660 volume", "FAIL", volErr.Error())
	} else {
		id := vol.VolumeID
		if id == "" {
			id = "(blank)"
		}
		add("ISO 9660 volume", "PASS", fmt.Sprintf("volume identifier %q, system identifier %q, logical block size %d",
			id, vol.SystemID, vol.BlockSize))
	}

	// 4. Declared volume size versus the real file size.
	if volErr == nil && vol.DeclaredSz > 0 {
		switch {
		case vol.DeclaredSz > im.size:
			add("Declared volume size", "FAIL",
				fmt.Sprintf("the volume declares %d blocks x %d bytes = %d bytes (%s) but the file holds only %d bytes (%s) - the image is truncated",
					vol.SpaceSize, vol.BlockSize, vol.DeclaredSz, humanBytes(vol.DeclaredSz), im.size, humanBytes(im.size)))
		case vol.DeclaredSz < im.size:
			add("Declared volume size", "WARN",
				fmt.Sprintf("the volume declares %d bytes (%s) but the file is %d bytes (%s); %d trailing bytes are outside the volume (padding, or an appended hybrid image)",
					vol.DeclaredSz, humanBytes(vol.DeclaredSz), im.size, humanBytes(im.size), im.size-vol.DeclaredSz))
		default:
			add("Declared volume size", "PASS",
				fmt.Sprintf("%d blocks x %d bytes = %d bytes, exactly the file size", vol.SpaceSize, vol.BlockSize, vol.DeclaredSz))
		}
	}

	// 5. El Torito boot catalog.
	a, err := im.analyzeBoot()
	if err != nil {
		return err
	}
	v := a.verdict()
	switch {
	case !a.Record.Present:
		add("El Torito boot record", "WARN",
			fmt.Sprintf("absent - no type 0 volume descriptor (sector %d, offset %d); this is a data image and it will not boot",
				bootRecordSector, bootRecordSector*sectorSize))
	case !a.Record.IsElTorito:
		add("El Torito boot record", "WARN",
			fmt.Sprintf("a boot record exists at sector %d but its boot system identifier is %q, not %q",
				a.Record.Sector, a.Record.BootSystemID, elToritoID))
	default:
		add("El Torito boot record", "PASS",
			fmt.Sprintf("sector %d (offset %d), boot system identifier %q, catalog at LBA %d (offset %d)",
				a.Record.Sector, a.Record.Offset, a.Record.BootSystemID, a.Record.CatalogLBA, a.Record.CatalogOff))
	}
	switch {
	case a.CatalogErr != "":
		add("Boot catalog", "FAIL", a.CatalogErr)
	case a.Validation == nil:
		add("Boot catalog", "WARN", "no boot catalog to read")
	case a.Validation.Valid:
		add("Boot catalog", "PASS",
			fmt.Sprintf("validation entry checksum settles to 0x0000, key bytes 0x55 0xAA present, platform 0x%02X (%s), %d boot entr%s",
				a.Validation.PlatformID, a.Validation.Platform, len(a.Entries), plural(len(a.Entries))))
	default:
		add("Boot catalog", "FAIL",
			fmt.Sprintf("validation entry is corrupt: the 16 little-endian words sum to 0x%04X instead of 0x0000 (key bytes 0x%02X 0x%02X)",
				a.Validation.Sum, a.Validation.Key55, a.Validation.KeyAA))
	}

	// 6. Firmware verdict.
	switch {
	case v.BIOS && v.UEFI:
		add("Firmware support", "PASS", "bootable on legacy BIOS and on UEFI")
	case v.BIOS:
		add("Firmware support", "WARN", "legacy BIOS only - a UEFI-only machine will not boot this image")
	case v.UEFI:
		add("Firmware support", "WARN", "UEFI only - a legacy BIOS machine will not boot this image")
	case v.Other:
		add("Firmware support", "WARN", "only a non-x86 platform entry is bootable")
	default:
		add("Firmware support", "WARN", "no bootable entry for any firmware")
	}

	// 7. Does it fit on the target device.
	if target < 0 {
		add("Fits target device", "WARN", "not checked - pass --target-size <bytes> with the capacity of the USB stick")
	} else if im.size <= target {
		add("Fits target device", "PASS",
			fmt.Sprintf("image %d bytes (%s) <= target %d bytes (%s), leaving %d bytes (%s) spare",
				im.size, humanBytes(im.size), target, humanBytes(target), target-im.size, humanBytes(target-im.size)))
	} else {
		add("Fits target device", "FAIL",
			fmt.Sprintf("image %d bytes (%s) > target %d bytes (%s); it is %d bytes (%s) too large",
				im.size, humanBytes(im.size), target, humanBytes(target), im.size-target, humanBytes(im.size-target)))
	}

	var pass, warn, fail int
	for _, c := range checks {
		switch c.Status {
		case "PASS":
			pass++
		case "WARN":
			warn++
		case "FAIL":
			fail++
		}
	}
	summary := v.Summary
	if fail > 0 {
		summary = "NOT READY TO WRITE - " + summary
	} else if warn > 0 {
		summary = "READY WITH CAVEATS - " + summary
	} else {
		summary = "READY TO WRITE - " + summary
	}

	if *asJSON {
		j := preflightJSON{
			File: im.name, FileSize: im.size, FileHuman: humanBytes(im.size),
			VolumeID: vol.VolumeID, Checks: checks,
			Passed: pass, Warned: warn, Failed: fail,
			Verdict: summary, OK: fail == 0,
		}
		if err := writeJSON(j); err != nil {
			return err
		}
		if fail > 0 {
			return codeProblem
		}
		return nil
	}

	fmt.Printf("PRE-FLIGHT REPORT: %s\n\n", im.name)
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	for _, c := range checks {
		fmt.Fprintf(w, "  %s\t%s\t%s\n", c.Status, c.Name, c.Detail)
	}
	if err := w.Flush(); err != nil {
		return err
	}
	fmt.Printf("\n  %d passed, %d warning(s), %d failed\n", pass, warn, fail)
	fmt.Printf("\n  %s\n", summary)
	if fail > 0 {
		return codeProblem
	}
	return nil
}

func plural(n int) string {
	if n == 1 {
		return "y"
	}
	return "ies"
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

func usageTo(w io.Writer) {
	fmt.Fprint(w, `rescueusb - pre-flight bootability verifier for ISO disc images

Answers three questions before you write an image to a USB stick: did the
download arrive intact, is the image actually bootable, and on which firmware.
rescueusb only ever reads: it never writes to a device and never modifies an
image.

USAGE
  rescueusb <command> [options]

COMMANDS
  verify    <file.iso> [--sha256 H] [--md5 H] [--checksum-file F] [--json]
            Hash the image and compare against published checksums.
  boot      <file.iso> [--json]
            Decode the El Torito boot record and boot catalog, verify the
            catalog's 16-bit validation checksum, and report BIOS/UEFI
            bootability.
  preflight <file.iso> [--target-size N] [--json]
            Combined readiness report: size, sector alignment, ISO 9660
            volume, boot catalog, firmware support, and whether the image
            fits the destination device.
  help      Show this help.

OPTIONS
  --sha256 HASH         Expected SHA-256 hash (64 hex characters)
  --md5 HASH            Expected MD5 hash (32 hex characters)
  --checksum-file FILE  Published checksum file: "<hash>  <filename>" lines,
                        or the BSD "SHA256 (file) = hash" form
  --target-size N       Capacity of the destination device, in bytes; a
                        suffix such as 8G, 16GB or 512MiB is also accepted
  --json                Emit machine-readable JSON
  -h, --help            Show this help

EXIT STATUS
  0  the answer is yes: checksums match, the image boots, all checks pass
  1  the command could not be carried out (missing file, unreadable image)
  2  the report is complete but the answer is no: a checksum mismatched, the
     image will not boot, or a pre-flight check failed

NOTES
  Flags may appear before or after positional arguments.
  A checksum file may list many images; the line whose filename matches the
  image being checked is the one used.

EXAMPLES
  rescueusb verify ubuntu.iso --sha256 946f1a...c3
  rescueusb verify ubuntu.iso --checksum-file SHA256SUMS
  rescueusb boot ubuntu.iso
  rescueusb boot ubuntu.iso --json
  rescueusb preflight ubuntu.iso --target-size 8G
`)
}

func usage() { usageTo(os.Stderr) }

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	args := os.Args[2:]
	var err error
	switch os.Args[1] {
	case "-h", "--help", "help", "-help", "--h":
		usageTo(os.Stdout)
		os.Exit(0)
	case "verify":
		err = cmdVerify(args)
	case "boot":
		err = cmdBoot(args)
	case "preflight":
		err = cmdPreflight(args)
	default:
		fmt.Fprintf(os.Stderr, "rescueusb: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
	if err != nil {
		var code exitCode
		if errors.As(err, &code) {
			os.Exit(int(code))
		}
		fmt.Fprintf(os.Stderr, "rescueusb: %v\n", err)
		os.Exit(1)
	}
}
