// Command cardrecovery is a read-only file carver for memory card images.
//
// It recovers photos and documents from a raw image whose filesystem is gone
// by scanning the raw bytes for file signatures and walking each format's
// internal structure to find where the file ends. It never parses a directory
// structure, and it never writes to the image it scans.
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"hash/crc32"
	"image"
	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

const (
	toolName = "cardrecovery"

	// chunkSize is the size of the sequential read window used by the header
	// scanner. overlapWindow bytes of the previous chunk are carried into the
	// next one so that a signature straddling a chunk boundary is still found.
	chunkSize     = 1 << 20 // 1 MiB
	overlapWindow = 64      // bytes; longest signature is 8 bytes

	blockSize       = 64 << 10 // bulk search / hashing block
	defaultMaxCarve = 16 << 20 // cap for a header with no reachable footer

	exitUsage      = 1
	exitCheckFails = 2
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX 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])
}

// ---------------------------------------------------------------------------
// Image access. The image is opened read-only and every read is bounds-checked.
// ---------------------------------------------------------------------------

type imageFile struct {
	path string
	f    *os.File
	size int64
}

func openImage(path string) (*imageFile, error) {
	f, err := os.Open(path)
	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 card image", path)
	}
	if !st.Mode().IsRegular() {
		f.Close()
		return nil, fmt.Errorf("%s is not a regular file (cardrecovery reads image files, not raw devices)", path)
	}
	if st.Size() == 0 {
		f.Close()
		return nil, fmt.Errorf("%s is empty (0 bytes): nothing to carve", path)
	}
	return &imageFile{path: path, f: f, size: st.Size()}, nil
}

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

// at returns a copy of up to n bytes at off, clamped to the end of the image.
// It returns a short slice (possibly nil) rather than an error at EOF; every
// caller treats a short read as "the file is truncated here".
func (im *imageFile) at(off int64, n int) []byte {
	if off < 0 || n <= 0 || off >= im.size {
		return nil
	}
	if off+int64(n) > im.size {
		n = int(im.size - off)
	}
	buf := make([]byte, n)
	m, err := im.f.ReadAt(buf, off)
	if m <= 0 {
		return nil
	}
	if err != nil && m < n {
		return buf[:m]
	}
	return buf[:m]
}

// indexFrom searches for pat in [from, limit) and returns its absolute offset,
// or -1. It reads in blocks with a len(pat)-1 overlap so a match spanning a
// block boundary is not missed.
func (im *imageFile) indexFrom(from, limit int64, pat []byte) int64 {
	if from < 0 {
		from = 0
	}
	if limit > im.size {
		limit = im.size
	}
	p := from
	for p < limit {
		n := int64(blockSize)
		if p+n > limit {
			n = limit - p
		}
		buf := im.at(p, int(n))
		if len(buf) < len(pat) {
			return -1
		}
		if i := indexBytes(buf, pat); i >= 0 {
			return p + int64(i)
		}
		p += int64(len(buf)) - int64(len(pat)) + 1
	}
	return -1
}

func indexBytes(hay, needle []byte) int {
	return bytes.Index(hay, needle)
}

func be16(b []byte) uint16 { return binary.BigEndian.Uint16(b) }
func be32(b []byte) uint32 { return binary.BigEndian.Uint32(b) }
func le16(b []byte) uint16 { return binary.LittleEndian.Uint16(b) }
func le32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) }

// ---------------------------------------------------------------------------
// Signature table.
// ---------------------------------------------------------------------------

type sigDef struct {
	kind  string
	ext   string
	magic []byte
}

var signatures = []sigDef{
	{"jpeg", ".jpg", []byte{0xFF, 0xD8, 0xFF}},
	{"png", ".png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}},
	{"gif", ".gif", []byte("GIF87a")},
	{"gif", ".gif", []byte("GIF89a")},
	{"pdf", ".pdf", []byte("%PDF-")},
	{"zip", ".zip", []byte("PK\x03\x04")},
}

var knownKinds = []string{"jpeg", "png", "gif", "pdf", "zip"}

func extFor(kind string) string {
	for _, s := range signatures {
		if s.kind == kind {
			return s.ext
		}
	}
	return ".bin"
}

// ---------------------------------------------------------------------------
// Phase 1: streaming header scan with an overlapping window.
// ---------------------------------------------------------------------------

type candidate struct {
	kind string
	off  int64
}

func scanHeaders(im *imageFile) ([]candidate, error) {
	if _, err := im.f.Seek(0, io.SeekStart); err != nil {
		return nil, err
	}
	buf := make([]byte, chunkSize+overlapWindow)
	var base int64 // file offset of buf[0]
	carried := 0
	var out []candidate
	for {
		n, err := io.ReadFull(im.f, buf[carried:])
		total := carried + n
		atEOF := err == io.EOF || err == io.ErrUnexpectedEOF
		if err != nil && !atEOF {
			return nil, err
		}
		// Matches starting at or past limit are re-examined in the next
		// iteration, where they are fully contained in the buffer.
		limit := total - overlapWindow
		if atEOF || limit < 0 {
			limit = total
		}
		window := buf[:total]
		for _, s := range signatures {
			p := 0
			for p < limit {
				i := indexBytes(window[p:], s.magic)
				if i < 0 {
					break
				}
				abs := p + i
				if abs >= limit {
					break
				}
				out = append(out, candidate{s.kind, base + int64(abs)})
				p = abs + 1
			}
		}
		if atEOF {
			break
		}
		copy(buf[:overlapWindow], buf[total-overlapWindow:total])
		base += int64(total - overlapWindow)
		carried = overlapWindow
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].off != out[j].off {
			return out[i].off < out[j].off
		}
		return out[i].kind < out[j].kind
	})
	return out, nil
}

