// Command zipdock is a read-only, multi-format archive inspector and safety
// scanner. It examines archives you received from somebody else and reports
// whether opening them is likely to be safe. It never modifies an archive and
// never extracts one.
package main

import (
	"archive/tar"
	"archive/zip"
	"compress/bzip2"
	"compress/gzip"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"hash/crc32"
	"io"
	"os"
	"path"
	"sort"
	"strconv"
	"strings"
	"text/tabwriter"
	"time"
	"unicode/utf8"
)

// streamLimit caps how many decompressed bytes zipdock will pull through a
// compressed container while enumerating it, so that a malicious archive
// cannot make the inspector itself run forever.
const streamLimit int64 = 8 << 30

const (
	exitOK      = 0
	exitError   = 1
	exitFinding = 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])
}

// ---------------------------------------------------------------------------
// model
// ---------------------------------------------------------------------------

type entryInfo struct {
	Path       string   `json:"path"`
	IsDir      bool     `json:"is_dir"`
	Compressed *int64   `json:"compressed_size"`
	Size       int64    `json:"uncompressed_size"`
	Ratio      *float64 `json:"ratio"`
	Modified   string   `json:"modified"`
	CRC32      *string  `json:"crc32,omitempty"`
	Method     string   `json:"method,omitempty"`
	LinkType   string   `json:"link_type,omitempty"`
	LinkTarget string   `json:"link_target,omitempty"`
}

type archive struct {
	Path         string
	Format       string
	FileSize     int64
	Entries      []entryInfo
	TotalComp    int64
	TotalUncomp  int64
	PerEntryComp bool
	Truncated    bool
	Notes        []string
}

func (a *archive) counts() (files, dirs int) {
	for _, e := range a.Entries {
		if e.IsDir {
			dirs++
		} else {
			files++
		}
	}
	return
}

func (a *archive) overallRatio() float64 {
	if a.TotalComp <= 0 {
		return 0
	}
	return float64(a.TotalUncomp) / float64(a.TotalComp)
}

// ---------------------------------------------------------------------------
// format detection
// ---------------------------------------------------------------------------

var errUnknownFormat = errors.New("unrecognised archive format (not zip, tar, tar.gz, gzip, tar.bz2 or bzip2)")

func hasTarMagic(head []byte) bool {
	if len(head) < 265 {
		return false
	}
	m := string(head[257:263])
	return m == "ustar\x00" || m == "ustar " || string(head[257:262]) == "ustar"
}

// innerIsTar decompresses at most one tar header block from r to see whether
// the compressed stream wraps a tar archive.
func innerIsTar(r io.Reader) bool {
	head := make([]byte, 512)
	n, err := io.ReadFull(r, head)
	if err != nil && n < 265 {
		return false
	}
	return hasTarMagic(head[:n])
}

func detectFormat(f *os.File) (string, error) {
	head := make([]byte, 1024)
	n, err := f.Read(head)
	if err != nil && err != io.EOF {
		return "", err
	}
	head = head[:n]
	if n == 0 {
		return "", errors.New("file is empty")
	}
	switch {
	case n >= 4 && head[0] == 'P' && head[1] == 'K' &&
		((head[2] == 3 && head[3] == 4) || (head[2] == 5 && head[3] == 6) || (head[2] == 7 && head[3] == 8)):
		return "zip", nil
	case n >= 2 && head[0] == 0x1f && head[1] == 0x8b:
		if _, err := f.Seek(0, io.SeekStart); err != nil {
			return "", err
		}
		zr, err := gzip.NewReader(f)
		if err != nil {
			return "", fmt.Errorf("gzip header: %w", err)
		}
		isTar := innerIsTar(zr)
		zr.Close()
		if isTar {
			return "tar.gz", nil
		}
		return "gzip", nil
	case n >= 3 && head[0] == 'B' && head[1] == 'Z' && head[2] == 'h':
		if _, err := f.Seek(0, io.SeekStart); err != nil {
			return "", err
		}
		if innerIsTar(bzip2.NewReader(f)) {
			return "tar.bz2", nil
		}
		return "bzip2", nil
	case hasTarMagic(head):
		return "tar", nil
	}
	return "", errUnknownFormat
}

// ---------------------------------------------------------------------------
// reading
// ---------------------------------------------------------------------------

var zipMethods = map[uint16]string{
	0: "Store", 1: "Shrink", 6: "Implode", 8: "Deflate", 9: "Deflate64",
	12: "BZip2", 14: "LZMA", 93: "Zstandard", 95: "XZ", 96: "JPEG",
	97: "WavPack", 98: "PPMd",
}

func methodName(m uint16) string {
	if s, ok := zipMethods[m]; ok {
		return s
	}
	return fmt.Sprintf("Method(%d)", m)
}

func fmtTime(t time.Time) string {
	if t.IsZero() {
		return "-"
	}
	return t.UTC().Format("2006-01-02 15:04:05Z")
}

// fmtRatio keeps very small ratios (a tar of tiny files inside a padded
// container, say) from collapsing to a meaningless "0.00:1".
func fmtRatio(r float64) string {
	if r > 0 && r < 0.01 {
		return fmt.Sprintf("%.4f:1", r)
	}
	return fmt.Sprintf("%.2f:1", r)
}

func ratioOf(uncomp, comp int64) *float64 {
	if comp <= 0 {
		return nil
	}
	r := float64(uncomp) / float64(comp)
	return &r
}

