// PackSafe — split a tree into fixed-size volumes with a verifiable manifest.
//
// Usage:
//
//	packsafe pack <path> [<path> ...] --out NAME --volume-size SIZE [--apply] [--json]
//	packsafe verify  NAME.manifest.json [--json]
//	packsafe restore NAME.manifest.json --out DIR [--apply] [--json]
//	packsafe list    NAME.manifest.json [--json]
//
// A volume set is one tar+gzip stream cut into NAME.001, NAME.002, ...  Each
// volume carries its own SHA-256 in NAME.manifest.json, alongside the SHA-256
// of the complete pre-split stream. That lets anybody holding the pieces prove
// the set is complete and undamaged — and name the exact volume that is
// missing or bad — before a restore is attempted.
//
// Volumes are NOT encrypted; that is VaultZip's job in this product line.
package main

import (
	"archive/tar"
	"compress/gzip"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"hash"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

const (
	manifestFormat  = "packsafe-volume-set"
	manifestVersion = 1
	manifestSuffix  = ".manifest.json"

	statusOK       = "OK"
	statusMissing  = "MISSING"
	statusSize     = "SIZE MISMATCH"
	statusChecksum = "CHECKSUM MISMATCH"
	statusUnread   = "UNREADABLE"
)

// Exit codes: 0 success, 1 usage/IO error, 2 integrity failure.
const (
	exitUsage     = 1
	exitIntegrity = 2
)

// Manifest is the on-disk description of a volume set.
type Manifest struct {
	Format       string       `json:"format"`
	Version      int          `json:"version"`
	Name         string       `json:"name"`
	Created      string       `json:"created"`
	VolumeSize   int64        `json:"volume_size"`
	TotalBytes   int64        `json:"total_bytes"`
	StreamSHA256 string       `json:"stream_sha256"`
	Volumes      []Volume     `json:"volumes"`
	SourceBytes  int64        `json:"source_bytes"`
	Sources      []SourceFile `json:"sources"`
}

// Volume is one split piece of the stream.
type Volume struct {
	Index  int    `json:"index"`
	File   string `json:"file"`
	Bytes  int64  `json:"bytes"`
	SHA256 string `json:"sha256"`
}

// SourceFile is one regular file that went into the archive.
type SourceFile struct {
	Path  string `json:"path"`
	Bytes int64  `json:"bytes"`
}

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions 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(exitUsage)
	}
	switch os.Args[1] {
	case "pack":
		cmdPack(os.Args[2:])
	case "verify":
		cmdVerify(os.Args[2:])
	case "restore":
		cmdRestore(os.Args[2:])
	case "list":
		cmdList(os.Args[2:])
	case "-h", "--help", "help":
		usage()
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(exitUsage)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `PackSafe — split volumes with a verifiable manifest

Usage:
  packsafe pack <path> [<path> ...] --out NAME --volume-size SIZE [--apply] [--json]
  packsafe verify  NAME.manifest.json [--json]
  packsafe restore NAME.manifest.json --out DIR [--apply] [--json]
  packsafe list    NAME.manifest.json [--json]

  --volume-size accepts human sizes: 500KB, 1MB, 10MiB, 2G, or raw bytes.
                KB/MB/GB are powers of 1000, KiB/MiB/GiB powers of 1024.
  --apply       actually write. "pack" and "restore" are a DRY RUN without it.
  --json        machine-readable report on stdout.

Volumes are not encrypted and carry no parity data: a bad volume must be
re-fetched, not repaired. Exit 0 ok, 1 usage/IO error, 2 integrity failure.
`)
}

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])
}