// ---------------------------------------------------------------------------
// Phase 2: per-format structural walks that locate the end of each file.
// ---------------------------------------------------------------------------

// walkResult is what a format walker reports about one candidate header.
type walkResult struct {
	end        int64 // exclusive end offset; only meaningful when status != invalid
	status     string
	confidence string
	detail     string
}

const (
	statusComplete  = "complete"
	statusTruncated = "truncated"
	statusInvalid   = "invalid"
	statusNested    = "nested"
)

func isSOF(m byte) bool {
	return m >= 0xC0 && m <= 0xCF && m != 0xC4 && m != 0xC8 && m != 0xCC
}

// walkJPEG walks the JPEG marker segments from SOI to EOI. Because APP
// segments are skipped by their declared length, an embedded EXIF thumbnail
// (which is itself a complete JPEG) never terminates the outer file early.
func walkJPEG(im *imageFile, start, limit int64) walkResult {
	p := start + 2
	var w, h int
	sawSOS, sawSOF := false, false
	for p < limit {
		b := im.at(p, 2)
		if len(b) < 2 {
			break
		}
		if b[0] != 0xFF {
			if !sawSOS {
				return walkResult{0, statusInvalid, "none",
					fmt.Sprintf("expected a JPEG marker at offset %d, found 0x%02X", p, b[0])}
			}
			break
		}
		m := b[1]
		if m == 0xFF { // fill byte
			p++
			continue
		}
		if m == 0xD9 { // EOI
			return walkResult{p + 2, statusComplete, jpegConfidence(sawSOF),
				fmt.Sprintf("%s, EOI at offset %d", dims(w, h), p)}
		}
		if m == 0x01 || (m >= 0xD0 && m <= 0xD7) { // standalone markers
			p += 2
			continue
		}
		lb := im.at(p+2, 2)
		if len(lb) < 2 {
			break
		}
		segLen := int64(be16(lb))
		if segLen < 2 {
			if !sawSOS {
				return walkResult{0, statusInvalid, "none",
					fmt.Sprintf("marker 0xFF%02X at offset %d declares an impossible segment length %d", m, p, segLen)}
			}
			break
		}
		if isSOF(m) {
			if sof := im.at(p+4, 5); len(sof) == 5 {
				h, w = int(be16(sof[1:3])), int(be16(sof[3:5]))
				sawSOF = true
			}
		}
		next := p + 2 + segLen
		if m == 0xDA { // SOS: entropy-coded data follows
			sawSOS = true
			q := scanEntropy(im, next, limit)
			if q < 0 {
				break
			}
			p = q
			continue
		}
		p = next
	}
	return truncatedAt(limit, fmt.Sprintf("%s, no EOI (0xFFD9) found before the carve limit", dims(w, h)))
}

func jpegConfidence(sawSOF bool) string {
	if sawSOF {
		return "high"
	}
	return "medium"
}

// scanEntropy finds the next real marker in JPEG entropy-coded data, skipping
// 0xFF00 byte stuffing and 0xFFD0-0xFFD7 restart markers.
func scanEntropy(im *imageFile, from, limit int64) int64 {
	p := from
	for p < limit {
		n := int64(blockSize)
		if p+n > limit {
			n = limit - p
		}
		buf := im.at(p, int(n))
		if len(buf) == 0 {
			return -1
		}
		for i := 0; i < len(buf); i++ {
			if buf[i] != 0xFF {
				continue
			}
			var nb byte
			if i+1 < len(buf) {
				nb = buf[i+1]
			} else {
				t := im.at(p+int64(i)+1, 1)
				if len(t) == 0 {
					return -1
				}
				nb = t[0]
			}
			if nb == 0x00 || nb == 0xFF || (nb >= 0xD0 && nb <= 0xD7) {
				continue
			}
			return p + int64(i)
		}
		p += int64(len(buf))
	}
	return -1
}