func readArchive(pathname string) (*archive, error) {
	f, err := os.Open(pathname)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	st, err := f.Stat()
	if err != nil {
		return nil, err
	}
	if st.IsDir() {
		return nil, fmt.Errorf("%s is a directory, not an archive", pathname)
	}
	if st.Size() == 0 {
		return nil, errors.New("file is empty (0 bytes)")
	}

	format, err := detectFormat(f)
	if err != nil {
		return nil, err
	}

	a := &archive{Path: pathname, Format: format, FileSize: st.Size()}

	switch format {
	case "zip":
		if err := readZip(a, f, st.Size()); err != nil {
			return nil, err
		}
	case "tar":
		if _, err := f.Seek(0, io.SeekStart); err != nil {
			return nil, err
		}
		if err := readTar(a, f, false); err != nil {
			return nil, err
		}
		a.TotalComp = st.Size()
	case "tar.gz", "tar.bz2":
		r, closer, err := decompressor(f, format)
		if err != nil {
			return nil, err
		}
		err = readTar(a, r, true)
		if closer != nil {
			closer.Close()
		}
		if err != nil {
			return nil, err
		}
		a.TotalComp = st.Size()
	case "gzip", "bzip2":
		if err := readSingleStream(a, f, format, st); err != nil {
			return nil, err
		}
		a.TotalComp = st.Size()
	}
	return a, nil
}

func decompressor(f *os.File, format string) (io.Reader, io.Closer, error) {
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		return nil, nil, err
	}
	switch format {
	case "tar.gz", "gzip":
		zr, err := gzip.NewReader(f)
		if err != nil {
			return nil, nil, fmt.Errorf("gzip: %w", err)
		}
		return zr, zr, nil
	default:
		return bzip2.NewReader(f), nil, nil
	}
}

func readZip(a *archive, f *os.File, size int64) error {
	zr, err := zip.NewReader(f, size)
	if err != nil {
		return fmt.Errorf("cannot read the zip central directory: %w", err)
	}
	a.PerEntryComp = true
	for _, zf := range zr.File {
		isDir := strings.HasSuffix(zf.Name, "/") || zf.FileInfo().IsDir()
		comp := int64(zf.CompressedSize64)
		uncomp := int64(zf.UncompressedSize64)
		crcStr := fmt.Sprintf("%08x", zf.CRC32)
		e := entryInfo{
			Path:       zf.Name,
			IsDir:      isDir,
			Compressed: &comp,
			Size:       uncomp,
			Ratio:      ratioOf(uncomp, comp),
			Modified:   fmtTime(zf.Modified),
			CRC32:      &crcStr,
			Method:     methodName(zf.Method),
		}
		a.Entries = append(a.Entries, e)
		a.TotalComp += comp
		a.TotalUncomp += uncomp
	}
	return nil
}

func tarTypeInfo(flag byte) (string, bool) {
	switch flag {
	case tar.TypeDir:
		return "", true
	case tar.TypeSymlink:
		return "symlink", false
	case tar.TypeLink:
		return "hardlink", false
	case tar.TypeChar:
		return "chardev", false
	case tar.TypeBlock:
		return "blockdev", false
	case tar.TypeFifo:
		return "fifo", false
	}
	return "", false
}

func readTar(a *archive, r io.Reader, compressed bool) error {
	tr := tar.NewReader(r)
	var consumed int64
	for {
		h, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			if len(a.Entries) > 0 {
				a.Notes = append(a.Notes, fmt.Sprintf("tar stream ended early after %d entries: %v", len(a.Entries), err))
				break
			}
			return fmt.Errorf("tar: %w", err)
		}
		linkType, isDir := tarTypeInfo(h.Typeflag)
		name := h.Name
		if isDir {
			name = strings.TrimSuffix(name, "/") + "/"
		}
		e := entryInfo{
			Path:       name,
			IsDir:      isDir,
			Size:       h.Size,
			Modified:   fmtTime(h.ModTime),
			LinkType:   linkType,
			LinkTarget: h.Linkname,
		}
		if !compressed {
			// A plain tar stores members verbatim: stored size == real size.
			c := h.Size
			e.Compressed = &c
			e.Ratio = ratioOf(h.Size, c)
		}
		a.Entries = append(a.Entries, e)
		a.TotalUncomp += h.Size
		consumed += h.Size
		if consumed > streamLimit {
			a.Truncated = true
			a.Notes = append(a.Notes, fmt.Sprintf("stopped after %s of member data (safety limit)", humanBytes(consumed)))
			break
		}
	}
	if !compressed {
		a.PerEntryComp = true
	}
	if len(a.Entries) == 0 {
		a.Notes = append(a.Notes, "tar archive contains no entries")
	}
	return nil
}

// countingDiscard measures a stream without keeping any of it.
type countingDiscard struct{ n int64 }

func (c *countingDiscard) Write(p []byte) (int, error) {
	c.n += int64(len(p))
	return len(p), nil
}

func readSingleStream(a *archive, f *os.File, format string, st os.FileInfo) error {
	r, closer, err := decompressor(f, format)
	if err != nil {
		return err
	}
	if closer != nil {
		defer closer.Close()
	}
	name := strings.TrimSuffix(path.Base(a.Path), ".gz")
	name = strings.TrimSuffix(name, ".bz2")
	if zr, ok := r.(*gzip.Reader); ok && zr.Name != "" {
		name = zr.Name
	}
	mod := st.ModTime()
	if zr, ok := r.(*gzip.Reader); ok && !zr.ModTime.IsZero() {
		mod = zr.ModTime
	}
	var cd countingDiscard
	_, err = io.Copy(&cd, io.LimitReader(r, streamLimit+1))
	if err != nil {
		return fmt.Errorf("%s: %w", format, err)
	}
	if cd.n > streamLimit {
		a.Truncated = true
		a.Notes = append(a.Notes, fmt.Sprintf("stopped after %s of decompressed data (safety limit)", humanBytes(cd.n)))
	}
	comp := st.Size()
	a.Entries = append(a.Entries, entryInfo{
		Path:       name,
		Size:       cd.n,
		Compressed: &comp,
		Ratio:      ratioOf(cd.n, comp),
		Modified:   fmtTime(mod),
	})
	a.TotalUncomp = cd.n
	a.PerEntryComp = true
	return nil
}