// parseSize turns "1MB", "500KB", "10MiB", "2G" or "1048576" into bytes.
func parseSize(s string) (int64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, fmt.Errorf("empty size")
	}
	up := strings.ToUpper(t)
	type suffix struct {
		tag  string
		mult int64
	}
	// Longest suffixes first so "KIB" wins over "K".
	suffixes := []suffix{
		{"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", 1000}, {"M", 1000 * 1000}, {"G", 1000 * 1000 * 1000}, {"T", 1000 * 1000 * 1000 * 1000},
		{"B", 1},
	}
	mult := int64(1)
	num := up
	for _, sf := range suffixes {
		if strings.HasSuffix(up, sf.tag) {
			mult = sf.mult
			num = strings.TrimSpace(strings.TrimSuffix(up, sf.tag))
			break
		}
	}
	if num == "" {
		return 0, fmt.Errorf("no number in size %q", s)
	}
	v, err := strconv.ParseFloat(num, 64)
	if err != nil {
		return 0, fmt.Errorf("bad size %q", s)
	}
	if v <= 0 {
		return 0, fmt.Errorf("size must be greater than zero (got %q)", s)
	}
	out := int64(v * float64(mult))
	if out <= 0 {
		return 0, fmt.Errorf("size must be greater than zero (got %q)", s)
	}
	return out, nil
}