// walkPNG walks the PNG chunk chain to IEND, verifying every chunk CRC32.
func walkPNG(im *imageFile, start, limit int64) walkResult {
	p := start + 8
	first := true
	var w, h int
	badCRC, chunks := 0, 0
	for {
		hdr := im.at(p, 8)
		if len(hdr) < 8 {
			return truncatedAt(limit, fmt.Sprintf("%s, chunk header at offset %d is cut off", dims(w, h), p))
		}
		length := int64(be32(hdr[0:4]))
		typ := string(hdr[4:8])
		if !printableChunkType(typ) || length > 1<<31 {
			if first {
				return walkResult{0, statusInvalid, "none",
					fmt.Sprintf("first chunk at offset %d is not a valid PNG chunk", p)}
			}
			return truncatedAt(limit, fmt.Sprintf("%s, corrupt chunk header at offset %d", dims(w, h), p))
		}
		if first {
			if typ != "IHDR" || length != 13 {
				return walkResult{0, statusInvalid, "none",
					fmt.Sprintf("PNG signature is not followed by a 13-byte IHDR chunk (found %q, length %d)", typ, length)}
			}
			if d := im.at(p+8, 13); len(d) == 13 {
				w, h = int(be32(d[0:4])), int(be32(d[4:8]))
			}
			first = false
		}
		end := p + 8 + length + 4
		if end > limit {
			return truncatedAt(limit, fmt.Sprintf("%s, %q chunk at offset %d runs past the carve limit", dims(w, h), typ, p))
		}
		c := crc32.NewIEEE()
		c.Write(hdr[4:8])
		if !hashRange(im, c, p+8, length) {
			return truncatedAt(limit, fmt.Sprintf("%s, %q chunk data at offset %d is cut off", dims(w, h), typ, p))
		}
		cb := im.at(p+8+length, 4)
		if len(cb) < 4 {
			return truncatedAt(limit, fmt.Sprintf("%s, %q chunk CRC at offset %d is cut off", dims(w, h), typ, p))
		}
		chunks++
		if c.Sum32() != be32(cb) {
			badCRC++
		}
		p = end
		if typ == "IEND" {
			conf, note := "high", fmt.Sprintf("%d chunks, all CRC32 valid", chunks)
			if badCRC > 0 {
				conf = "medium"
				note = fmt.Sprintf("%d chunks, %d with a bad CRC32", chunks, badCRC)
			}
			return walkResult{p, statusComplete, conf, fmt.Sprintf("%s, %s", dims(w, h), note)}
		}
	}
}

func printableChunkType(s string) bool {
	for i := 0; i < len(s); i++ {
		c := s[i]
		if !(c >= 'A' && c <= 'Z') && !(c >= 'a' && c <= 'z') {
			return false
		}
	}
	return true
}

func hashRange(im *imageFile, w io.Writer, off, n int64) bool {
	for n > 0 {
		k := int64(blockSize)
		if k > n {
			k = n
		}
		b := im.at(off, int(k))
		if int64(len(b)) < k {
			return false
		}
		w.Write(b)
		off += k
		n -= k
	}
	return true
}

// walkGIF walks the GIF block structure to the 0x3B trailer.
func walkGIF(im *imageFile, start, limit int64) walkResult {
	lsd := im.at(start+6, 7)
	if len(lsd) < 7 {
		return walkResult{0, statusInvalid, "none", "GIF header is not followed by a logical screen descriptor"}
	}
	w, h := int(le16(lsd[0:2])), int(le16(lsd[2:4]))
	p := start + 13
	if lsd[4]&0x80 != 0 {
		p += int64(3 * (1 << ((lsd[4] & 0x07) + 1)))
	}
	frames := 0
	firstBlock := true
	for p < limit {
		b := im.at(p, 1)
		if len(b) < 1 {
			break
		}
		switch b[0] {
		case 0x3B: // trailer
			return walkResult{p + 1, statusComplete, "high",
				fmt.Sprintf("%s, %d image block(s), trailer at offset %d", dims(w, h), frames, p)}
		case 0x21: // extension
			q, ok := skipSubBlocks(im, p+2, limit)
			if !ok {
				p = limit
				break
			}
			p = q
		case 0x2C: // image descriptor
			d := im.at(p+1, 9)
			if len(d) < 9 {
				p = limit
				break
			}
			q := p + 10
			if d[8]&0x80 != 0 {
				q += int64(3 * (1 << ((d[8] & 0x07) + 1)))
			}
			q++ // LZW minimum code size
			r, ok := skipSubBlocks(im, q, limit)
			if !ok {
				p = limit
				break
			}
			frames++
			p = r
		default:
			if firstBlock {
				return walkResult{0, statusInvalid, "none",
					fmt.Sprintf("byte 0x%02X at offset %d is not a GIF block introducer", b[0], p)}
			}
			return truncatedAt(limit, fmt.Sprintf("%s, corrupt block introducer 0x%02X at offset %d", dims(w, h), b[0], p))
		}
		firstBlock = false
	}
	return truncatedAt(limit, fmt.Sprintf("%s, no 0x3B trailer found before the carve limit", dims(w, h)))
}

func skipSubBlocks(im *imageFile, p, limit int64) (int64, bool) {
	for p < limit {
		b := im.at(p, 1)
		if len(b) < 1 {
			return 0, false
		}
		if b[0] == 0 {
			return p + 1, true
		}
		p += 1 + int64(b[0])
	}
	return 0, false
}