// ---------------------------------------------------------------------------
// info
// ---------------------------------------------------------------------------

type infoJSON struct {
	Path              string   `json:"path"`
	Format            string   `json:"format"`
	FileSize          int64    `json:"file_size"`
	Entries           int      `json:"entries"`
	Files             int      `json:"files"`
	Directories       int      `json:"directories"`
	TotalCompressed   int64    `json:"total_compressed"`
	TotalUncompressed int64    `json:"total_uncompressed"`
	Ratio             float64  `json:"ratio"`
	Truncated         bool     `json:"truncated"`
	Notes             []string `json:"notes"`
}

func cmdInfo(args []string) int {
	fs, jsonOut := newFlagSet("info")
	target, code := parseOne(fs, args, "info")
	if target == "" {
		return code
	}
	a, err := readArchive(target)
	if err != nil {
		return fail(err)
	}
	files, dirs := a.counts()
	if *jsonOut {
		notes := a.Notes
		if notes == nil {
			notes = []string{}
		}
		return emitJSON(infoJSON{
			Path: a.Path, Format: a.Format, FileSize: a.FileSize,
			Entries: len(a.Entries), Files: files, Directories: dirs,
			TotalCompressed: a.TotalComp, TotalUncompressed: a.TotalUncomp,
			Ratio: a.overallRatio(), Truncated: a.Truncated, Notes: notes,
		})
	}
	fmt.Printf("Archive:      %s\n", a.Path)
	fmt.Printf("Format:       %s\n", a.Format)
	fmt.Printf("Size on disk: %s (%d bytes)\n", humanBytes(a.FileSize), a.FileSize)
	fmt.Printf("Entries:      %d (%d files, %d directories)\n", len(a.Entries), files, dirs)
	fmt.Printf("Compressed:   %s (%d bytes)\n", humanBytes(a.TotalComp), a.TotalComp)
	fmt.Printf("Uncompressed: %s (%d bytes)\n", humanBytes(a.TotalUncomp), a.TotalUncomp)
	if r := a.overallRatio(); r > 0 {
		fmt.Printf("Ratio:        %s\n", fmtRatio(r))
	} else {
		fmt.Printf("Ratio:        n/a\n")
	}
	if !a.PerEntryComp {
		fmt.Printf("Note:         %s stores no per-entry compressed size; ratio is archive-wide\n", a.Format)
	}
	for _, n := range a.Notes {
		fmt.Printf("Note:         %s\n", n)
	}
	return exitOK
}

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

type listJSON struct {
	Path    string      `json:"path"`
	Format  string      `json:"format"`
	Entries []entryInfo `json:"entries"`
}

func cmdList(args []string) int {
	fs, jsonOut := newFlagSet("list")
	target, code := parseOne(fs, args, "list")
	if target == "" {
		return code
	}
	a, err := readArchive(target)
	if err != nil {
		return fail(err)
	}
	if *jsonOut {
		entries := a.Entries
		if entries == nil {
			entries = []entryInfo{}
		}
		return emitJSON(listJSON{Path: a.Path, Format: a.Format, Entries: entries})
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	isZip := a.Format == "zip"
	if isZip {
		fmt.Fprintln(w, "UNCOMPRESSED\tCOMPRESSED\tRATIO\tMETHOD\tCRC32\tMODIFIED\tPATH")
	} else {
		fmt.Fprintln(w, "UNCOMPRESSED\tCOMPRESSED\tRATIO\tTYPE\tMODIFIED\tPATH")
	}
	for _, e := range a.Entries {
		comp := "n/a"
		if e.Compressed != nil {
			comp = strconv.FormatInt(*e.Compressed, 10)
		}
		ratio := "n/a"
		if e.Ratio != nil {
			ratio = fmtRatio(*e.Ratio)
		}
		name := e.Path
		if e.LinkTarget != "" {
			name = fmt.Sprintf("%s -> %s", e.Path, e.LinkTarget)
		}
		if isZip {
			crc := "-"
			if e.CRC32 != nil {
				crc = *e.CRC32
			}
			fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Size, comp, ratio, e.Method, crc, e.Modified, name)
			continue
		}
		typ := "file"
		if e.IsDir {
			typ = "dir"
		} else if e.LinkType != "" {
			typ = e.LinkType
		}
		fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", e.Size, comp, ratio, typ, e.Modified, name)
	}
	w.Flush()
	fmt.Printf("%d entries\n", len(a.Entries))
	return exitOK
}

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

type finding struct {
	Check   string   `json:"check"`
	Status  string   `json:"status"`
	Summary string   `json:"summary"`
	Details []string `json:"details"`
}

type scanJSON struct {
	Path     string    `json:"path"`
	Format   string    `json:"format"`
	Entries  int       `json:"entries"`
	MaxRatio float64   `json:"max_ratio"`
	MaxTotal int64     `json:"max_total"`
	Findings []finding `json:"findings"`
	Failed   int       `json:"failed"`
	Warned   int       `json:"warned"`
	Passed   int       `json:"passed"`
	Result   string    `json:"result"`
}

func worse(cur, next string) string {
	rank := map[string]int{"PASS": 0, "WARN": 1, "FAIL": 2}
	if rank[next] > rank[cur] {
		return next
	}
	return cur
}

func normPath(p string) string {
	n := strings.ReplaceAll(p, "\\", "/")
	n = strings.TrimSuffix(n, "/")
	if n == "" {
		return "."
	}
	return path.Clean(n)
}