// unsafeEntry reports tar entries that would escape the extraction root.
func unsafeEntry(name string) bool {
	if name == "" {
		return true
	}
	n := strings.ReplaceAll(name, `\`, "/")
	if strings.HasPrefix(n, "/") {
		return true
	}
	if len(n) >= 2 && n[1] == ':' {
		return true
	}
	for _, part := range strings.Split(n, "/") {
		if part == ".." {
			return true
		}
	}
	return false
}

func fail(format string, a ...any) {
	fmt.Fprintf(os.Stderr, "packsafe: "+format+"\n", a...)
	os.Exit(exitUsage)
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fmt.Fprintln(os.Stderr, "packsafe: json encode error:", err)
		os.Exit(exitUsage)
	}
}

// ---------------------------------------------------------------- pack

// volumeWriter cuts everything written to it into fixed-size files.
type volumeWriter struct {
	dir     string
	base    string
	max     int64
	idx     int
	cur     *os.File
	curN    int64
	curHash hash.Hash
	vols    []Volume
}

func newVolumeWriter(dir, base string, max int64) *volumeWriter {
	return &volumeWriter{dir: dir, base: base, max: max}
}

func volumeName(base string, idx int) string {
	return fmt.Sprintf("%s.%03d", base, idx)
}

func (vw *volumeWriter) open() error {
	vw.idx++
	name := volumeName(vw.base, vw.idx)
	f, err := os.Create(filepath.Join(vw.dir, name))
	if err != nil {
		return err
	}
	vw.cur = f
	vw.curN = 0
	vw.curHash = sha256.New()
	return nil
}

func (vw *volumeWriter) closeCurrent() error {
	if vw.cur == nil {
		return nil
	}
	name := volumeName(vw.base, vw.idx)
	if err := vw.cur.Close(); err != nil {
		return err
	}
	vw.vols = append(vw.vols, Volume{
		Index:  vw.idx,
		File:   name,
		Bytes:  vw.curN,
		SHA256: hex.EncodeToString(vw.curHash.Sum(nil)),
	})
	vw.cur = nil
	return nil
}

func (vw *volumeWriter) Write(p []byte) (int, error) {
	total := 0
	for len(p) > 0 {
		if vw.cur == nil {
			if err := vw.open(); err != nil {
				return total, err
			}
		}
		chunk := p
		if room := vw.max - vw.curN; int64(len(chunk)) > room {
			chunk = p[:room]
		}
		n, err := vw.cur.Write(chunk)
		vw.curHash.Write(chunk[:n])
		vw.curN += int64(n)
		total += n
		p = p[n:]
		if err != nil {
			return total, err
		}
		if vw.curN >= vw.max {
			if err := vw.closeCurrent(); err != nil {
				return total, err
			}
		}
	}
	return total, nil
}

func (vw *volumeWriter) Close() error { return vw.closeCurrent() }

// countWriter swallows bytes and counts them (used by the dry run).
type countWriter struct{ n int64 }

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

// buildStream writes a tar+gzip of targets to w and returns the file list.
func buildStream(w io.Writer, targets []string) ([]SourceFile, int64, error) {
	gz := gzip.NewWriter(w)
	tw := tar.NewWriter(gz)
	var sources []SourceFile
	var srcBytes int64

	for _, t := range targets {
		if _, err := os.Stat(t); err != nil {
			return nil, 0, fmt.Errorf("cannot read source %s: %w", t, err)
		}
		base := filepath.Dir(filepath.Clean(t))
		walkErr := filepath.Walk(t, func(path string, fi os.FileInfo, err error) error {
			if err != nil {
				return err
			}
			rel, err := filepath.Rel(base, path)
			if err != nil {
				rel = filepath.Base(path)
			}
			name := filepath.ToSlash(rel)
			switch {
			case fi.IsDir():
				return tw.WriteHeader(&tar.Header{
					Typeflag: tar.TypeDir,
					Name:     name + "/",
					Mode:     int64(fi.Mode().Perm()),
					ModTime:  fi.ModTime(),
				})
			case !fi.Mode().IsRegular():
				fmt.Fprintf(os.Stderr, "packsafe: skipping non-regular file %s\n", path)
				return nil
			}
			f, err := os.Open(path)
			if err != nil {
				return err
			}
			defer f.Close()
			if err := tw.WriteHeader(&tar.Header{
				Typeflag: tar.TypeReg,
				Name:     name,
				Size:     fi.Size(),
				Mode:     int64(fi.Mode().Perm()),
				ModTime:  fi.ModTime(),
			}); err != nil {
				return err
			}
			n, err := io.Copy(tw, f)
			if err != nil {
				return err
			}
			sources = append(sources, SourceFile{Path: name, Bytes: n})
			srcBytes += n
			return nil
		})
		if walkErr != nil {
			return nil, 0, fmt.Errorf("walking %s: %w", t, walkErr)
		}
	}
	if err := tw.Close(); err != nil {
		return nil, 0, err
	}
	if err := gz.Close(); err != nil {
		return nil, 0, err
	}
	return sources, srcBytes, nil
}

type packReport struct {
	Command      string       `json:"command"`
	Applied      bool         `json:"applied"`
	Name         string       `json:"name"`
	Manifest     string       `json:"manifest"`
	VolumeSize   int64        `json:"volume_size"`
	TotalBytes   int64        `json:"total_bytes"`
	VolumeCount  int          `json:"volume_count"`
	StreamSHA256 string       `json:"stream_sha256"`
	Volumes      []Volume     `json:"volumes"`
	SourceCount  int          `json:"source_count"`
	SourceBytes  int64        `json:"source_bytes"`
	Sources      []SourceFile `json:"sources"`
}

func cmdPack(args []string) {
	fs := flag.NewFlagSet("pack", flag.ExitOnError)
	out := fs.String("out", "", "output volume set name (required)")
	volSize := fs.String("volume-size", "", "maximum bytes per volume, e.g. 10MiB (required)")
	apply := fs.Bool("apply", false, "actually write volumes (default: dry run)")
	asJSON := fs.Bool("json", false, "machine-readable report")
	fs.Parse(reorderFlags(args, map[string]bool{"out": true, "volume-size": true}))
	targets := fs.Args()
	if len(targets) == 0 || *out == "" || *volSize == "" {
		fmt.Fprintln(os.Stderr, "usage: packsafe pack <path> [<path> ...] --out NAME --volume-size SIZE [--apply] [--json]")
		os.Exit(exitUsage)
	}
	max, err := parseSize(*volSize)
	if err != nil {
		fail("%v", err)
	}
	for _, t := range targets {
		if _, err := os.Stat(t); err != nil {
			fail("cannot read source %s: %v", t, err)
		}
	}

	dir := filepath.Dir(*out)
	base := filepath.Base(*out)
	manifestPath := filepath.Join(dir, base+manifestSuffix)

	sum := sha256.New()
	rep := packReport{Command: "pack", Applied: *apply, Name: base, Manifest: manifestPath, VolumeSize: max}

	if !*apply {
		cw := &countWriter{}
		sources, srcBytes, err := buildStream(io.MultiWriter(cw, sum), targets)
		if err != nil {
			fail("%v", err)
		}
		total := cw.n
		count := int((total + max - 1) / max)
		if count == 0 {
			count = 1
		}
		rep.TotalBytes = total
		rep.VolumeCount = count
		rep.StreamSHA256 = hex.EncodeToString(sum.Sum(nil))
		rep.Sources = sources
		rep.SourceCount = len(sources)
		rep.SourceBytes = srcBytes
		for i := 1; i <= count; i++ {
			n := max
			if i == count {
				n = total - int64(count-1)*max
			}
			rep.Volumes = append(rep.Volumes, Volume{Index: i, File: volumeName(base, i), Bytes: n})
		}
		if *asJSON {
			emitJSON(rep)
			return
		}
		fmt.Printf("DRY RUN — nothing written. Re-run with --apply to create the volume set.\n\n")
		fmt.Printf("Set name      : %s\n", base)
		fmt.Printf("Files         : %d (%s of source data)\n", len(sources), humanBytes(srcBytes))
		fmt.Printf("Stream        : %s (%d bytes) tar+gzip\n", humanBytes(total), total)
		fmt.Printf("Stream SHA-256: %s\n", rep.StreamSHA256)
		fmt.Printf("Volume size   : %s (%d bytes)\n", humanBytes(max), max)
		fmt.Printf("Would write   : %d volumes + %s\n\n", count, base+manifestSuffix)
		for _, v := range rep.Volumes {
			fmt.Printf("  %-24s %10d bytes  (%s)\n", v.File, v.Bytes, humanBytes(v.Bytes))
		}
		return
	}

	if dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			fail("cannot create output directory: %v", err)
		}
	}
	vw := newVolumeWriter(dir, base, max)
	sources, srcBytes, err := buildStream(io.MultiWriter(vw, sum), targets)
	if err != nil {
		vw.Close()
		fail("%v", err)
	}
	if err := vw.Close(); err != nil {
		fail("writing volume: %v", err)
	}
	var total int64
	for _, v := range vw.vols {
		total += v.Bytes
	}
	man := Manifest{
		Format:       manifestFormat,
		Version:      manifestVersion,
		Name:         base,
		Created:      time.Now().UTC().Format(time.RFC3339),
		VolumeSize:   max,
		TotalBytes:   total,
		StreamSHA256: hex.EncodeToString(sum.Sum(nil)),
		Volumes:      vw.vols,
		SourceBytes:  srcBytes,
		Sources:      sources,
	}
	buf, err := json.MarshalIndent(man, "", "  ")
	if err != nil {
		fail("encoding manifest: %v", err)
	}
	if err := os.WriteFile(manifestPath, append(buf, '\n'), 0o644); err != nil {
		fail("writing manifest: %v", err)
	}

	rep.TotalBytes = total
	rep.VolumeCount = len(vw.vols)
	rep.StreamSHA256 = man.StreamSHA256
	rep.Volumes = vw.vols
	rep.Sources = sources
	rep.SourceCount = len(sources)
	rep.SourceBytes = srcBytes
	if *asJSON {
		emitJSON(rep)
		return
	}
	fmt.Printf("Packed %d files (%s) into %d volumes.\n\n", len(sources), humanBytes(srcBytes), len(vw.vols))
	for _, v := range vw.vols {
		fmt.Printf("  %-24s %10d bytes  sha256:%s\n", v.File, v.Bytes, v.SHA256)
	}
	fmt.Printf("\nStream        : %s (%d bytes)\n", humanBytes(total), total)
	fmt.Printf("Stream SHA-256: %s\n", man.StreamSHA256)
	fmt.Printf("Manifest      : %s\n", manifestPath)
}

// ---------------------------------------------------------------- shared

func loadManifest(path string) (*Manifest, string) {
	raw, err := os.ReadFile(path)
	if err != nil {
		fail("cannot read manifest: %v", err)
	}
	var m Manifest
	if err := json.Unmarshal(raw, &m); err != nil {
		fail("malformed manifest %s: %v", path, err)
	}
	if m.Format != manifestFormat {
		fail("not a PackSafe manifest (format=%q, want %q)", m.Format, manifestFormat)
	}
	if m.Version != manifestVersion {
		fail("unsupported manifest version %d (this build understands %d)", m.Version, manifestVersion)
	}
	if len(m.Volumes) == 0 {
		fail("manifest %s lists no volumes", path)
	}
	if m.StreamSHA256 == "" {
		fail("manifest %s has no stream_sha256", path)
	}
	return &m, filepath.Dir(path)
}

type volResult struct {
	Index          int    `json:"index"`
	File           string `json:"file"`
	Status         string `json:"status"`
	ExpectedBytes  int64  `json:"expected_bytes"`
	ActualBytes    int64  `json:"actual_bytes"`
	ExpectedSHA256 string `json:"expected_sha256"`
	ActualSHA256   string `json:"actual_sha256"`
}

type verifyReport struct {
	Command      string      `json:"command"`
	Manifest     string      `json:"manifest"`
	Name         string      `json:"name"`
	Created      string      `json:"created"`
	VolumeCount  int         `json:"volume_count"`
	TotalBytes   int64       `json:"total_bytes"`
	StreamSHA256 string      `json:"stream_sha256"`
	Volumes      []volResult `json:"volumes"`
	OKCount      int         `json:"ok_count"`
	BadCount     int         `json:"bad_count"`
	Complete     bool        `json:"complete"`
	Verdict      string      `json:"verdict"`
}

func verifyVolumes(m *Manifest, dir string) verifyReport {
	rep := verifyReport{
		Command:      "verify",
		Name:         m.Name,
		Created:      m.Created,
		VolumeCount:  len(m.Volumes),
		TotalBytes:   m.TotalBytes,
		StreamSHA256: m.StreamSHA256,
	}
	for _, v := range m.Volumes {
		r := volResult{Index: v.Index, File: v.File, ExpectedBytes: v.Bytes, ExpectedSHA256: v.SHA256}
		path := filepath.Join(dir, v.File)
		fi, err := os.Stat(path)
		if err != nil {
			r.Status = statusMissing
			r.ActualBytes = -1
			rep.Volumes = append(rep.Volumes, r)
			continue
		}
		r.ActualBytes = fi.Size()
		if fi.Size() != v.Bytes {
			r.Status = statusSize
			rep.Volumes = append(rep.Volumes, r)
			continue
		}
		f, err := os.Open(path)
		if err != nil {
			r.Status = statusUnread
			rep.Volumes = append(rep.Volumes, r)
			continue
		}
		h := sha256.New()
		_, cErr := io.Copy(h, f)
		f.Close()
		if cErr != nil {
			r.Status = statusUnread
			rep.Volumes = append(rep.Volumes, r)
			continue
		}
		r.ActualSHA256 = hex.EncodeToString(h.Sum(nil))
		if r.ActualSHA256 != v.SHA256 {
			r.Status = statusChecksum
		} else {
			r.Status = statusOK
		}
		rep.Volumes = append(rep.Volumes, r)
	}
	for _, r := range rep.Volumes {
		if r.Status == statusOK {
			rep.OKCount++
		} else {
			rep.BadCount++
		}
	}
	rep.Complete = rep.BadCount == 0
	if rep.Complete {
		rep.Verdict = "COMPLETE — restore can safely proceed"
	} else {
		rep.Verdict = fmt.Sprintf("INCOMPLETE — %d of %d volumes unusable, restore must not proceed", rep.BadCount, rep.VolumeCount)
	}
	return rep
}

func printVerify(rep verifyReport) {
	fmt.Printf("Volume set : %s (%d volumes, %s, created %s)\n\n", rep.Name, rep.VolumeCount, humanBytes(rep.TotalBytes), rep.Created)
	for _, r := range rep.Volumes {
		switch r.Status {
		case statusOK:
			fmt.Printf("  [%3d] %-24s %-18s %10d bytes  sha256:%s\n", r.Index, r.File, r.Status, r.ActualBytes, r.ExpectedSHA256[:16])
		case statusMissing:
			fmt.Printf("  [%3d] %-24s %-18s file not found\n", r.Index, r.File, r.Status)
		case statusSize:
			fmt.Printf("  [%3d] %-24s %-18s expected %d bytes, found %d bytes\n", r.Index, r.File, r.Status, r.ExpectedBytes, r.ActualBytes)
		case statusChecksum:
			fmt.Printf("  [%3d] %-24s %-18s expected sha256:%s, got sha256:%s\n", r.Index, r.File, r.Status, r.ExpectedSHA256[:16], r.ActualSHA256[:16])
		default:
			fmt.Printf("  [%3d] %-24s %-18s\n", r.Index, r.File, r.Status)
		}
	}
	fmt.Printf("\n%d OK, %d bad, of %d volumes\n", rep.OKCount, rep.BadCount, rep.VolumeCount)
	fmt.Printf("Verdict: %s\n", rep.Verdict)
	if !rep.Complete {
		fmt.Printf("Re-fetch the volumes listed above; PackSafe has no parity data and cannot repair them.\n")
	}
}

func cmdVerify(args []string) {
	fs := flag.NewFlagSet("verify", flag.ExitOnError)
	asJSON := fs.Bool("json", false, "machine-readable report")
	fs.Parse(reorderFlags(args, map[string]bool{}))
	pos := fs.Args()
	if len(pos) != 1 {
		fmt.Fprintln(os.Stderr, "usage: packsafe verify NAME.manifest.json [--json]")
		os.Exit(exitUsage)
	}
	m, dir := loadManifest(pos[0])
	rep := verifyVolumes(m, dir)
	rep.Manifest = pos[0]
	if *asJSON {
		emitJSON(rep)
	} else {
		printVerify(rep)
	}
	if !rep.Complete {
		os.Exit(exitIntegrity)
	}
}

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

type listReport struct {
	Command     string       `json:"command"`
	Manifest    string       `json:"manifest"`
	Name        string       `json:"name"`
	Created     string       `json:"created"`
	VolumeCount int          `json:"volume_count"`
	SourceCount int          `json:"source_count"`
	SourceBytes int64        `json:"source_bytes"`
	Sources     []SourceFile `json:"sources"`
}

func cmdList(args []string) {
	fs := flag.NewFlagSet("list", flag.ExitOnError)
	asJSON := fs.Bool("json", false, "machine-readable report")
	fs.Parse(reorderFlags(args, map[string]bool{}))
	pos := fs.Args()
	if len(pos) != 1 {
		fmt.Fprintln(os.Stderr, "usage: packsafe list NAME.manifest.json [--json]")
		os.Exit(exitUsage)
	}
	m, _ := loadManifest(pos[0])
	rep := listReport{
		Command:     "list",
		Manifest:    pos[0],
		Name:        m.Name,
		Created:     m.Created,
		VolumeCount: len(m.Volumes),
		SourceCount: len(m.Sources),
		SourceBytes: m.SourceBytes,
		Sources:     m.Sources,
	}
	if *asJSON {
		emitJSON(rep)
		return
	}
	fmt.Printf("Volume set : %s (%d volumes, created %s)\n", m.Name, len(m.Volumes), m.Created)
	fmt.Printf("Contents   : %d files, %s\n\n", len(m.Sources), humanBytes(m.SourceBytes))
	for _, s := range m.Sources {
		fmt.Printf("  %10d  %-10s  %s\n", s.Bytes, humanBytes(s.Bytes), s.Path)
	}
	fmt.Printf("\nNothing was extracted; this listing comes from the manifest.\n")
}

// ---------------------------------------------------------------- restore

type restoreReport struct {
	Command      string       `json:"command"`
	Applied      bool         `json:"applied"`
	Manifest     string       `json:"manifest"`
	Out          string       `json:"out"`
	Verify       verifyReport `json:"verify"`
	StreamOK     bool         `json:"stream_ok"`
	StreamSHA256 string       `json:"stream_sha256"`
	ActualSHA256 string       `json:"actual_sha256"`
	UnsafeMode   bool         `json:"unsafe_entries_found"`
	Unsafe       []string     `json:"unsafe_entries"`
	Entries      []SourceFile `json:"entries"`
	EntryCount   int          `json:"entry_count"`
	ExtractBytes int64        `json:"extract_bytes"`
	Verdict      string       `json:"verdict"`
}

func cmdRestore(args []string) {
	fs := flag.NewFlagSet("restore", flag.ExitOnError)
	out := fs.String("out", "", "directory to restore into (required)")
	apply := fs.Bool("apply", false, "actually extract (default: dry run)")
	asJSON := fs.Bool("json", false, "machine-readable report")
	fs.Parse(reorderFlags(args, map[string]bool{"out": true}))
	pos := fs.Args()
	if len(pos) != 1 || *out == "" {
		fmt.Fprintln(os.Stderr, "usage: packsafe restore NAME.manifest.json --out DIR [--apply] [--json]")
		os.Exit(exitUsage)
	}
	m, dir := loadManifest(pos[0])

	rep := restoreReport{Command: "restore", Applied: *apply, Manifest: pos[0], Out: *out, StreamSHA256: m.StreamSHA256}
	rep.Verify = verifyVolumes(m, dir)
	rep.Verify.Manifest = pos[0]

	finish := func(code int) {
		if *asJSON {
			emitJSON(rep)
		} else {
			fmt.Println(rep.Verdict)
		}
		os.Exit(code)
	}

	if !rep.Verify.Complete {
		if !*asJSON {
			printVerify(rep.Verify)
			fmt.Println()
		}
		rep.Verdict = "REFUSED — the volume set did not verify; nothing was written to " + *out
		finish(exitIntegrity)
	}
	if !*asJSON {
		printVerify(rep.Verify)
		fmt.Println()
	}

	// Reassemble into a temporary file outside --out so a failed restore
	// leaves the destination untouched.
	tmp, err := os.CreateTemp("", "packsafe-stream-*.tgz")
	if err != nil {
		fail("cannot create temporary file: %v", err)
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)
	sum := sha256.New()
	for _, v := range m.Volumes {
		f, err := os.Open(filepath.Join(dir, v.File))
		if err != nil {
			tmp.Close()
			fail("cannot read volume %s: %v", v.File, err)
		}
		_, err = io.Copy(io.MultiWriter(tmp, sum), f)
		f.Close()
		if err != nil {
			tmp.Close()
			fail("reassembling %s: %v", v.File, err)
		}
	}
	if err := tmp.Close(); err != nil {
		fail("writing temporary stream: %v", err)
	}
	rep.ActualSHA256 = hex.EncodeToString(sum.Sum(nil))
	rep.StreamOK = rep.ActualSHA256 == m.StreamSHA256
	if !*asJSON {
		fmt.Printf("Reassembled stream SHA-256: %s\n", rep.ActualSHA256)
		fmt.Printf("Manifest stream SHA-256   : %s\n", m.StreamSHA256)
	}
	if !rep.StreamOK {
		rep.Verdict = "REFUSED — WHOLE-STREAM CHECKSUM MISMATCH: every volume verified individually but the reassembled stream is not the packed stream (volumes reordered or manifest tampered with); nothing was written to " + *out
		finish(exitIntegrity)
	}
	if !*asJSON {
		fmt.Printf("Whole-stream checksum OK.\n\n")
	}

	// Pass 1: scan every tar entry for tar-slip before writing anything.
	entries, unsafe, err := scanEntries(tmpPath)
	if err != nil {
		fail("corrupt archive stream: %v", err)
	}
	rep.Entries = entries
	rep.EntryCount = len(entries)
	for _, e := range entries {
		rep.ExtractBytes += e.Bytes
	}
	rep.Unsafe = unsafe
	rep.UnsafeMode = len(unsafe) > 0
	if rep.UnsafeMode {
		if !*asJSON {
			for _, u := range unsafe {
				fmt.Printf("  UNSAFE PATH  %s\n", u)
			}
			fmt.Println()
		}
		rep.Verdict = fmt.Sprintf("REFUSED — %d unsafe entry path(s) would escape --out (tar-slip); nothing was written to %s", len(unsafe), *out)
		finish(exitIntegrity)
	}

	if !*apply {
		rep.Verdict = "DRY RUN — verification passed; nothing written. Re-run with --apply to extract."
		if *asJSON {
			emitJSON(rep)
			return
		}
		fmt.Printf("DRY RUN — nothing written. Re-run with --apply to extract into %s\n\n", *out)
		for _, e := range entries {
			fmt.Printf("  would extract %10d  %s\n", e.Bytes, e.Path)
		}
		fmt.Printf("\n%d entries, %s\n", len(entries), humanBytes(rep.ExtractBytes))
		return
	}

	if err := os.MkdirAll(*out, 0o755); err != nil {
		fail("cannot create output directory: %v", err)
	}
	root, err := filepath.Abs(*out)
	if err != nil {
		fail("cannot resolve output directory: %v", err)
	}
	if err := extractStream(tmpPath, root, *asJSON); err != nil {
		fail("%v", err)
	}
	rep.Verdict = fmt.Sprintf("RESTORED — %d entries (%s) into %s", len(entries), humanBytes(rep.ExtractBytes), *out)
	if *asJSON {
		emitJSON(rep)
		return
	}
	fmt.Printf("\n%s\n", rep.Verdict)
}

func scanEntries(path string) ([]SourceFile, []string, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, nil, err
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		return nil, nil, err
	}
	defer gz.Close()
	tr := tar.NewReader(gz)
	var entries []SourceFile
	var unsafe []string
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, nil, err
		}
		if unsafeEntry(hdr.Name) {
			unsafe = append(unsafe, hdr.Name)
			continue
		}
		if hdr.Typeflag == tar.TypeDir {
			continue
		}
		entries = append(entries, SourceFile{Path: hdr.Name, Bytes: hdr.Size})
	}
	return entries, unsafe, nil
}

func extractStream(path, root string, quiet bool) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		return err
	}
	defer gz.Close()
	tr := tar.NewReader(gz)
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return fmt.Errorf("corrupt archive entry: %w", err)
		}
		if unsafeEntry(hdr.Name) {
			return fmt.Errorf("unsafe entry path %q", hdr.Name)
		}
		dest := filepath.Join(root, filepath.FromSlash(hdr.Name))
		if dest != root && !strings.HasPrefix(dest, root+string(os.PathSeparator)) {
			return fmt.Errorf("entry %q escapes the output directory", hdr.Name)
		}
		mode := os.FileMode(hdr.Mode).Perm()
		if hdr.Typeflag == tar.TypeDir {
			if err := os.MkdirAll(dest, mode|0o700); err != nil {
				return err
			}
			continue
		}
		if hdr.Typeflag != tar.TypeReg {
			fmt.Fprintf(os.Stderr, "packsafe: skipping unsupported entry type %q in %s\n", hdr.Typeflag, hdr.Name)
			continue
		}
		if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
			return err
		}
		w, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
		if err != nil {
			return err
		}
		n, err := io.Copy(w, tr)
		cErr := w.Close()
		if err != nil {
			return err
		}
		if cErr != nil {
			return cErr
		}
		if !hdr.ModTime.IsZero() {
			os.Chtimes(dest, hdr.ModTime, hdr.ModTime)
		}
		if !quiet {
			fmt.Printf("  extracted %10d  %s\n", n, hdr.Name)
		}
	}
	return nil
}