// walkPDF finds the last %%EOF belonging to this document. The search stops at
// the next %PDF- header so a following document is not swallowed.
func walkPDF(im *imageFile, start, limit int64) walkResult {
	stop := limit
	if nx := im.indexFrom(start+5, limit, []byte("%PDF-")); nx > 0 {
		stop = nx
	}
	last := int64(-1)
	p := start + 5
	for {
		i := im.indexFrom(p, stop, []byte("%%EOF"))
		if i < 0 {
			break
		}
		last = i
		p = i + 5
	}
	if last < 0 {
		return truncatedAt(limit, "no %%EOF marker found before the carve limit")
	}
	end := last + 5
	if t := im.at(end, 2); len(t) > 0 { // absorb the trailing EOL
		if t[0] == '\r' {
			end++
			if len(t) > 1 && t[1] == '\n' {
				end++
			}
		} else if t[0] == '\n' {
			end++
		}
	}
	conf := "medium"
	note := "%%EOF found, no startxref (linearised or damaged)"
	if im.indexFrom(start, end, []byte("startxref")) >= 0 {
		conf = "high"
		note = "startxref and %%EOF both present"
	}
	return walkResult{end, statusComplete, conf, note}
}

// walkZIP finds the end-of-central-directory record and validates it against
// the central directory offset and size it declares.
func walkZIP(im *imageFile, start, limit int64) walkResult {
	firstEnd, firstEntries := int64(-1), 0
	p := start + 4
	for {
		i := im.indexFrom(p, limit, []byte("PK\x05\x06"))
		if i < 0 {
			break
		}
		rec := im.at(i, 22)
		if len(rec) == 22 {
			entries := int(le16(rec[10:12]))
			cdSize := int64(le32(rec[12:16]))
			cdOff := int64(le32(rec[16:20]))
			end := i + 22 + int64(le16(rec[20:22]))
			if end <= limit {
				if start+cdOff+cdSize == i {
					return walkResult{end, statusComplete, "high",
						fmt.Sprintf("%d entries, central directory at +%d validates against EOCD", entries, cdOff)}
				}
				if firstEnd < 0 {
					firstEnd, firstEntries = end, entries
				}
			}
		}
		p = i + 4
	}
	if firstEnd >= 0 {
		return walkResult{firstEnd, statusComplete, "medium",
			fmt.Sprintf("%d entries, EOCD found but the central directory offset does not validate", firstEntries)}
	}
	return truncatedAt(limit, "no end-of-central-directory record found before the carve limit")
}

func truncatedAt(limit int64, detail string) walkResult {
	return walkResult{limit, statusTruncated, "low", detail}
}

func dims(w, h int) string {
	if w <= 0 || h <= 0 {
		return "dimensions unknown"
	}
	return fmt.Sprintf("%dx%d", w, h)
}

// ---------------------------------------------------------------------------
// Carving pipeline.
// ---------------------------------------------------------------------------

type finding struct {
	Index      int    `json:"index"`
	Type       string `json:"type"`
	Offset     int64  `json:"offset"`
	End        int64  `json:"end_offset"`
	Length     int64  `json:"length"`
	LengthHum  string `json:"length_human"`
	Status     string `json:"status"`
	Confidence string `json:"confidence"`
	Detail     string `json:"detail"`
}

func carve(im *imageFile, maxCarve int64) ([]finding, error) {
	cands, err := scanHeaders(im)
	if err != nil {
		return nil, err
	}
	var out []finding
	lastEnd := int64(-1)
	for ci, c := range cands {
		if c.off < lastEnd {
			out = append(out, finding{
				Type: c.kind, Offset: c.off, End: c.off, Status: statusNested, Confidence: "n/a",
				LengthHum: "-",
				Detail:    fmt.Sprintf("signature lies inside the file carved at offset %d; not carved separately", lastCarveStart(out)),
			})
			continue
		}
		limit := c.off + maxCarve
		if limit > im.size {
			limit = im.size
		}
		var r walkResult
		switch c.kind {
		case "jpeg":
			r = walkJPEG(im, c.off, limit)
		case "png":
			r = walkPNG(im, c.off, limit)
		case "gif":
			r = walkGIF(im, c.off, limit)
		case "pdf":
			r = walkPDF(im, c.off, limit)
		case "zip":
			r = walkZIP(im, c.off, limit)
		}
		if r.status == statusTruncated {
			// Do not let a footerless header swallow the files that follow it.
			for j := ci + 1; j < len(cands); j++ {
				if cands[j].off > c.off {
					if cands[j].off < r.end {
						r.end = cands[j].off
						r.detail += "; carve stopped at the next signature"
					}
					break
				}
			}
		}
		f := finding{
			Type: c.kind, Offset: c.off, Status: r.status,
			Confidence: r.confidence, Detail: r.detail,
		}
		if r.status == statusInvalid {
			f.End, f.Length, f.LengthHum = c.off, 0, "-"
			out = append(out, f)
			continue
		}
		f.End = r.end
		f.Length = r.end - c.off
		f.LengthHum = humanBytes(f.Length)
		out = append(out, f)
		lastEnd = r.end
	}
	for i := range out {
		out[i].Index = i + 1
	}
	return out, nil
}

func lastCarveStart(fs []finding) int64 {
	for i := len(fs) - 1; i >= 0; i-- {
		if fs[i].Status != statusNested && fs[i].Status != statusInvalid {
			return fs[i].Offset
		}
	}
	return -1
}

// recoverable reports whether a finding is a carvable file.
func recoverable(f finding) bool {
	return f.Status == statusComplete || f.Status == statusTruncated
}