func components(p string) []string {
	n := strings.ReplaceAll(p, "\\", "/")
	return strings.Split(n, "/")
}

func checkBomb(a *archive, maxRatio float64, maxTotal int64) finding {
	f := finding{Check: "ZIP BOMB", Status: "PASS"}
	overall := a.overallRatio()
	f.Summary = fmt.Sprintf("overall ratio %s, total uncompressed %s", fmtRatio(overall), humanBytes(a.TotalUncomp))
	if overall > maxRatio {
		f.Status = "FAIL"
		f.Details = append(f.Details, fmt.Sprintf("archive overall ratio %s exceeds --max-ratio %.2f (%d bytes -> %d bytes)",
			fmtRatio(overall), maxRatio, a.TotalComp, a.TotalUncomp))
	} else if overall > maxRatio*0.8 {
		f.Status = worse(f.Status, "WARN")
		f.Details = append(f.Details, fmt.Sprintf("archive overall ratio %s is within 20%% of --max-ratio %.2f", fmtRatio(overall), maxRatio))
	}
	if a.TotalUncomp > maxTotal {
		f.Status = "FAIL"
		f.Details = append(f.Details, fmt.Sprintf("total uncompressed size %s (%d bytes) exceeds --max-total %s (%d bytes)",
			humanBytes(a.TotalUncomp), a.TotalUncomp, humanBytes(maxTotal), maxTotal))
	}
	for _, e := range a.Entries {
		if e.Ratio == nil || e.IsDir {
			continue
		}
		if *e.Ratio > maxRatio {
			f.Status = "FAIL"
			f.Details = append(f.Details, fmt.Sprintf("entry %q ratio %s exceeds --max-ratio %.2f (%d bytes -> %d bytes)",
				e.Path, fmtRatio(*e.Ratio), maxRatio, *e.Compressed, e.Size))
		} else if *e.Ratio > maxRatio*0.8 {
			f.Status = worse(f.Status, "WARN")
			f.Details = append(f.Details, fmt.Sprintf("entry %q ratio %s is within 20%% of --max-ratio %.2f", e.Path, fmtRatio(*e.Ratio), maxRatio))
		}
	}
	if a.Truncated {
		f.Status = worse(f.Status, "WARN")
		f.Details = append(f.Details, "archive exceeded the internal streaming limit; totals are a lower bound")
	}
	if f.Status == "PASS" {
		f.Details = append(f.Details, fmt.Sprintf("no entry exceeds --max-ratio %.2f and the archive stays under --max-total %s", maxRatio, humanBytes(maxTotal)))
	}
	return f
}

func checkTraversal(a *archive) finding {
	f := finding{Check: "PATH TRAVERSAL", Status: "PASS"}
	bad := 0
	for _, e := range a.Entries {
		n := e.Path
		switch {
		case strings.HasPrefix(n, "/"), strings.HasPrefix(n, "\\"):
			f.Status = "FAIL"
			bad++
			f.Details = append(f.Details, fmt.Sprintf("entry %q is an absolute path and would write outside the extraction directory", n))
		case len(n) >= 2 && isDriveLetter(n[0]) && n[1] == ':':
			f.Status = "FAIL"
			bad++
			f.Details = append(f.Details, fmt.Sprintf("entry %q carries a Windows drive letter and would write outside the extraction directory", n))
		default:
			esc := false
			for _, c := range components(n) {
				if c == ".." {
					esc = true
				}
			}
			if esc {
				f.Status = "FAIL"
				bad++
				f.Details = append(f.Details, fmt.Sprintf("entry %q contains a %q segment (zip slip) and would escape the extraction directory", n, ".."))
			}
		}
		if strings.Contains(n, "\\") && !strings.HasPrefix(n, "\\") {
			f.Status = worse(f.Status, "WARN")
			f.Details = append(f.Details, fmt.Sprintf("entry %q uses backslash separators, which some extractors treat as directories", n))
		}
	}
	if f.Status == "FAIL" {
		f.Summary = fmt.Sprintf("%d entr%s would write outside the extraction directory", bad, plural(bad, "y", "ies"))
	} else {
		f.Summary = "all entry paths stay inside the extraction directory"
		if f.Status == "PASS" {
			f.Details = append(f.Details, fmt.Sprintf("checked %d entries for \"..\" segments, absolute paths and drive letters", len(a.Entries)))
		}
	}
	return f
}

func checkLinks(a *archive) finding {
	f := finding{Check: "SYMLINK ENTRIES", Status: "PASS"}
	links, escaping := 0, 0
	for _, e := range a.Entries {
		if e.LinkType == "" {
			continue
		}
		links++
		t := e.LinkTarget
		escapes := strings.HasPrefix(t, "/") || strings.HasPrefix(t, "\\") ||
			(len(t) >= 2 && isDriveLetter(t[0]) && t[1] == ':')
		if !escapes {
			joined := path.Join(path.Dir(normPath(e.Path)), strings.ReplaceAll(t, "\\", "/"))
			if joined == ".." || strings.HasPrefix(joined, "../") {
				escapes = true
			}
		}
		if escapes {
			escaping++
			f.Status = "FAIL"
			f.Details = append(f.Details, fmt.Sprintf("%s entry %q points to %q, which resolves outside the archive", e.LinkType, e.Path, t))
		} else {
			f.Status = worse(f.Status, "WARN")
			f.Details = append(f.Details, fmt.Sprintf("%s entry %q points to %q inside the archive", e.LinkType, e.Path, t))
		}
	}
	switch {
	case escaping > 0:
		f.Summary = fmt.Sprintf("%d of %d link entr%s point outside the archive", escaping, links, plural(escaping, "y", "ies"))
	case links > 0:
		f.Summary = fmt.Sprintf("%d link entr%s, all resolving inside the archive", links, plural(links, "y", "ies"))
	default:
		f.Summary = "no symlink or hardlink entries"
		f.Details = append(f.Details, "archive contains only regular files and directories")
	}
	return f
}

func checkCollisions(a *archive) finding {
	f := finding{Check: "NAME COLLISIONS", Status: "PASS"}
	exact := map[string][]string{}
	fold := map[string][]string{}
	var order []string
	for _, e := range a.Entries {
		n := normPath(e.Path)
		if _, seen := exact[n]; !seen {
			order = append(order, n)
		}
		exact[n] = append(exact[n], e.Path)
		lf := strings.ToLower(n)
		fold[lf] = append(fold[lf], e.Path)
	}
	dupes, folds := 0, 0
	for _, n := range order {
		if len(exact[n]) > 1 {
			dupes++
			f.Status = "FAIL"
			f.Details = append(f.Details, fmt.Sprintf("%d entries resolve to the same path %q: %s", len(exact[n]), n, strings.Join(exact[n], ", ")))
		}
	}
	var foldKeys []string
	for k := range fold {
		foldKeys = append(foldKeys, k)
	}
	sort.Strings(foldKeys)
	for _, k := range foldKeys {
		names := fold[k]
		if len(names) < 2 {
			continue
		}
		distinct := map[string]bool{}
		for _, n := range names {
			distinct[normPath(n)] = true
		}
		if len(distinct) < 2 {
			continue // already reported as an exact duplicate
		}
		folds++
		f.Status = worse(f.Status, "WARN")
		f.Details = append(f.Details, fmt.Sprintf("case-insensitive collision on %q: %s (these overwrite each other on Windows and macOS)", k, strings.Join(names, ", ")))
	}
	switch {
	case dupes > 0:
		f.Summary = fmt.Sprintf("%d duplicate path%s, %d case-insensitive collision%s", dupes, plural(dupes, "", "s"), folds, plural(folds, "", "s"))
	case folds > 0:
		f.Summary = fmt.Sprintf("%d case-insensitive collision%s", folds, plural(folds, "", "s"))
	default:
		f.Summary = "every entry writes to a distinct path"
		f.Details = append(f.Details, fmt.Sprintf("checked %d entries for exact and case-insensitive duplicates", len(a.Entries)))
	}
	return f
}