func counts(fs []finding) (complete, truncated, invalid, nested int) {
	for _, f := range fs {
		switch f.Status {
		case statusComplete:
			complete++
		case statusTruncated:
			truncated++
		case statusInvalid:
			invalid++
		case statusNested:
			nested++
		}
	}
	return
}

// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------

type scanJSON struct {
	Image       string    `json:"image"`
	SizeBytes   int64     `json:"size_bytes"`
	SizeHuman   string    `json:"size_human"`
	ChunkBytes  int       `json:"scan_chunk_bytes"`
	Overlap     int       `json:"scan_overlap_bytes"`
	MaxCarve    int64     `json:"max_carve_bytes"`
	Findings    []finding `json:"findings"`
	Complete    int       `json:"complete"`
	Truncated   int       `json:"truncated"`
	Invalid     int       `json:"false_positives"`
	Nested      int       `json:"nested_suppressed"`
	Recoverable int       `json:"recoverable"`
}

func cmdScan(im *imageFile, maxCarve int64, asJSON bool) int {
	fs, err := carve(im, maxCarve)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %s: %v\n", toolName, im.path, err)
		return exitUsage
	}
	comp, trunc, inv, nest := counts(fs)
	if asJSON {
		if fs == nil {
			fs = []finding{}
		}
		return emitJSON(scanJSON{
			Image: im.path, SizeBytes: im.size, SizeHuman: humanBytes(im.size),
			ChunkBytes: chunkSize, Overlap: overlapWindow, MaxCarve: maxCarve,
			Findings: fs, Complete: comp, Truncated: trunc, Invalid: inv, Nested: nest,
			Recoverable: comp + trunc,
		})
	}
	fmt.Printf("Image:      %s\n", im.path)
	fmt.Printf("Size:       %s (%d bytes)\n", humanBytes(im.size), im.size)
	fmt.Printf("Scan:       %s chunks with a %d-byte overlap (signatures spanning a chunk boundary are still found)\n",
		humanBytes(chunkSize), overlapWindow)
	fmt.Printf("Max carve:  %s\n", humanBytes(maxCarve))
	fmt.Println()
	if len(fs) == 0 {
		fmt.Println("No known file signatures found.")
		return 0
	}
	fmt.Printf("%-4s %-5s %-14s %-14s %-12s %-10s %-11s %s\n",
		"#", "TYPE", "OFFSET", "END", "LENGTH", "STATUS", "CONFIDENCE", "DETAIL")
	for _, f := range fs {
		length := "-"
		if f.Length > 0 {
			length = strconv.FormatInt(f.Length, 10)
		}
		fmt.Printf("%-4d %-5s %-14d %-14d %-12s %-10s %-11s %s\n",
			f.Index, f.Type, f.Offset, f.End, length, f.Status, f.Confidence, f.Detail)
	}
	fmt.Println()
	fmt.Printf("Summary: %d recoverable (%d complete, %d truncated), %d false positive(s), %d nested signature(s) suppressed\n",
		comp+trunc, comp, trunc, inv, nest)
	return 0
}

// ---------------------------------------------------------------------------
// recover
// ---------------------------------------------------------------------------

type carvedFile struct {
	Name       string `json:"name"`
	Type       string `json:"type"`
	Offset     int64  `json:"offset"`
	Length     int64  `json:"length"`
	Status     string `json:"status"`
	Confidence string `json:"confidence"`
	SHA256     string `json:"sha256,omitempty"`
}

type recoverJSON struct {
	Image     string       `json:"image"`
	Out       string       `json:"out"`
	Applied   bool         `json:"applied"`
	Types     []string     `json:"types"`
	MinSize   int64        `json:"min_size_bytes"`
	MaxCarve  int64        `json:"max_carve_bytes"`
	Found     int          `json:"found_recoverable"`
	Selected  int          `json:"selected"`
	SkipType  int          `json:"skipped_type_filter"`
	SkipSize  int          `json:"skipped_min_size"`
	TotalSize int64        `json:"total_bytes"`
	Files     []carvedFile `json:"files"`
}

func cmdRecover(im *imageFile, outDir string, types map[string]bool, typeList []string, minSize, maxCarve int64, apply, asJSON bool) int {
	fs, err := carve(im, maxCarve)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %s: %v\n", toolName, im.path, err)
		return exitUsage
	}
	var sel []carvedFile
	found, skipType, skipSize := 0, 0, 0
	var total int64
	for _, f := range fs {
		if !recoverable(f) {
			continue
		}
		found++
		if types != nil && !types[f.Type] {
			skipType++
			continue
		}
		if f.Length < minSize {
			skipSize++
			continue
		}
		sel = append(sel, carvedFile{
			Type: f.Type, Offset: f.Offset, Length: f.Length,
			Status: f.Status, Confidence: f.Confidence,
		})
		total += f.Length
	}
	for i := range sel {
		sel[i].Name = fmt.Sprintf("%06d%s", i+1, extFor(sel[i].Type))
	}

	if apply {
		if err := os.MkdirAll(outDir, 0o755); err != nil {
			fmt.Fprintf(os.Stderr, "%s: cannot create --out directory: %v\n", toolName, err)
			return exitUsage
		}
		for i := range sel {
			sum, err := writeCarve(im, filepath.Join(outDir, sel[i].Name), sel[i].Offset, sel[i].Length)
			if err != nil {
				fmt.Fprintf(os.Stderr, "%s: writing %s: %v\n", toolName, sel[i].Name, err)
				return exitUsage
			}
			sel[i].SHA256 = sum
		}
		if err := writeManifest(im, outDir, sel); err != nil {
			fmt.Fprintf(os.Stderr, "%s: writing manifest: %v\n", toolName, err)
			return exitUsage
		}
	}

	if asJSON {
		if sel == nil {
			sel = []carvedFile{}
		}
		return emitJSON(recoverJSON{
			Image: im.path, Out: outDir, Applied: apply, Types: typeList,
			MinSize: minSize, MaxCarve: maxCarve, Found: found, Selected: len(sel),
			SkipType: skipType, SkipSize: skipSize, TotalSize: total, Files: sel,
		})
	}

	fmt.Printf("Image:    %s\n", im.path)
	fmt.Printf("Out:      %s\n", outDir)
	fmt.Printf("Filters:  types=%s  min-size=%d  max-carve=%d\n", strings.Join(typeList, ","), minSize, maxCarve)
	fmt.Println()
	if len(sel) == 0 {
		fmt.Printf("Nothing to recover (%d recoverable found, %d filtered out by --types, %d by --min-size).\n",
			found, skipType, skipSize)
		return 0
	}
	fmt.Printf("%-4s %-14s %-5s %-14s %-12s %-10s %s\n", "#", "NAME", "TYPE", "OFFSET", "LENGTH", "STATUS", "SHA256")
	for i, c := range sel {
		sum := c.SHA256
		if sum == "" {
			sum = "(not written)"
		}
		fmt.Printf("%-4d %-14s %-5s %-14d %-12d %-10s %s\n", i+1, c.Name, c.Type, c.Offset, c.Length, c.Status, sum)
	}
	fmt.Println()
	fmt.Printf("Selected %d of %d recoverable file(s), %s total (%d filtered out by --types, %d by --min-size)\n",
		len(sel), found, humanBytes(total), skipType, skipSize)
	if apply {
		fmt.Printf("Wrote %d file(s) plus manifest.txt to %s\n", len(sel), outDir)
	} else {
		fmt.Println("DRY RUN: nothing was written. Re-run with --apply to carve these files out.")
	}
	return 0
}

func writeCarve(im *imageFile, path string, off, length int64) (string, error) {
	f, err := os.Create(path)
	if err != nil {
		return "", err
	}
	h := sha256.New()
	n, err := io.Copy(io.MultiWriter(f, h), io.NewSectionReader(im.f, off, length))
	if cerr := f.Close(); err == nil {
		err = cerr
	}
	if err != nil {
		return "", err
	}
	if n != length {
		return "", fmt.Errorf("short carve: wrote %d of %d bytes", n, length)
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

func writeManifest(im *imageFile, outDir string, sel []carvedFile) error {
	var b strings.Builder
	fmt.Fprintf(&b, "cardrecovery manifest\n")
	fmt.Fprintf(&b, "source image: %s (%d bytes)\n", im.path, im.size)
	fmt.Fprintf(&b, "files: %d\n\n", len(sel))
	fmt.Fprintf(&b, "%-14s %-5s %-14s %-12s %-10s %s\n", "NAME", "TYPE", "OFFSET", "LENGTH", "STATUS", "SHA256")
	for _, c := range sel {
		fmt.Fprintf(&b, "%-14s %-5s %-14d %-12d %-10s %s\n", c.Name, c.Type, c.Offset, c.Length, c.Status, c.SHA256)
	}
	return os.WriteFile(filepath.Join(outDir, "manifest.txt"), []byte(b.String()), 0o644)
}

// ---------------------------------------------------------------------------
// verify
// ---------------------------------------------------------------------------

type verifyFile struct {
	Name   string `json:"name"`
	Bytes  int64  `json:"size_bytes"`
	Format string `json:"format"`
	Valid  bool   `json:"valid"`
	Width  int    `json:"width"`
	Height int    `json:"height"`
	Error  string `json:"error,omitempty"`
}

type verifyJSON struct {
	Directory string       `json:"directory"`
	Files     []verifyFile `json:"files"`
	Valid     int          `json:"valid"`
	Invalid   int          `json:"invalid"`
	Skipped   int          `json:"skipped"`
	Result    string       `json:"result"`
}

var imageExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true}

func cmdVerify(dir string, asJSON bool) int {
	st, err := os.Stat(dir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return exitUsage
	}
	if !st.IsDir() {
		fmt.Fprintf(os.Stderr, "%s: %s is not a directory (verify takes the --out directory produced by recover)\n", toolName, dir)
		return exitUsage
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return exitUsage
	}
	var files []verifyFile
	skipped := 0
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if !imageExts[strings.ToLower(filepath.Ext(e.Name()))] {
			skipped++
			continue
		}
		files = append(files, decodeOne(filepath.Join(dir, e.Name()), e.Name()))
	}
	sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
	valid, invalid := 0, 0
	for _, f := range files {
		if f.Valid {
			valid++
		} else {
			invalid++
		}
	}
	result := "PASS"
	if invalid > 0 {
		result = "FAIL"
	}
	if asJSON {
		if files == nil {
			files = []verifyFile{}
		}
		if rc := emitJSON(verifyJSON{
			Directory: dir, Files: files, Valid: valid, Invalid: invalid,
			Skipped: skipped, Result: result,
		}); rc != 0 {
			return rc
		}
	} else {
		fmt.Printf("Directory: %s\n\n", dir)
		if len(files) == 0 {
			fmt.Printf("No decodable image files found (%d non-image file(s) skipped).\n", skipped)
			return 0
		}
		fmt.Printf("%-14s %-12s %-6s %-12s %s\n", "NAME", "SIZE", "FORMAT", "DIMENSIONS", "RESULT")
		for _, f := range files {
			d, res := "-", "VALID"
			if f.Valid {
				d = fmt.Sprintf("%dx%d", f.Width, f.Height)
			} else {
				res = "INVALID: " + f.Error
			}
			fmt.Printf("%-14s %-12d %-6s %-12s %s\n", f.Name, f.Bytes, f.Format, d, res)
		}
		fmt.Println()
		fmt.Printf("Result: %s  (%d fully decoded, %d damaged, %d non-image file(s) skipped)\n",
			result, valid, invalid, skipped)
	}
	if invalid > 0 {
		return exitCheckFails
	}
	return 0
}