var reservedWindows = map[string]bool{
	"CON": true, "PRN": true, "AUX": true, "NUL": true,
	"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
	"COM6": true, "COM7": true, "COM8": true, "COM9": true,
	"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
	"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}

func checkNames(a *archive) finding {
	f := finding{Check: "SUSPICIOUS NAMES", Status: "PASS"}
	bad := 0
	for _, e := range a.Entries {
		n := e.Path
		if ctl, off := firstControlChar(n); ctl {
			bad++
			f.Status = "FAIL"
			f.Details = append(f.Details, fmt.Sprintf("entry %q contains a control character (0x%02x at byte %d)", n, n[off], off))
		}
		if !utf8.ValidString(n) {
			f.Status = worse(f.Status, "WARN")
			f.Details = append(f.Details, fmt.Sprintf("entry %q is not valid UTF-8", n))
		}
		for _, c := range components(strings.TrimSuffix(n, "/")) {
			if c == "" || c == "." || c == ".." {
				continue
			}
			if strings.HasSuffix(c, ".") || strings.HasSuffix(c, " ") {
				f.Status = worse(f.Status, "WARN")
				f.Details = append(f.Details, fmt.Sprintf("entry %q has a component %q ending in a dot or space, which Windows silently strips", n, c))
			}
			base := strings.ToUpper(c)
			if i := strings.IndexByte(base, '.'); i > 0 {
				base = base[:i]
			}
			if reservedWindows[base] {
				f.Status = worse(f.Status, "WARN")
				f.Details = append(f.Details, fmt.Sprintf("entry %q uses the reserved Windows device name %q", n, base))
			}
		}
	}
	if bad > 0 {
		f.Summary = fmt.Sprintf("%d entr%s with control characters in the name", bad, plural(bad, "y", "ies"))
	} else if f.Status == "WARN" {
		f.Summary = "some entry names would behave badly on Windows"
	} else {
		f.Summary = "no control characters, trailing dots/spaces or reserved names"
		f.Details = append(f.Details, fmt.Sprintf("checked %d entry names", len(a.Entries)))
	}
	return f
}

func firstControlChar(s string) (bool, int) {
	for i := 0; i < len(s); i++ {
		if s[i] < 0x20 || s[i] == 0x7f {
			return true, i
		}
	}
	return false, -1
}

func isDriveLetter(b byte) bool {
	return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}

func plural(n int, one, many string) string {
	if n == 1 {
		return one
	}
	return many
}

func cmdScan(args []string) int {
	fs := flag.NewFlagSet("scan", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	jsonOut := fs.Bool("json", false, "emit JSON")
	maxRatio := fs.Float64("max-ratio", 100, "maximum tolerated uncompressed:compressed ratio")
	maxTotal := fs.String("max-total", "1GiB", "maximum tolerated total uncompressed size")
	args = reorderFlags(args, map[string]bool{"max-ratio": true, "max-total": true})
	if code, done := helpOrParse(fs, args, "scan"); done {
		return code
	}
	if fs.NArg() != 1 {
		usage()
		return exitError
	}
	limit, err := parseSize(*maxTotal)
	if err != nil {
		return fail(fmt.Errorf("--max-total: %w", err))
	}
	if *maxRatio <= 0 {
		return fail(errors.New("--max-ratio must be greater than zero"))
	}
	a, err := readArchive(fs.Arg(0))
	if err != nil {
		return fail(err)
	}

	findings := []finding{
		checkBomb(a, *maxRatio, limit),
		checkTraversal(a),
		checkLinks(a),
		checkCollisions(a),
		checkNames(a),
	}
	failed, warned, passed := 0, 0, 0
	for _, f := range findings {
		switch f.Status {
		case "FAIL":
			failed++
		case "WARN":
			warned++
		default:
			passed++
		}
	}
	result := "PASS"
	if warned > 0 {
		result = "WARN"
	}
	if failed > 0 {
		result = "FAIL"
	}

	if *jsonOut {
		for i := range findings {
			if findings[i].Details == nil {
				findings[i].Details = []string{}
			}
		}
		if code := emitJSON(scanJSON{
			Path: a.Path, Format: a.Format, Entries: len(a.Entries),
			MaxRatio: *maxRatio, MaxTotal: limit, Findings: findings,
			Failed: failed, Warned: warned, Passed: passed, Result: result,
		}); code != exitOK {
			return code
		}
	} else {
		fmt.Printf("SAFETY SCAN   %s\n", a.Path)
		fmt.Printf("Format        %s, %d entries, %s uncompressed\n", a.Format, len(a.Entries), humanBytes(a.TotalUncomp))
		fmt.Printf("Thresholds    --max-ratio %.2f  --max-total %s\n\n", *maxRatio, humanBytes(limit))
		for _, f := range findings {
			fmt.Printf("[%s] %s: %s\n", f.Status, f.Check, f.Summary)
			for _, d := range f.Details {
				fmt.Printf("       - %s\n", d)
			}
		}
		fmt.Printf("\nRESULT: %s (%d failed, %d warned, %d passed)\n", result, failed, warned, passed)
	}
	if failed > 0 {
		return exitFinding
	}
	return exitOK
}

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

type verifyEntry struct {
	Path     string `json:"path"`
	Status   string `json:"status"`
	Expected string `json:"expected_crc32,omitempty"`
	Actual   string `json:"actual_crc32,omitempty"`
	Size     int64  `json:"size"`
	Detail   string `json:"detail,omitempty"`
}

type verifyJSON struct {
	Path    string        `json:"path"`
	Format  string        `json:"format"`
	Checked int           `json:"checked"`
	OK      int           `json:"ok"`
	Failed  int           `json:"failed"`
	Result  string        `json:"result"`
	Note    string        `json:"note,omitempty"`
	Entries []verifyEntry `json:"entries"`
}

func verifyZip(pathname string) ([]verifyEntry, string, error) {
	f, err := os.Open(pathname)
	if err != nil {
		return nil, "", err
	}
	defer f.Close()
	st, err := f.Stat()
	if err != nil {
		return nil, "", err
	}
	zr, err := zip.NewReader(f, st.Size())
	if err != nil {
		return nil, "", fmt.Errorf("cannot read the zip central directory: %w", err)
	}
	var out []verifyEntry
	for _, zf := range zr.File {
		if strings.HasSuffix(zf.Name, "/") || zf.FileInfo().IsDir() {
			continue
		}
		ve := verifyEntry{Path: zf.Name, Expected: fmt.Sprintf("%08x", zf.CRC32)}
		rc, err := zf.Open()
		if err != nil {
			ve.Status = "FAIL"
			ve.Detail = fmt.Sprintf("cannot open entry: %v", err)
			out = append(out, ve)
			continue
		}
		h := crc32.NewIEEE()
		n, cerr := io.Copy(h, rc)
		rc.Close()
		ve.Size = n
		ve.Actual = fmt.Sprintf("%08x", h.Sum32())
		switch {
		case cerr != nil && errors.Is(cerr, zip.ErrChecksum):
			ve.Status = "FAIL"
			ve.Detail = "CRC32 MISMATCH: decompressed data does not match the checksum in the central directory"
		case cerr != nil:
			ve.Status = "FAIL"
			ve.Detail = fmt.Sprintf("DECOMPRESSION FAILED: the compressed stream for this entry is damaged (%v)", cerr)
		case ve.Actual != ve.Expected:
			ve.Status = "FAIL"
			ve.Detail = "CRC32 MISMATCH: decompressed data does not match the checksum in the central directory"
		case uint64(n) != zf.UncompressedSize64:
			ve.Status = "FAIL"
			ve.Detail = fmt.Sprintf("SIZE MISMATCH: header says %d bytes, stream produced %d", zf.UncompressedSize64, n)
		default:
			ve.Status = "OK"
		}
		out = append(out, ve)
	}
	return out, "", nil
}

func verifyStream(pathname, format string) ([]verifyEntry, string, error) {
	f, err := os.Open(pathname)
	if err != nil {
		return nil, "", err
	}
	defer f.Close()
	r, closer, err := decompressor(f, format)
	if err != nil {
		return nil, "", err
	}
	if closer != nil {
		defer closer.Close()
	}
	name := strings.TrimSuffix(path.Base(pathname), ".gz")
	name = strings.TrimSuffix(name, ".bz2")
	if zr, ok := r.(*gzip.Reader); ok && zr.Name != "" {
		name = zr.Name
	}
	h := crc32.NewIEEE()
	n, cerr := io.Copy(h, r)
	ve := verifyEntry{Path: name, Size: n, Actual: fmt.Sprintf("%08x", h.Sum32())}
	note := ""
	switch format {
	case "gzip", "tar.gz":
		if crc, ok := gzipTrailerCRC(f); ok {
			ve.Expected = fmt.Sprintf("%08x", crc)
		}
		note = "gzip trailer CRC32 and ISIZE validated over the whole decompressed stream"
		if cerr != nil {
			ve.Status = "FAIL"
			ve.Detail = fmt.Sprintf("gzip trailer check failed: %v", cerr)
		} else {
			ve.Status = "OK"
			ve.Detail = "gzip trailer CRC32/ISIZE match the decompressed stream"
		}
	default:
		note = "bzip2 carries per-block CRCs; a read error means the stream is damaged"
		if cerr != nil {
			ve.Status = "FAIL"
			ve.Detail = fmt.Sprintf("bzip2 block check failed: %v", cerr)
		} else {
			ve.Status = "OK"
			ve.Detail = "all bzip2 block CRCs are intact"
		}
	}
	return []verifyEntry{ve}, note, nil
}

// gzipTrailerCRC returns the CRC32 recorded in the final gzip member's
// 8-byte trailer, which is what the decompressed stream must hash to.
func gzipTrailerCRC(f *os.File) (uint32, bool) {
	st, err := f.Stat()
	if err != nil || st.Size() < 8 {
		return 0, false
	}
	var buf [8]byte
	if _, err := f.ReadAt(buf[:], st.Size()-8); err != nil {
		return 0, false
	}
	return uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24, true
}

func verifyTar(pathname, format string) ([]verifyEntry, string, error) {
	f, err := os.Open(pathname)
	if err != nil {
		return nil, "", err
	}
	defer f.Close()
	var r io.Reader = f
	if format != "tar" {
		dr, closer, err := decompressor(f, format)
		if err != nil {
			return nil, "", err
		}
		if closer != nil {
			defer closer.Close()
		}
		r = dr
	}
	tr := tar.NewReader(r)
	var out []verifyEntry
	for {
		h, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			out = append(out, verifyEntry{Path: "<stream>", Status: "FAIL",
				Detail: fmt.Sprintf("tar stream damaged: %v", err)})
			break
		}
		if h.Typeflag != tar.TypeReg {
			continue
		}
		hh := crc32.NewIEEE()
		n, cerr := io.Copy(hh, tr)
		ve := verifyEntry{Path: h.Name, Size: n, Actual: fmt.Sprintf("%08x", hh.Sum32())}
		switch {
		case cerr != nil:
			ve.Status = "FAIL"
			ve.Detail = fmt.Sprintf("read error: %v", cerr)
		case n != h.Size:
			ve.Status = "FAIL"
			ve.Detail = fmt.Sprintf("SIZE MISMATCH: header says %d bytes, stream produced %d", h.Size, n)
		default:
			ve.Status = "OK"
			ve.Detail = "header size matches the stored bytes (tar carries no content checksum)"
		}
		out = append(out, ve)
	}
	note := "tar has no per-entry content checksum; zipdock verifies header checksums, structure and declared sizes"
	if format == "tar.gz" {
		note += ", plus the gzip trailer CRC32 over the whole stream"
	}
	if format == "tar.bz2" {
		note += ", plus the bzip2 per-block CRCs"
	}
	return out, note, nil
}

func cmdVerify(args []string) int {
	fs, jsonOut := newFlagSet("verify")
	target, code := parseOne(fs, args, "verify")
	if target == "" {
		return code
	}
	f, err := os.Open(target)
	if err != nil {
		return fail(err)
	}
	st, err := f.Stat()
	if err != nil {
		f.Close()
		return fail(err)
	}
	if st.IsDir() {
		f.Close()
		return fail(fmt.Errorf("%s is a directory, not an archive", target))
	}
	if st.Size() == 0 {
		f.Close()
		return fail(errors.New("file is empty (0 bytes)"))
	}
	format, err := detectFormat(f)
	f.Close()
	if err != nil {
		return fail(err)
	}

	var entries []verifyEntry
	var note string
	switch format {
	case "zip":
		entries, note, err = verifyZip(target)
	case "gzip", "bzip2":
		entries, note, err = verifyStream(target, format)
	default:
		entries, note, err = verifyTar(target, format)
		if format == "tar.gz" || format == "tar.bz2" {
			extra, _, serr := verifyStream(target, format)
			if serr == nil && len(extra) == 1 {
				extra[0].Path = "<whole stream>"
				entries = append(entries, extra[0])
			}
		}
	}
	if err != nil {
		return fail(err)
	}

	ok, bad := 0, 0
	for _, e := range entries {
		if e.Status == "OK" {
			ok++
		} else {
			bad++
		}
	}
	result := "PASS"
	if bad > 0 {
		result = "FAIL"
	}
	if *jsonOut {
		if entries == nil {
			entries = []verifyEntry{}
		}
		if code := emitJSON(verifyJSON{
			Path: target, Format: format, Checked: len(entries),
			OK: ok, Failed: bad, Result: result, Note: note, Entries: entries,
		}); code != exitOK {
			return code
		}
	} else {
		fmt.Printf("VERIFY  %s\n", target)
		fmt.Printf("Format  %s\n\n", format)
		w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		for _, e := range entries {
			exp := e.Expected
			if exp == "" {
				exp = "-"
			}
			act := e.Actual
			if act == "" {
				act = "-"
			}
			fmt.Fprintf(w, "[%s]\t%s\tstored=%s\tactual=%s\t%d bytes\n", e.Status, e.Path, exp, act, e.Size)
		}
		w.Flush()
		for _, e := range entries {
			if e.Status != "OK" && e.Detail != "" {
				fmt.Printf("\n  %s: %s\n", e.Path, e.Detail)
			}
		}
		if note != "" {
			fmt.Printf("\nNote: %s\n", note)
		}
		fmt.Printf("\nRESULT: %s (%d verified, %d failed)\n", result, ok, bad)
	}
	if bad > 0 {
		return exitFinding
	}
	return exitOK
}

// ---------------------------------------------------------------------------
// plumbing
// ---------------------------------------------------------------------------

func parseSize(s string) (int64, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, errors.New("empty size")
	}
	u := strings.ToUpper(s)
	type unit struct {
		suffix string
		mult   int64
	}
	units := []unit{
		{"KIB", 1 << 10}, {"MIB", 1 << 20}, {"GIB", 1 << 30}, {"TIB", 1 << 40},
		{"KB", 1 << 10}, {"MB", 1 << 20}, {"GB", 1 << 30}, {"TB", 1 << 40},
		{"K", 1 << 10}, {"M", 1 << 20}, {"G", 1 << 30}, {"T", 1 << 40},
		{"B", 1},
	}
	mult := int64(1)
	for _, un := range units {
		if strings.HasSuffix(u, un.suffix) {
			mult = un.mult
			u = strings.TrimSpace(strings.TrimSuffix(u, un.suffix))
			break
		}
	}
	if u == "" {
		return 0, fmt.Errorf("no number in %q", s)
	}
	v, err := strconv.ParseFloat(u, 64)
	if err != nil {
		return 0, fmt.Errorf("cannot parse %q as a size", s)
	}
	if v < 0 {
		return 0, fmt.Errorf("size %q must not be negative", s)
	}
	return int64(v * float64(mult)), nil
}