func decodeOne(path, name string) verifyFile {
	v := verifyFile{Name: name, Format: "?"}
	st, err := os.Stat(path)
	if err == nil {
		v.Bytes = st.Size()
	}
	f, err := os.Open(path)
	if err != nil {
		v.Error = err.Error()
		return v
	}
	defer f.Close()
	if _, format, err := image.DecodeConfig(f); err == nil {
		v.Format = format
	}
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		v.Error = err.Error()
		return v
	}
	img, format, err := image.Decode(f)
	if err != nil {
		v.Error = err.Error()
		return v
	}
	v.Format = format
	b := img.Bounds()
	v.Width, v.Height = b.Dx(), b.Dy()
	v.Valid = true
	return v
}

// ---------------------------------------------------------------------------
// Plumbing.
// ---------------------------------------------------------------------------

func emitJSON(v any) int {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fmt.Fprintf(os.Stderr, "%s: encoding JSON: %v\n", toolName, err)
		return exitUsage
	}
	return 0
}

// parseSize accepts a plain byte count or a K/M/G suffixed size.
func parseSize(s string) (int64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, fmt.Errorf("empty size")
	}
	mult := int64(1)
	switch t[len(t)-1] {
	case 'k', 'K':
		mult, t = 1<<10, t[:len(t)-1]
	case 'm', 'M':
		mult, t = 1<<20, t[:len(t)-1]
	case 'g', 'G':
		mult, t = 1<<30, t[:len(t)-1]
	}
	n, err := strconv.ParseInt(strings.TrimSpace(t), 10, 64)
	if err != nil {
		return 0, fmt.Errorf("%q is not a byte count (try 4096, 512K or 16M)", s)
	}
	if n < 0 {
		return 0, fmt.Errorf("%q is negative", s)
	}
	return n * mult, nil
}

func parseTypes(s string) (map[string]bool, []string, error) {
	if strings.TrimSpace(s) == "" || s == "all" {
		return nil, knownKinds, nil
	}
	set := map[string]bool{}
	var list []string
	for _, raw := range strings.Split(s, ",") {
		t := strings.ToLower(strings.TrimSpace(raw))
		if t == "" {
			continue
		}
		if t == "jpg" {
			t = "jpeg"
		}
		known := false
		for _, k := range knownKinds {
			if k == t {
				known = true
			}
		}
		if !known {
			return nil, nil, fmt.Errorf("unknown type %q (known types: %s)", raw, strings.Join(knownKinds, ", "))
		}
		if !set[t] {
			set[t] = true
			list = append(list, t)
		}
	}
	if len(set) == 0 {
		return nil, nil, fmt.Errorf("--types was given no usable type names")
	}
	return set, list, nil
}