func emitJSON(v any) int {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		return fail(err)
	}
	return exitOK
}

func fail(err error) int {
	fmt.Fprintf(os.Stderr, "zipdock: %v\n", err)
	return exitError
}

func newFlagSet(name string) (*flag.FlagSet, *bool) {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	return fs, fs.Bool("json", false, "emit JSON")
}

func isHelp(a string) bool {
	return a == "-h" || a == "--help" || a == "help" || a == "-help"
}

// helpOrParse returns (exitCode, true) when the caller should stop.
func helpOrParse(fs *flag.FlagSet, args []string, name string) (int, bool) {
	for _, a := range args {
		if isHelp(a) {
			printHelp(os.Stdout, name)
			return exitOK, true
		}
	}
	if err := fs.Parse(args); err != nil {
		fmt.Fprintf(os.Stderr, "zipdock: %v\n", err)
		usage()
		return exitError, true
	}
	return exitOK, false
}

// parseOne handles the commands that take exactly one archive path. It returns
// an empty name when the caller should exit with the returned code.
func parseOne(fs *flag.FlagSet, args []string, name string) (string, int) {
	args = reorderFlags(args, map[string]bool{})
	if code, done := helpOrParse(fs, args, name); done {
		return "", code
	}
	if fs.NArg() != 1 {
		usage()
		return "", exitError
	}
	return fs.Arg(0), exitOK
}

func usage() {
	printHelp(os.Stderr, "")
}

func printHelp(w io.Writer, topic string) {
	fmt.Fprint(w, `zipdock - read-only archive inspector and safety scanner (Techlosoft Archive Vault)

USAGE
  zipdock <command> <archive> [options]

COMMANDS
  info    <archive> [--json]
          Auto-detect the format from magic bytes and summarise the archive:
          format, entry count, total compressed and uncompressed size, ratio.

  list    <archive> [--json]
          List every entry: path, compressed and uncompressed size, per-entry
          ratio, modified time and directory flag. For zip, also the stored
          CRC32 and the compression method name.

  scan    <archive> [--max-ratio N] [--max-total SIZE] [--json]
          Safety report. Each check reports PASS, WARN or FAIL:
            ZIP BOMB         entry or archive ratio over --max-ratio, or total
                             uncompressed size over --max-total
            PATH TRAVERSAL   ".." segments, absolute paths, drive letters
            SYMLINK ENTRIES  tar symlinks/hardlinks, especially escaping ones
            NAME COLLISIONS  duplicate paths, including case-insensitive ones
            SUSPICIOUS NAMES control characters, trailing dot/space, reserved
                             Windows device names
          Exits 2 if any check FAILs.

  verify  <archive> [--json]
          zip:  decompress every entry to a hash and compare the actual CRC32
                against the value in the central directory.
          gzip: validate the trailing CRC32 and ISIZE.
          tar:  validate structure and declared sizes (tar has no content
                checksum), plus the container CRC for .tar.gz / .tar.bz2.
          Nothing is ever written to disk. Exits 2 on any mismatch.

OPTIONS
  --json               machine-readable output
  --max-ratio N        maximum uncompressed:compressed ratio (default 100)
  --max-total SIZE     maximum total uncompressed size (default 1GiB);
                       accepts 500, 500B, 64K, 10MB, 2GiB
  -h, --help, help     this text

FORMATS
  zip, tar, tar.gz, gzip, tar.bz2, bzip2 - detected from magic bytes, not the
  file extension.

EXIT CODES
  0  success, or scan/verify found nothing wrong
  1  usage error, unreadable file, unrecognised format
  2  scan found a FAIL, or verify found a checksum mismatch

zipdock is strictly read-only: it never modifies an archive and never extracts.
`)
	_ = topic
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// 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(exitError)
	}
	if isHelp(args[0]) {
		printHelp(os.Stdout, "")
		os.Exit(exitOK)
	}
	cmd := args[0]
	rest := args[1:]
	switch cmd {
	case "info":
		os.Exit(cmdInfo(rest))
	case "list":
		os.Exit(cmdList(rest))
	case "scan":
		os.Exit(cmdScan(rest))
	case "verify":
		os.Exit(cmdVerify(rest))
	default:
		fmt.Fprintf(os.Stderr, "zipdock: unknown command %q\n", cmd)
		usage()
		os.Exit(exitError)
	}
}