func usage(w io.Writer) {
	io.WriteString(w, `cardrecovery - read-only file carver for memory card images

Recovers files from a raw card image whose filesystem is gone by scanning the
raw bytes for file signatures. It never reads a directory structure and never
writes to the image it scans.

USAGE
  cardrecovery scan     <image> [--json] [--max-carve <size>]
  cardrecovery recover  <image> --out <dir> [--types <list>] [--min-size <size>]
                                [--max-carve <size>] [--apply] [--json]
  cardrecovery verify   <dir> [--json]

COMMANDS
  scan      Scan the raw image for known signatures and report every file that
            could be recovered: type, byte offset, length and confidence.
  recover   Carve the found files out into --out with sequential names.
            Dry run by default; pass --apply to actually write.
  verify    Fully decode each recovered JPEG/PNG/GIF in a directory and report
            which ones genuinely survived.
  help      Show this message.

OPTIONS
  --json               Emit machine-readable JSON instead of text.
  --out <dir>          Destination directory for recover (required).
  --types <list>       Comma-separated types to carve: jpeg,png,gif,pdf,zip.
  --min-size <size>    Skip carves smaller than this (e.g. 4096, 8K, 1M).
  --max-carve <size>   Cap for a header with no reachable footer (default 16M).
  --apply              Actually write the carved files (recover only).
  -h, --help           Show this message.

FORMATS
  jpeg  FFD8FF .. FFD9   walked marker by marker, so an embedded EXIF thumbnail
                         does not end the outer file early
  png   89504E47.. IEND  every chunk CRC32 is verified
  gif   GIF87a/89a .. 3B walked block by block to the trailer
  pdf   %PDF- .. %%EOF   last %%EOF before the next %PDF- header
  zip   PK\x03\x04 ..    end-of-central-directory, validated against the
                         central directory offset it declares

EXIT STATUS
  0   success
  1   usage error, or the image or directory could not be read
  2   verify only: at least one recovered file failed to decode

NOTES
  The image is opened read-only; only the --out directory is ever written to.
  The scanner reads in 1 MiB chunks with a 64-byte overlap so a signature that
  straddles a chunk boundary is still found.

EXAMPLES
  cardrecovery scan card.img
  cardrecovery scan card.img --json
  cardrecovery recover card.img --out ./out
  cardrecovery recover card.img --out ./out --types jpeg,png --min-size 8K --apply
  cardrecovery verify ./out
`)
}

func isHelpArg(s string) bool {
	switch s {
	case "-h", "--help", "help", "-help", "--h":
		return true
	}
	return false
}

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
		}
	}
	os.Exit(run(os.Args[1:]))
}

func run(argv []string) int {
	if len(argv) == 0 {
		usage(os.Stderr)
		return exitUsage
	}
	if isHelpArg(argv[0]) {
		usage(os.Stdout)
		return 0
	}

	cmd := argv[0]
	switch cmd {
	case "scan", "recover", "verify":
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n\n", toolName, cmd)
		usage(os.Stderr)
		return exitUsage
	}

	args := reorderFlags(argv[1:], map[string]bool{
		"out": true, "types": true, "min-size": true, "max-carve": true,
	})

	fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	asJSON := fs.Bool("json", false, "emit JSON")
	outDir := fs.String("out", "", "destination directory")
	typesArg := fs.String("types", "", "comma-separated types")
	minSizeArg := fs.String("min-size", "0", "minimum carve size")
	maxCarveArg := fs.String("max-carve", strconv.Itoa(defaultMaxCarve), "carve cap")
	apply := fs.Bool("apply", false, "write the carved files")
	if err := fs.Parse(args); err != nil {
		if err == flag.ErrHelp {
			usage(os.Stdout)
			return 0
		}
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage(os.Stderr)
		return exitUsage
	}

	rest := fs.Args()
	what := "image path"
	if cmd == "verify" {
		what = "directory"
	}
	if len(rest) == 0 {
		fmt.Fprintf(os.Stderr, "%s: %s requires a %s\n\n", toolName, cmd, what)
		usage(os.Stderr)
		return exitUsage
	}
	if len(rest) > 1 {
		fmt.Fprintf(os.Stderr, "%s: %s takes exactly one %s (got %d: %s)\n\n",
			toolName, cmd, what, len(rest), strings.Join(rest, ", "))
		usage(os.Stderr)
		return exitUsage
	}

	if cmd == "verify" {
		if *apply || *outDir != "" || *typesArg != "" {
			fmt.Fprintf(os.Stderr, "%s: verify accepts only --json\n\n", toolName)
			usage(os.Stderr)
			return exitUsage
		}
		return cmdVerify(rest[0], *asJSON)
	}

	minSize, err := parseSize(*minSizeArg)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: invalid --min-size: %v\n", toolName, err)
		return exitUsage
	}
	maxCarve, err := parseSize(*maxCarveArg)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: invalid --max-carve: %v\n", toolName, err)
		return exitUsage
	}
	if maxCarve < 512 {
		fmt.Fprintf(os.Stderr, "%s: --max-carve must be at least 512 bytes (got %d)\n", toolName, maxCarve)
		return exitUsage
	}
	types, typeList, err := parseTypes(*typesArg)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return exitUsage
	}

	if cmd == "scan" && (*apply || *outDir != "") {
		fmt.Fprintf(os.Stderr, "%s: scan does not write anything; --out and --apply belong to recover\n\n", toolName)
		usage(os.Stderr)
		return exitUsage
	}
	if cmd == "recover" && strings.TrimSpace(*outDir) == "" {
		fmt.Fprintf(os.Stderr, "%s: recover requires --out <dir>\n\n", toolName)
		usage(os.Stderr)
		return exitUsage
	}

	path := rest[0]
	im, err := openImage(path)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		return exitUsage
	}
	defer im.Close()
	if abs, aerr := filepath.Abs(path); aerr == nil {
		im.path = abs
	}

	if cmd == "scan" {
		return cmdScan(im, maxCarve, *asJSON)
	}
	return cmdRecover(im, *outDir, types, typeList, minSize, maxCarve, *apply, *asJSON)
}
