// Command devicedriver seals a Windows driver store folder into a manifest of
// every file's SHA-256 and later proves the folder on disk is still exactly
// what was sealed.
//
// It answers a different question from DriverPilot: DriverPilot reads what a
// driver package SAYS about itself, devicedriver proves the bytes on disk have
// not changed since you approved them. It reads driver PACKAGE FILES only, is
// strictly read-only on the store it inspects, and writes nothing except the
// manifest/--out file it is asked to write.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
	"unicode/utf16"
	"unicode/utf8"
)

const (
	toolName    = "devicedriver"
	toolVersion = "1.0.0"
	toolLine    = "DeviceDriver " + toolVersion + " - Driver Safety Center"
	formatVer   = 1
)

// Exit codes.
const (
	exitOK       = 0
	exitUsage    = 1
	exitFindings = 2
)

// noPackage labels a file that no .inf package accounts for.
const noPackage = "(no package)"

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the tool line).
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------

// Model is one device line inside a models section of an .inf.
type Model struct {
	Description    string   `json:"description"`
	Section        string   `json:"section"`
	InstallSection string   `json:"installSection"`
	HardwareIDs    []string `json:"hardwareIds"`
}

// ManufacturerEntry is one line of the [Manufacturer] section.
type ManufacturerEntry struct {
	Name           string   `json:"name"`
	BaseSection    string   `json:"baseSection"`
	TargetSections []string `json:"targetSections"`
}

// Package is everything extracted from a single .inf file.
type Package struct {
	File             string              `json:"file"`
	Encoding         string              `json:"encoding"`
	SizeBytes        int64               `json:"sizeBytes"`
	Provider         string              `json:"provider"`
	Class            string              `json:"class"`
	ClassGUID        string              `json:"classGuid"`
	DriverVerDate    string              `json:"driverVerDate"`
	DriverVerVersion string              `json:"driverVerVersion"`
	CatalogFile      string              `json:"catalogFile"`
	Manufacturers    []ManufacturerEntry `json:"manufacturers"`
	Models           []Model             `json:"models"`
	HardwareIDs      []string            `json:"hardwareIds"`
	ReferencedFiles  []string            `json:"referencedFiles"`
}

// Failure records one file that could not be parsed or read.
type Failure struct {
	File  string `json:"file"`
	Error string `json:"error"`
}

// FileEntry is one sealed file.
type FileEntry struct {
	Path      string `json:"path"`
	SizeBytes int64  `json:"sizeBytes"`
	ModTime   string `json:"modTime"`
	SHA256    string `json:"sha256"`
	Package   string `json:"package"`
}

// Summary is the sealed store as a whole.
type Summary struct {
	PackageCount int    `json:"packageCount"`
	FileCount    int    `json:"fileCount"`
	TotalBytes   int64  `json:"totalBytes"`
	StoreDigest  string `json:"storeDigest"`
}

// Manifest is the on-disk seal written by `seal --out`.
type Manifest struct {
	Tool        string      `json:"tool"`
	Version     string      `json:"version"`
	Format      int         `json:"format"`
	Generated   string      `json:"generated"`
	Root        string      `json:"root"`
	Note        string      `json:"note"`
	Summary     Summary     `json:"summary"`
	Packages    []string    `json:"packages"`
	InfFailures []Failure   `json:"infFailures"`
	ReadErrors  []Failure   `json:"readErrors"`
	Files       []FileEntry `json:"files"`
}

// ---------------------------------------------------------------------------
// Decoding: UTF-16LE/BE with BOM, UTF-8 with/without BOM, ANSI fallback.
// ---------------------------------------------------------------------------

func decodeUTF16(b []byte, bigEndian bool) (string, error) {
	if len(b)%2 != 0 {
		return "", errors.New("truncated UTF-16 data (odd number of bytes)")
	}
	u := make([]uint16, len(b)/2)
	for i := 0; i < len(u); i++ {
		if bigEndian {
			u[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1])
		} else {
			u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i])
		}
	}
	return string(utf16.Decode(u)), nil
}

// looksLikeUTF16LE guesses BOM-less UTF-16LE: ASCII text in UTF-16LE has a
// zero byte in every odd position.
func looksLikeUTF16LE(b []byte) bool {
	if len(b) < 8 || len(b)%2 != 0 {
		return false
	}
	n := len(b)
	if n > 512 {
		n = 512
	}
	zeroOdd, pairs := 0, 0
	for i := 0; i+1 < n; i += 2 {
		pairs++
		if b[i+1] == 0 && b[i] != 0 {
			zeroOdd++
		}
	}
	return pairs > 0 && zeroOdd*10 >= pairs*8
}

// decodeText turns raw file bytes into a string plus a human encoding label.
func decodeText(raw []byte) (string, string, error) {
	switch {
	case len(raw) >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF:
		return string(raw[3:]), "UTF-8 (BOM)", nil
	case len(raw) >= 2 && raw[0] == 0xFF && raw[1] == 0xFE:
		s, err := decodeUTF16(raw[2:], false)
		return s, "UTF-16LE (BOM)", err
	case len(raw) >= 2 && raw[0] == 0xFE && raw[1] == 0xFF:
		s, err := decodeUTF16(raw[2:], true)
		return s, "UTF-16BE (BOM)", err
	}
	if looksLikeUTF16LE(raw) {
		s, err := decodeUTF16(raw, false)
		return s, "UTF-16LE (no BOM)", err
	}
	if utf8.Valid(raw) {
		return string(raw), "UTF-8", nil
	}
	// Legacy single-byte code page: map each byte to the matching rune so a
	// stray 0xE9 does not make the whole package unreadable.
	var sb strings.Builder
	sb.Grow(len(raw))
	for _, c := range raw {
		sb.WriteRune(rune(c))
	}
	return sb.String(), "ANSI (single byte)", nil
}

// looksBinary reports whether decoded text is really binary junk.
func looksBinary(s string) bool {
	if strings.ContainsRune(s, 0) {
		return true
	}
	bad, total := 0, 0
	for _, r := range s {
		total++
		if r == '\n' || r == '\r' || r == '\t' {
			continue
		}
		if r == utf8.RuneError || (r < 0x20) || r == 0x7f {
			bad++
		}
	}
	return total > 0 && bad*100 > total*2
}

// ---------------------------------------------------------------------------
// INF lexing: sections, key=value pairs, comments, continuations.
// ---------------------------------------------------------------------------

type kv struct {
	Key   string
	Value string
	Line  int
}

type infFile struct {
	Path     string
	Encoding string
	Size     int64
	names    []string // section names in original case, in file order
	sections map[string][]kv
}

func (f *infFile) section(name string) ([]kv, bool) {
	s, ok := f.sections[strings.ToLower(name)]
	return s, ok
}

// stripComment removes a ';' comment, honouring double-quoted strings.
func stripComment(line string) string {
	inQuote := false
	for i, r := range line {
		switch r {
		case '"':
			inQuote = !inQuote
		case ';':
			if !inQuote {
				return line[:i]
			}
		}
	}
	return line
}

// splitTopLevel splits on commas outside double quotes.
func splitTopLevel(s string) []string {
	var out []string
	inQuote := false
	start := 0
	for i, r := range s {
		switch r {
		case '"':
			inQuote = !inQuote
		case ',':
			if !inQuote {
				out = append(out, s[start:i])
				start = i + 1
			}
		}
	}
	out = append(out, s[start:])
	return out
}

func unquote(s string) string {
	s = strings.TrimSpace(s)
	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
		s = s[1 : len(s)-1]
	}
	return strings.TrimSpace(s)
}

// lexINF splits decoded text into sections. It returns an error for structural
// damage (unterminated section header, content outside any section).
func lexINF(text string) (*infFile, error) {
	f := &infFile{sections: map[string][]kv{}}
	raw := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")

	// Join line continuations: a trailing backslash preceded by a comma or
	// whitespace continues onto the next line. Requiring that prefix keeps
	// hardware IDs such as PCI\VEN_8086 from being misread.
	type physLine struct {
		text string
		num  int
	}
	var lines []physLine
	for i := 0; i < len(raw); i++ {
		cur := stripComment(raw[i])
		num := i + 1
		for strings.HasSuffix(strings.TrimRight(cur, " \t"), "\\") && i+1 < len(raw) {
			t := strings.TrimRight(cur, " \t")
			prev := byte(' ')
			if len(t) >= 2 {
				prev = t[len(t)-2]
			}
			if prev != ',' && prev != ' ' && prev != '\t' && prev != '=' {
				break
			}
			i++
			cur = t[:len(t)-1] + " " + strings.TrimSpace(stripComment(raw[i]))
		}
		lines = append(lines, physLine{cur, num})
	}

	current := ""
	for _, pl := range lines {
		line := strings.TrimSpace(pl.text)
		if line == "" {
			continue
		}
		if strings.HasPrefix(line, "[") {
			end := strings.Index(line, "]")
			if end < 0 {
				return nil, fmt.Errorf("line %d: unterminated section header %q", pl.num, line)
			}
			name := strings.TrimSpace(line[1:end])
			if name == "" {
				return nil, fmt.Errorf("line %d: empty section name", pl.num)
			}
			lower := strings.ToLower(name)
			if _, seen := f.sections[lower]; !seen {
				f.names = append(f.names, name)
				f.sections[lower] = nil
			}
			current = lower
			continue
		}
		if current == "" {
			return nil, fmt.Errorf("line %d: content outside any section: %q", pl.num, trunc(line, 48))
		}
		if eq := strings.Index(line, "="); eq >= 0 {
			f.sections[current] = append(f.sections[current], kv{
				Key:   strings.TrimSpace(line[:eq]),
				Value: strings.TrimSpace(line[eq+1:]),
				Line:  pl.num,
			})
			continue
		}
		// Valueless list entry (legal in sections such as [SourceDisksNames]).
		f.sections[current] = append(f.sections[current], kv{Value: line, Line: pl.num})
	}
	if len(f.names) == 0 {
		return nil, errors.New("no INF sections found")
	}
	return f, nil
}

func trunc(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "..."
}

// ---------------------------------------------------------------------------
// INF semantics
// ---------------------------------------------------------------------------

// lookup finds a key in a section, case-insensitively.
func (f *infFile) lookup(section, key string) (string, bool) {
	entries, ok := f.section(section)
	if !ok {
		return "", false
	}
	want := strings.ToLower(key)
	for _, e := range entries {
		if strings.ToLower(e.Key) == want {
			return e.Value, true
		}
	}
	return "", false
}

// lookupPrefix finds the first key whose lowercase form starts with prefix,
// which is how decorated keys such as CatalogFile.NTamd64 are picked up.
func (f *infFile) lookupPrefix(section, prefix string) (string, bool) {
	entries, ok := f.section(section)
	if !ok {
		return "", false
	}
	want := strings.ToLower(prefix)
	for _, e := range entries {
		if strings.HasPrefix(strings.ToLower(e.Key), want) && e.Value != "" {
			return e.Value, true
		}
	}
	return "", false
}

// resolve expands %Token% references using the [Strings] section.
func (f *infFile) resolve(s string) string {
	s = unquote(s)
	if !strings.Contains(s, "%") {
		return s
	}
	var out strings.Builder
	i := 0
	for i < len(s) {
		if s[i] != '%' {
			out.WriteByte(s[i])
			i++
			continue
		}
		end := strings.IndexByte(s[i+1:], '%')
		if end < 0 {
			out.WriteString(s[i:])
			break
		}
		token := s[i+1 : i+1+end]
		if token == "" { // %% is a literal percent sign
			out.WriteByte('%')
			i += 2
			continue
		}
		if v, ok := f.stringToken(token); ok {
			out.WriteString(v)
		} else {
			out.WriteString("%" + token + "%")
		}
		i += end + 2
	}
	return strings.TrimSpace(out.String())
}

// stringToken searches [Strings] and any localised [Strings.xxxx] section.
func (f *infFile) stringToken(token string) (string, bool) {
	if v, ok := f.lookup("Strings", token); ok {
		return unquote(v), true
	}
	for _, name := range f.names {
		if strings.HasPrefix(strings.ToLower(name), "strings.") {
			if v, ok := f.lookup(name, token); ok {
				return unquote(v), true
			}
		}
	}
	return "", false
}

// parseDriverVer splits `09/21/2023,31.0.15.3179` into date and version.
func parseDriverVer(v string) (date, version string) {
	parts := splitTopLevel(v)
	if len(parts) > 0 {
		date = unquote(parts[0])
	}
	if len(parts) > 1 {
		version = unquote(parts[1])
	}
	return date, version
}

func contains(ss []string, s string) bool {
	for _, x := range ss {
		if x == s {
			return true
		}
	}
	return false
}

// parseModelSection reads `%DeviceDesc% = Install_Section, HWID [, HWID...]`.
func parseModelSection(f *infFile, name string) []Model {
	entries, ok := f.section(name)
	if !ok {
		return nil
	}
	var out []Model
	for _, e := range entries {
		if e.Key == "" {
			continue
		}
		fields := splitTopLevel(e.Value)
		m := Model{
			Description:    f.resolve(e.Key),
			Section:        name,
			InstallSection: unquote(fields[0]),
			HardwareIDs:    []string{},
		}
		for _, id := range fields[1:] {
			id = f.resolve(id)
			if id != "" {
				m.HardwareIDs = append(m.HardwareIDs, id)
			}
		}
		out = append(out, m)
	}
	return out
}

// parseManufacturers walks [Manufacturer] into its models sections, honouring
// the `BaseSection, TargetDecoration, ...` form.
func parseManufacturers(f *infFile) ([]ManufacturerEntry, []Model) {
	entries, ok := f.section("Manufacturer")
	if !ok {
		return nil, nil
	}
	var mfgs []ManufacturerEntry
	var models []Model
	visited := map[string]bool{}

	for _, e := range entries {
		if e.Key == "" {
			continue
		}
		fields := splitTopLevel(e.Value)
		base := unquote(fields[0])
		if base == "" {
			continue
		}
		me := ManufacturerEntry{Name: f.resolve(e.Key), BaseSection: base}

		var candidates []string
		for _, d := range fields[1:] {
			d = unquote(d)
			if d == "" {
				continue
			}
			candidates = append(candidates, base+"."+d)
		}
		// The undecorated section is always a legal target as well.
		candidates = append(candidates, base)

		for _, name := range candidates {
			if _, exists := f.section(name); !exists {
				continue
			}
			if !contains(me.TargetSections, name) {
				me.TargetSections = append(me.TargetSections, name)
			}
			if visited[strings.ToLower(name)] {
				continue
			}
			visited[strings.ToLower(name)] = true
			models = append(models, parseModelSection(f, name)...)
		}
		if me.TargetSections == nil {
			me.TargetSections = []string{}
		}
		mfgs = append(mfgs, me)
	}
	return mfgs, models
}

// referencedFiles collects every payload file name an .inf names: its catalog,
// its [SourceDisksFiles] entries and every file listed in a CopyFiles section.
// These are the files that legitimately belong to the package; anything else in
// the store is a stray.
func referencedFiles(f *infFile) []string {
	seen := map[string]bool{}
	var out []string
	add := func(s string) {
		s = strings.TrimSpace(f.resolve(s))
		s = strings.TrimPrefix(s, "@")
		s = filepath.Base(filepath.FromSlash(strings.ReplaceAll(s, "\\", "/")))
		if s == "" || s == "." || s == string(filepath.Separator) {
			return
		}
		k := strings.ToLower(s)
		if !seen[k] {
			seen[k] = true
			out = append(out, s)
		}
	}

	// CatalogFile / CatalogFile.NTamd64 / ...
	if entries, ok := f.section("Version"); ok {
		for _, e := range entries {
			if strings.HasPrefix(strings.ToLower(e.Key), "catalogfile") {
				add(e.Value)
			}
		}
	}
	// [SourceDisksFiles] and its decorated variants: key is the file name.
	for _, name := range f.names {
		if strings.HasPrefix(strings.ToLower(name), "sourcedisksfiles") {
			entries, _ := f.section(name)
			for _, e := range entries {
				if e.Key != "" {
					add(e.Key)
				} else {
					add(splitTopLevel(e.Value)[0])
				}
			}
		}
	}
	// CopyFiles = @file.sys or CopyFiles = SectionName
	var targets []string
	for _, name := range f.names {
		entries, _ := f.section(name)
		for _, e := range entries {
			if !strings.HasPrefix(strings.ToLower(e.Key), "copyfiles") {
				continue
			}
			for _, t := range splitTopLevel(e.Value) {
				t = unquote(t)
				if t == "" {
					continue
				}
				if strings.HasPrefix(t, "@") {
					add(t)
					continue
				}
				targets = append(targets, t)
			}
		}
	}
	for _, t := range targets {
		entries, ok := f.section(t)
		if !ok {
			continue
		}
		for _, e := range entries {
			if e.Key != "" {
				add(e.Key)
				continue
			}
			fields := splitTopLevel(e.Value)
			add(fields[0])
			if len(fields) > 1 && strings.TrimSpace(fields[1]) != "" {
				add(fields[1])
			}
		}
	}
	sort.Slice(out, func(i, j int) bool {
		return strings.ToLower(out[i]) < strings.ToLower(out[j])
	})
	return out
}

// parsePackage reads and interprets a single .inf file.
func parsePackage(path string) (*Package, error) {
	st, err := os.Stat(path)
	if err != nil {
		return nil, err
	}
	if st.IsDir() {
		return nil, errors.New("is a directory, not an .inf file")
	}
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if len(raw) == 0 {
		return nil, errors.New("file is empty (0 bytes)")
	}
	text, enc, err := decodeText(raw)
	if err != nil {
		return nil, err
	}
	if looksBinary(text) {
		return nil, fmt.Errorf("not a text .inf file: %s content looks binary", enc)
	}
	f, err := lexINF(text)
	if err != nil {
		return nil, err
	}
	f.Path = path
	f.Encoding = enc
	f.Size = st.Size()

	if _, ok := f.section("Version"); !ok {
		return nil, errors.New("missing [Version] section (not a driver package .inf)")
	}

	p := &Package{
		File:      filepath.ToSlash(path),
		Encoding:  enc,
		SizeBytes: st.Size(),
	}
	if v, ok := f.lookup("Version", "Provider"); ok {
		p.Provider = f.resolve(v)
	}
	if v, ok := f.lookup("Version", "Class"); ok {
		p.Class = f.resolve(v)
	}
	if v, ok := f.lookup("Version", "ClassGUID"); ok {
		p.ClassGUID = f.resolve(v)
	}
	if v, ok := f.lookupPrefix("Version", "CatalogFile"); ok {
		p.CatalogFile = f.resolve(v)
	}
	if v, ok := f.lookupPrefix("Version", "DriverVer"); ok {
		p.DriverVerDate, p.DriverVerVersion = parseDriverVer(f.resolve(v))
	}

	p.Manufacturers, p.Models = parseManufacturers(f)

	seen := map[string]bool{}
	for _, m := range p.Models {
		for _, id := range m.HardwareIDs {
			k := strings.ToUpper(id)
			if !seen[k] {
				seen[k] = true
				p.HardwareIDs = append(p.HardwareIDs, id)
			}
		}
	}
	p.ReferencedFiles = referencedFiles(f)
	if p.Manufacturers == nil {
		p.Manufacturers = []ManufacturerEntry{}
	}
	if p.Models == nil {
		p.Models = []Model{}
	}
	if p.HardwareIDs == nil {
		p.HardwareIDs = []string{}
	}
	if p.ReferencedFiles == nil {
		p.ReferencedFiles = []string{}
	}
	return p, nil
}

func isINF(name string) bool {
	return strings.EqualFold(filepath.Ext(name), ".inf")
}

// ---------------------------------------------------------------------------
// Scanning and sealing
// ---------------------------------------------------------------------------

// scan is one read-only pass over a driver store.
type scan struct {
	Root        string
	Files       []FileEntry
	Packages    []Package
	InfFailures []Failure
	ReadErrors  []Failure
	TotalBytes  int64
}

func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// openStore validates that root is a usable directory.
func openStore(root string) error {
	st, err := os.Stat(root)
	if err != nil {
		return err
	}
	if !st.IsDir() {
		return fmt.Errorf("%s: not a directory (a driver store must be a folder)", filepath.ToSlash(root))
	}
	return nil
}

// scanStore walks every file under root, hashes it, parses every .inf and
// attributes each file to the package that owns it. It never writes.
func scanStore(root string) (*scan, error) {
	if err := openStore(root); err != nil {
		return nil, err
	}
	sc := &scan{
		Root:        filepath.ToSlash(filepath.Clean(root)),
		Files:       []FileEntry{},
		Packages:    []Package{},
		InfFailures: []Failure{},
		ReadErrors:  []Failure{},
	}

	type rawFile struct {
		rel  string
		abs  string
		size int64
		mod  time.Time
	}
	var raws []rawFile
	var infs []string

	err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			sc.ReadErrors = append(sc.ReadErrors, Failure{File: relOf(root, path), Error: err.Error()})
			if d != nil && d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			return nil
		}
		if !d.Type().IsRegular() {
			sc.ReadErrors = append(sc.ReadErrors, Failure{
				File:  relOf(root, path),
				Error: "not a regular file (" + d.Type().String() + "), skipped",
			})
			return nil
		}
		info, err := d.Info()
		if err != nil {
			sc.ReadErrors = append(sc.ReadErrors, Failure{File: relOf(root, path), Error: err.Error()})
			return nil
		}
		raws = append(raws, rawFile{relOf(root, path), path, info.Size(), info.ModTime()})
		if isINF(d.Name()) {
			infs = append(infs, path)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}

	// Parse the .inf packages first: a parse failure is reported but never
	// aborts the seal.
	sort.Strings(infs)
	for _, path := range infs {
		p, err := parsePackage(path)
		if err != nil {
			sc.InfFailures = append(sc.InfFailures, Failure{File: relOf(root, path), Error: err.Error()})
			continue
		}
		p.File = relOf(root, path)
		sc.Packages = append(sc.Packages, *p)
	}

	ix := ownerIndexOf(sc)

	sort.Slice(raws, func(i, j int) bool { return raws[i].rel < raws[j].rel })
	for _, r := range raws {
		sum, err := hashFile(r.abs)
		if err != nil {
			sc.ReadErrors = append(sc.ReadErrors, Failure{File: r.rel, Error: err.Error()})
			continue
		}
		sc.Files = append(sc.Files, FileEntry{
			Path:      r.rel,
			SizeBytes: r.size,
			ModTime:   r.mod.UTC().Format(time.RFC3339Nano),
			SHA256:    sum,
			Package:   ix.ownerFor(r.rel),
		})
		sc.TotalBytes += r.size
	}
	return sc, nil
}

func relOf(root, path string) string {
	rel, err := filepath.Rel(root, path)
	if err != nil {
		return filepath.ToSlash(path)
	}
	return filepath.ToSlash(rel)
}

// ownerIndex answers which .inf package accounts for a given file: the .inf
// itself, a file the .inf names, or a file living in (or under) the folder that
// holds the .inf. Anything left over is a stray.
type ownerIndex struct {
	owner  map[string]string   // inf rel path -> itself
	infDir map[string]string   // dir -> inf rel path
	refBy  map[string][]string // lowercase base name -> inf rel paths
}

// ownerFor answers which package a relative path belongs to.
func (ix *ownerIndex) ownerFor(rel string) string {
	if p, ok := ix.owner[rel]; ok {
		return p
	}
	base := strings.ToLower(filepath.Base(rel))
	if owners, ok := ix.refBy[base]; ok && len(owners) > 0 {
		dir := filepath.ToSlash(filepath.Dir(rel))
		for _, o := range owners {
			if filepath.ToSlash(filepath.Dir(o)) == dir {
				return o
			}
		}
		sorted := append([]string{}, owners...)
		sort.Strings(sorted)
		return sorted[0]
	}
	dir := filepath.ToSlash(filepath.Dir(rel))
	for {
		if inf, ok := ix.infDir[dir]; ok {
			return inf
		}
		if dir == "." || dir == "/" || dir == "" {
			break
		}
		parent := filepath.ToSlash(filepath.Dir(dir))
		if parent == dir {
			break
		}
		dir = parent
	}
	return noPackage
}

// storeDigest folds the sorted per-file hashes into one digest for the whole
// store. Sorting makes it independent of walk order.
func storeDigest(files []FileEntry) string {
	lines := make([]string, 0, len(files))
	for _, f := range files {
		lines = append(lines, f.Path+"\x00"+f.SHA256)
	}
	sort.Strings(lines)
	h := sha256.New()
	for _, l := range lines {
		io.WriteString(h, l)
		h.Write([]byte{'\n'})
	}
	return hex.EncodeToString(h.Sum(nil))
}

func manifestOf(sc *scan, note string) *Manifest {
	names := []string{}
	for _, p := range sc.Packages {
		names = append(names, p.File)
	}
	sort.Strings(names)
	m := &Manifest{
		Tool:      toolName,
		Version:   toolVersion,
		Format:    formatVer,
		Generated: time.Now().UTC().Format(time.RFC3339),
		Root:      sc.Root,
		Note:      note,
		Summary: Summary{
			PackageCount: len(sc.Packages),
			FileCount:    len(sc.Files),
			TotalBytes:   sc.TotalBytes,
			StoreDigest:  storeDigest(sc.Files),
		},
		Packages:    names,
		InfFailures: sc.InfFailures,
		ReadErrors:  sc.ReadErrors,
		Files:       sc.Files,
	}
	return m
}

func loadManifest(path string) (*Manifest, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if len(data) == 0 {
		return nil, fmt.Errorf("%s: file is empty", filepath.ToSlash(path))
	}
	var m Manifest
	if err := json.Unmarshal(data, &m); err != nil {
		return nil, fmt.Errorf("%s: not a valid %s manifest: %v", filepath.ToSlash(path), toolName, err)
	}
	if m.Tool != toolName {
		return nil, fmt.Errorf("%s: not a %s manifest (tool=%q)", filepath.ToSlash(path), toolName, m.Tool)
	}
	if m.Format != formatVer {
		return nil, fmt.Errorf("%s: manifest format %d is not supported (want %d)", filepath.ToSlash(path), m.Format, formatVer)
	}
	if m.Files == nil {
		return nil, fmt.Errorf("%s: manifest has no files array", filepath.ToSlash(path))
	}
	if m.Summary.StoreDigest == "" {
		return nil, fmt.Errorf("%s: manifest has no store digest", filepath.ToSlash(path))
	}
	return &m, nil
}

// ---------------------------------------------------------------------------
// Verify / compare reporting
// ---------------------------------------------------------------------------

type fileDiff struct {
	Path              string `json:"path"`
	Status            string `json:"status"`
	Package           string `json:"package"`
	SealedSHA256      string `json:"sealedSha256,omitempty"`
	CurrentSHA256     string `json:"currentSha256,omitempty"`
	SealedSize        int64  `json:"sealedSizeBytes,omitempty"`
	CurrentSize       int64  `json:"currentSizeBytes,omitempty"`
	SealedModTime     string `json:"sealedModTime,omitempty"`
	CurrentModTime    string `json:"currentModTime,omitempty"`
	MetadataUnchanged bool   `json:"metadataUnchanged,omitempty"`
}

type verifyReport struct {
	Tool             string     `json:"tool"`
	Command          string     `json:"command"`
	Store            string     `json:"store"`
	ManifestFile     string     `json:"manifestFile"`
	ManifestRoot     string     `json:"manifestRoot"`
	ManifestSealed   string     `json:"manifestSealed"`
	Note             string     `json:"note"`
	SealedDigest     string     `json:"sealedDigest"`
	CurrentDigest    string     `json:"currentDigest"`
	SealedFileCount  int        `json:"sealedFileCount"`
	CurrentFileCount int        `json:"currentFileCount"`
	OKCount          int        `json:"okCount"`
	Modified         []fileDiff `json:"modified"`
	Missing          []fileDiff `json:"missing"`
	Added            []fileDiff `json:"added"`
	AffectedPackages []string   `json:"affectedPackages"`
	InfFailures      []Failure  `json:"infFailures"`
	ReadErrors       []Failure  `json:"readErrors"`
	Differences      int        `json:"differences"`
	Verdict          string     `json:"verdict"`
}

// diffFiles compares a sealed file list with a current file list.
func diffFiles(sealed, current []FileEntry, ownerFor func(string) string) (mod, missing, added []fileDiff, ok int) {
	cur := map[string]FileEntry{}
	for _, f := range current {
		cur[f.Path] = f
	}
	old := map[string]FileEntry{}
	for _, f := range sealed {
		old[f.Path] = f
	}
	mod, missing, added = []fileDiff{}, []fileDiff{}, []fileDiff{}

	for _, s := range sealed {
		c, present := cur[s.Path]
		pkg := s.Package
		if pkg == "" {
			pkg = noPackage
		}
		if !present {
			missing = append(missing, fileDiff{
				Path: s.Path, Status: "MISSING", Package: pkg,
				SealedSHA256: s.SHA256, SealedSize: s.SizeBytes, SealedModTime: s.ModTime,
			})
			continue
		}
		if c.SHA256 == s.SHA256 {
			ok++
			continue
		}
		mod = append(mod, fileDiff{
			Path: s.Path, Status: "MODIFIED", Package: pkg,
			SealedSHA256: s.SHA256, CurrentSHA256: c.SHA256,
			SealedSize: s.SizeBytes, CurrentSize: c.SizeBytes,
			SealedModTime: s.ModTime, CurrentModTime: c.ModTime,
			MetadataUnchanged: s.SizeBytes == c.SizeBytes && s.ModTime == c.ModTime,
		})
	}
	for _, c := range current {
		if _, present := old[c.Path]; present {
			continue
		}
		pkg := c.Package
		if ownerFor != nil {
			pkg = ownerFor(c.Path)
		}
		if pkg == "" {
			pkg = noPackage
		}
		added = append(added, fileDiff{
			Path: c.Path, Status: "ADDED", Package: pkg,
			CurrentSHA256: c.SHA256, CurrentSize: c.SizeBytes, CurrentModTime: c.ModTime,
		})
	}
	sort.Slice(mod, func(i, j int) bool { return mod[i].Path < mod[j].Path })
	sort.Slice(missing, func(i, j int) bool { return missing[i].Path < missing[j].Path })
	sort.Slice(added, func(i, j int) bool { return added[i].Path < added[j].Path })
	return mod, missing, added, ok
}

func affectedPackages(groups ...[]fileDiff) []string {
	seen := map[string]bool{}
	var out []string
	for _, g := range groups {
		for _, d := range g {
			if d.Package == "" || seen[d.Package] {
				continue
			}
			seen[d.Package] = true
			out = append(out, d.Package)
		}
	}
	sort.Strings(out)
	if out == nil {
		out = []string{}
	}
	return out
}

// sameStore decides whether a manifest plausibly describes this store, so a
// manifest from a DIFFERENT store is reported instead of silently compared.
func sameStore(sealed, current []FileEntry) (common int, ratio float64) {
	cur := map[string]bool{}
	for _, f := range current {
		cur[f.Path] = true
	}
	for _, f := range sealed {
		if cur[f.Path] {
			common++
		}
	}
	union := len(cur)
	for _, f := range sealed {
		if !cur[f.Path] {
			union++
		}
	}
	if union == 0 {
		return 0, 1
	}
	return common, float64(common) / float64(union)
}

// ---------------------------------------------------------------------------
// Output helpers
// ---------------------------------------------------------------------------

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

func fatalf(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", toolName, fmt.Sprintf(format, args...))
	os.Exit(exitUsage)
}

func usage() {
	fmt.Fprint(os.Stderr, helpText)
	os.Exit(exitUsage)
}

func orNone(s string) string {
	if strings.TrimSpace(s) == "" {
		return "(none)"
	}
	return s
}

func shortSum(s string) string {
	if len(s) <= 16 {
		return s
	}
	return s[:16] + ".."
}

const helpText = toolLine + `

Seal a driver store folder into a manifest of every file's SHA-256, then prove
later that the folder on disk is still EXACTLY what you sealed. This checks FILE
INTEGRITY against your own manifest. It does NOT verify Authenticode or catalog
signatures, never touches the live Windows driver store, and installs nothing.
It is strictly read-only on the store; only the --out manifest is written.

USAGE
  devicedriver <command> [options]

COMMANDS
  seal <driver-store-dir> --out <manifest.json> [--note "text"] [--json]
        Walk every file (not only .inf), record relative path, size, mtime and
        SHA-256, plus package count, file count, total bytes and one overall
        store digest folded from the sorted per-file hashes.

  verify <driver-store-dir> --manifest <manifest.json> [--json]
        Compare the store against the seal: OK / MODIFIED / MISSING / ADDED per
        file, naming the .inf package each affected file belongs to. Detection
        is by content hash, so restoring a file's size and mtime does not hide
        a change. Exits 2 if anything differs.

  compare --before <a.json> --after <b.json> [--json]
        Diff two seals directly, without the store being present. Exits 2 if
        the two seals differ.

  inspect <driver-store-dir> [--json]
        What is in this store: every .inf package with provider, class,
        DriverVer and hardware IDs, plus files no .inf accounts for.

  help, -h, --help      Show this help.
  version               Print version.

EXIT CODES
  0  success, nothing differs
  1  usage error, unreadable input, or a manifest for a different store
  2  verify/compare found at least one difference

EXAMPLES
  devicedriver seal ./DriverStore --out approved.json --note "approved by ops"
  devicedriver verify ./DriverStore --manifest approved.json
  devicedriver compare --before approved.json --after today.json --json
  devicedriver inspect ./DriverStore
`

// ---------------------------------------------------------------------------
// Flag handling
// ---------------------------------------------------------------------------

// takeFlags pulls known flags out of args and returns the positional rest.
func takeFlags(args []string, valueFlags map[string]bool, boolFlags map[string]bool) (map[string]string, []string, error) {
	args = reorderFlags(args, valueFlags)
	vals := map[string]string{}
	var rest []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		if !strings.HasPrefix(a, "-") {
			rest = append(rest, a)
			continue
		}
		name := strings.ToLower(strings.TrimLeft(a, "-"))
		if eq := strings.Index(name, "="); eq >= 0 {
			key := name[:eq]
			if !valueFlags[key] {
				return nil, nil, fmt.Errorf("unknown flag: %s", a)
			}
			vals[key] = name[eq+1:]
			continue
		}
		switch {
		case boolFlags[name]:
			vals[name] = "true"
		case valueFlags[name]:
			if i+1 >= len(args) {
				return nil, nil, fmt.Errorf("flag --%s needs a value", name)
			}
			i++
			vals[name] = args[i]
		default:
			return nil, nil, fmt.Errorf("unknown flag: %s", a)
		}
	}
	return vals, rest, nil
}

func isHelp(a string) bool {
	switch strings.ToLower(a) {
	case "-h", "--help", "help", "-help":
		return true
	}
	return false
}

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()
	}
	for _, a := range args {
		if isHelp(a) {
			fmt.Print(helpText)
			os.Exit(exitOK)
		}
	}
	cmd := strings.ToLower(args[0])
	rest := args[1:]

	switch cmd {
	case "seal":
		cmdSeal(rest)
	case "verify":
		cmdVerify(rest)
	case "compare":
		cmdCompare(rest)
	case "inspect":
		cmdInspect(rest)
	case "version", "--version", "-v":
		fmt.Println(toolLine)
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n\n", toolName, args[0])
		usage()
	}
}

// ---------------------------------------------------------------------------
// seal
// ---------------------------------------------------------------------------

func cmdSeal(args []string) {
	vals, rest, err := takeFlags(args,
		map[string]bool{"out": true, "note": true},
		map[string]bool{"json": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "%s: seal needs exactly one driver store directory\n\n", toolName)
		usage()
	}
	out := strings.TrimSpace(vals["out"])
	if out == "" {
		fmt.Fprintf(os.Stderr, "%s: seal requires --out <manifest.json>\n\n", toolName)
		usage()
	}
	sc, err := scanStore(rest[0])
	if err != nil {
		fatalf("%v", err)
	}
	if len(sc.Files) == 0 {
		fatalf("%s: store is empty (no files found) - nothing to seal", filepath.ToSlash(rest[0]))
	}
	m := manifestOf(sc, strings.TrimSpace(vals["note"]))

	data, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		fatalf("encoding manifest: %v", err)
	}
	data = append(data, '\n')
	if err := os.WriteFile(out, data, 0o644); err != nil {
		fatalf("writing %s: %v", out, err)
	}

	if vals["json"] == "true" {
		if err := emitJSON(struct {
			Tool     string    `json:"tool"`
			Command  string    `json:"command"`
			Out      string    `json:"out"`
			Manifest *Manifest `json:"manifest"`
		}{toolName, "seal", filepath.ToSlash(out), m}); err != nil {
			fatalf("writing json: %v", err)
		}
		return
	}

	fmt.Printf("Sealed store: %s\n", m.Root)
	fmt.Printf("Manifest:     %s (%s)\n", filepath.ToSlash(out), humanBytes(int64(len(data))))
	if m.Note != "" {
		fmt.Printf("Note:         %s\n", m.Note)
	}
	fmt.Printf("Sealed at:    %s\n\n", m.Generated)
	fmt.Printf("Packages (.inf): %d\n", m.Summary.PackageCount)
	fmt.Printf("Files sealed:    %d\n", m.Summary.FileCount)
	fmt.Printf("Total bytes:     %d (%s)\n", m.Summary.TotalBytes, humanBytes(m.Summary.TotalBytes))
	fmt.Printf("Store digest:    %s\n", m.Summary.StoreDigest)

	fmt.Printf("\nPackages:\n")
	for _, p := range sc.Packages {
		fmt.Printf("  %-34s %-20s %-12s %s\n", p.File,
			trunc(orNone(p.Provider), 20), orNone(p.Class), orNone(p.DriverVerVersion))
	}
	if len(sc.Packages) == 0 {
		fmt.Println("  (none)")
	}
	if len(m.InfFailures) > 0 {
		fmt.Printf("\nUnparseable .inf files (%d) - sealed by hash anyway:\n", len(m.InfFailures))
		for _, f := range m.InfFailures {
			fmt.Printf("  ! %s: %s\n", f.File, f.Error)
		}
	}
	if len(m.ReadErrors) > 0 {
		fmt.Printf("\nUnreadable entries (%d) - skipped:\n", len(m.ReadErrors))
		for _, f := range m.ReadErrors {
			fmt.Printf("  ! %s: %s\n", f.File, f.Error)
		}
	}
}

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

func cmdVerify(args []string) {
	vals, rest, err := takeFlags(args,
		map[string]bool{"manifest": true},
		map[string]bool{"json": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "%s: verify needs exactly one driver store directory\n\n", toolName)
		usage()
	}
	mf := strings.TrimSpace(vals["manifest"])
	if mf == "" {
		fmt.Fprintf(os.Stderr, "%s: verify requires --manifest <manifest.json>\n\n", toolName)
		usage()
	}
	m, err := loadManifest(mf)
	if err != nil {
		fatalf("%v", err)
	}
	sc, err := scanStore(rest[0])
	if err != nil {
		fatalf("%v", err)
	}
	if len(sc.Files) == 0 {
		fatalf("%s: store is empty (no files found) - nothing to verify", filepath.ToSlash(rest[0]))
	}

	common, ratio := sameStore(m.Files, sc.Files)
	if common == 0 {
		fatalf("manifest %s does not describe %s: none of its %d sealed path(s) exist there "+
			"(manifest was sealed from %q). Refusing to compare two different stores.",
			filepath.ToSlash(mf), sc.Root, len(m.Files), m.Root)
	}

	ix := ownerIndexOf(sc)
	mod, missing, added, okCount := diffFiles(m.Files, sc.Files, ix.ownerFor)

	rep := &verifyReport{
		Tool: toolName, Command: "verify",
		Store: sc.Root, ManifestFile: filepath.ToSlash(mf),
		ManifestRoot: m.Root, ManifestSealed: m.Generated, Note: m.Note,
		SealedDigest: m.Summary.StoreDigest, CurrentDigest: storeDigest(sc.Files),
		SealedFileCount: len(m.Files), CurrentFileCount: len(sc.Files),
		OKCount:  okCount,
		Modified: mod, Missing: missing, Added: added,
		AffectedPackages: affectedPackages(mod, missing, added),
		InfFailures:      sc.InfFailures, ReadErrors: sc.ReadErrors,
	}
	rep.Differences = len(mod) + len(missing) + len(added)
	if rep.Differences == 0 {
		rep.Verdict = "OK"
	} else {
		rep.Verdict = "TAMPERED"
	}

	if vals["json"] == "true" {
		if err := emitJSON(rep); err != nil {
			fatalf("writing json: %v", err)
		}
	} else {
		printVerify(rep, ratio)
	}
	if rep.Differences > 0 {
		os.Exit(exitFindings)
	}
}

func printVerify(rep *verifyReport, ratio float64) {
	fmt.Printf("Store:    %s\n", rep.Store)
	fmt.Printf("Manifest: %s (sealed %s from %s)\n", rep.ManifestFile, rep.ManifestSealed, rep.ManifestRoot)
	if rep.Note != "" {
		fmt.Printf("Note:     %s\n", rep.Note)
	}
	fmt.Printf("Sealed digest:  %s (%d file(s))\n", rep.SealedDigest, rep.SealedFileCount)
	fmt.Printf("Current digest: %s (%d file(s))\n", rep.CurrentDigest, rep.CurrentFileCount)
	if ratio < 0.5 {
		fmt.Printf("\nWARNING: only %.0f%% of the paths line up with this seal - check that this is\n"+
			"         the store you sealed.\n", ratio*100)
	}
	fmt.Println()

	fmt.Printf("MODIFIED (%d):\n", len(rep.Modified))
	for _, d := range rep.Modified {
		fmt.Printf("  ! %s\n", d.Path)
		fmt.Printf("      package: %s\n", d.Package)
		fmt.Printf("      sealed:  %s  %d bytes  %s\n", shortSum(d.SealedSHA256), d.SealedSize, d.SealedModTime)
		fmt.Printf("      now:     %s  %d bytes  %s\n", shortSum(d.CurrentSHA256), d.CurrentSize, d.CurrentModTime)
		if d.MetadataUnchanged {
			fmt.Printf("      note:    size and mtime are UNCHANGED - caught by content hash only\n")
		}
	}
	if len(rep.Modified) == 0 {
		fmt.Println("  (none)")
	}

	fmt.Printf("\nMISSING (%d):\n", len(rep.Missing))
	for _, d := range rep.Missing {
		fmt.Printf("  - %s   package: %s   sealed %s %d bytes\n",
			d.Path, d.Package, shortSum(d.SealedSHA256), d.SealedSize)
	}
	if len(rep.Missing) == 0 {
		fmt.Println("  (none)")
	}

	fmt.Printf("\nADDED (%d):\n", len(rep.Added))
	for _, d := range rep.Added {
		fmt.Printf("  + %s   package: %s   now %s %d bytes\n",
			d.Path, d.Package, shortSum(d.CurrentSHA256), d.CurrentSize)
	}
	if len(rep.Added) == 0 {
		fmt.Println("  (none)")
	}

	fmt.Printf("\nOK: %d file(s) match the seal byte for byte\n", rep.OKCount)

	if len(rep.InfFailures) > 0 {
		fmt.Printf("\nUnparseable .inf files (%d) - integrity still checked:\n", len(rep.InfFailures))
		for _, f := range rep.InfFailures {
			fmt.Printf("  ! %s: %s\n", f.File, f.Error)
		}
	}
	if len(rep.ReadErrors) > 0 {
		fmt.Printf("\nUnreadable entries (%d):\n", len(rep.ReadErrors))
		for _, f := range rep.ReadErrors {
			fmt.Printf("  ! %s: %s\n", f.File, f.Error)
		}
	}

	fmt.Println()
	if rep.Differences == 0 {
		fmt.Printf("VERDICT: OK - the store is exactly what was sealed.\n")
		return
	}
	fmt.Printf("VERDICT: TAMPERED - %d difference(s).\n", rep.Differences)
	fmt.Printf("Affected package(s) to re-fetch: %s\n", strings.Join(rep.AffectedPackages, ", "))
}

// ownerIndexOf builds the file-to-package index for a scan.
func ownerIndexOf(sc *scan) *ownerIndex {
	ix := &ownerIndex{
		owner:  map[string]string{},
		infDir: map[string]string{},
		refBy:  map[string][]string{},
	}
	record := func(rel string) {
		ix.owner[rel] = rel
		d := filepath.ToSlash(filepath.Dir(rel))
		if _, exists := ix.infDir[d]; !exists {
			ix.infDir[d] = rel
		}
	}
	for _, p := range sc.Packages {
		record(p.File)
		for _, r := range p.ReferencedFiles {
			k := strings.ToLower(r)
			if !contains(ix.refBy[k], p.File) {
				ix.refBy[k] = append(ix.refBy[k], p.File)
			}
		}
	}
	for _, f := range sc.InfFailures {
		if isINF(f.File) {
			record(f.File)
		}
	}
	return ix
}

// ---------------------------------------------------------------------------
// compare
// ---------------------------------------------------------------------------

type compareReport struct {
	Tool             string     `json:"tool"`
	Command          string     `json:"command"`
	BeforeFile       string     `json:"beforeFile"`
	AfterFile        string     `json:"afterFile"`
	BeforeRoot       string     `json:"beforeRoot"`
	AfterRoot        string     `json:"afterRoot"`
	BeforeSealed     string     `json:"beforeSealed"`
	AfterSealed      string     `json:"afterSealed"`
	BeforeDigest     string     `json:"beforeDigest"`
	AfterDigest      string     `json:"afterDigest"`
	BeforeFileCount  int        `json:"beforeFileCount"`
	AfterFileCount   int        `json:"afterFileCount"`
	UnchangedCount   int        `json:"unchangedCount"`
	Modified         []fileDiff `json:"modified"`
	Missing          []fileDiff `json:"missing"`
	Added            []fileDiff `json:"added"`
	AffectedPackages []string   `json:"affectedPackages"`
	Differences      int        `json:"differences"`
	Verdict          string     `json:"verdict"`
}

func cmdCompare(args []string) {
	vals, rest, err := takeFlags(args,
		map[string]bool{"before": true, "after": true},
		map[string]bool{"json": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 0 {
		fmt.Fprintf(os.Stderr, "%s: compare takes no positional arguments (got %q)\n\n", toolName, rest[0])
		usage()
	}
	if strings.TrimSpace(vals["before"]) == "" || strings.TrimSpace(vals["after"]) == "" {
		fmt.Fprintf(os.Stderr, "%s: compare requires --before <a.json> and --after <b.json>\n\n", toolName)
		usage()
	}
	before, err := loadManifest(vals["before"])
	if err != nil {
		fatalf("%v", err)
	}
	after, err := loadManifest(vals["after"])
	if err != nil {
		fatalf("%v", err)
	}
	common, _ := sameStore(before.Files, after.Files)
	if common == 0 && len(before.Files) > 0 && len(after.Files) > 0 {
		fatalf("these two seals have no path in common (%q vs %q): they describe different stores. "+
			"Refusing to compare them.", before.Root, after.Root)
	}

	mod, missing, added, okCount := diffFiles(before.Files, after.Files, nil)
	rep := &compareReport{
		Tool: toolName, Command: "compare",
		BeforeFile: filepath.ToSlash(vals["before"]), AfterFile: filepath.ToSlash(vals["after"]),
		BeforeRoot: before.Root, AfterRoot: after.Root,
		BeforeSealed: before.Generated, AfterSealed: after.Generated,
		BeforeDigest: before.Summary.StoreDigest, AfterDigest: after.Summary.StoreDigest,
		BeforeFileCount: len(before.Files), AfterFileCount: len(after.Files),
		UnchangedCount: okCount,
		Modified:       mod, Missing: missing, Added: added,
		AffectedPackages: affectedPackages(mod, missing, added),
	}
	rep.Differences = len(mod) + len(missing) + len(added)
	if rep.Differences == 0 {
		rep.Verdict = "IDENTICAL"
	} else {
		rep.Verdict = "DIFFERENT"
	}

	if vals["json"] == "true" {
		if err := emitJSON(rep); err != nil {
			fatalf("writing json: %v", err)
		}
	} else {
		fmt.Printf("Before: %s  (%s, sealed %s, %d file(s))\n",
			rep.BeforeFile, rep.BeforeRoot, rep.BeforeSealed, rep.BeforeFileCount)
		fmt.Printf("After:  %s  (%s, sealed %s, %d file(s))\n",
			rep.AfterFile, rep.AfterRoot, rep.AfterSealed, rep.AfterFileCount)
		fmt.Printf("Before digest: %s\n", rep.BeforeDigest)
		fmt.Printf("After digest:  %s\n\n", rep.AfterDigest)

		fmt.Printf("MODIFIED (%d):\n", len(rep.Modified))
		for _, d := range rep.Modified {
			fmt.Printf("  ! %s   package: %s\n", d.Path, d.Package)
			fmt.Printf("      %s %d bytes  ->  %s %d bytes\n",
				shortSum(d.SealedSHA256), d.SealedSize, shortSum(d.CurrentSHA256), d.CurrentSize)
			if d.MetadataUnchanged {
				fmt.Printf("      note: size and mtime are UNCHANGED - caught by content hash only\n")
			}
		}
		if len(rep.Modified) == 0 {
			fmt.Println("  (none)")
		}
		fmt.Printf("\nMISSING (%d):\n", len(rep.Missing))
		for _, d := range rep.Missing {
			fmt.Printf("  - %s   package: %s\n", d.Path, d.Package)
		}
		if len(rep.Missing) == 0 {
			fmt.Println("  (none)")
		}
		fmt.Printf("\nADDED (%d):\n", len(rep.Added))
		for _, d := range rep.Added {
			fmt.Printf("  + %s   package: %s\n", d.Path, d.Package)
		}
		if len(rep.Added) == 0 {
			fmt.Println("  (none)")
		}
		fmt.Printf("\nUNCHANGED: %d file(s)\n\n", rep.UnchangedCount)
		if rep.Differences == 0 {
			fmt.Printf("VERDICT: IDENTICAL - both seals describe the same bytes.\n")
		} else {
			fmt.Printf("VERDICT: DIFFERENT - %d difference(s).\n", rep.Differences)
			fmt.Printf("Affected package(s): %s\n", strings.Join(rep.AffectedPackages, ", "))
		}
	}
	if rep.Differences > 0 {
		os.Exit(exitFindings)
	}
}

// ---------------------------------------------------------------------------
// inspect
// ---------------------------------------------------------------------------

type inspectPackage struct {
	File             string   `json:"file"`
	Provider         string   `json:"provider"`
	Class            string   `json:"class"`
	ClassGUID        string   `json:"classGuid"`
	DriverVerDate    string   `json:"driverVerDate"`
	DriverVerVersion string   `json:"driverVerVersion"`
	CatalogFile      string   `json:"catalogFile"`
	HardwareIDs      []string `json:"hardwareIds"`
	PayloadFiles     []string `json:"payloadFiles"`
	MissingFiles     []string `json:"missingFiles"`
	SizeBytes        int64    `json:"sizeBytes"`
	Encoding         string   `json:"encoding"`
}

type strayFile struct {
	Path       string `json:"path"`
	SizeBytes  int64  `json:"sizeBytes"`
	SHA256     string `json:"sha256"`
	NearestInf string `json:"nearestInf"`
}

type inspectReport struct {
	Tool        string           `json:"tool"`
	Command     string           `json:"command"`
	Store       string           `json:"store"`
	FileCount   int              `json:"fileCount"`
	TotalBytes  int64            `json:"totalBytes"`
	StoreDigest string           `json:"storeDigest"`
	Packages    []inspectPackage `json:"packages"`
	StrayFiles  []strayFile      `json:"strayFiles"`
	InfFailures []Failure        `json:"infFailures"`
	ReadErrors  []Failure        `json:"readErrors"`
}

func cmdInspect(args []string) {
	vals, rest, err := takeFlags(args, map[string]bool{}, map[string]bool{"json": true})
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n\n", toolName, err)
		usage()
	}
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "%s: inspect needs exactly one driver store directory\n\n", toolName)
		usage()
	}
	sc, err := scanStore(rest[0])
	if err != nil {
		fatalf("%v", err)
	}
	if len(sc.Files) == 0 {
		fatalf("%s: store is empty (no files found) - nothing to inspect", filepath.ToSlash(rest[0]))
	}

	present := map[string]bool{}
	byPath := map[string]FileEntry{}
	for _, f := range sc.Files {
		present[strings.ToLower(filepath.Base(f.Path))] = true
		byPath[f.Path] = f
	}

	rep := &inspectReport{
		Tool: toolName, Command: "inspect", Store: sc.Root,
		FileCount: len(sc.Files), TotalBytes: sc.TotalBytes,
		StoreDigest: storeDigest(sc.Files),
		Packages:    []inspectPackage{}, StrayFiles: []strayFile{},
		InfFailures: sc.InfFailures, ReadErrors: sc.ReadErrors,
	}

	claimed := map[string]bool{} // lowercase base names claimed by some .inf
	for _, p := range sc.Packages {
		ip := inspectPackage{
			File: p.File, Provider: p.Provider, Class: p.Class, ClassGUID: p.ClassGUID,
			DriverVerDate: p.DriverVerDate, DriverVerVersion: p.DriverVerVersion,
			CatalogFile: p.CatalogFile, HardwareIDs: p.HardwareIDs,
			PayloadFiles: p.ReferencedFiles, MissingFiles: []string{},
			SizeBytes: p.SizeBytes, Encoding: p.Encoding,
		}
		for _, r := range p.ReferencedFiles {
			claimed[strings.ToLower(r)] = true
			if !present[strings.ToLower(r)] {
				ip.MissingFiles = append(ip.MissingFiles, r)
			}
		}
		rep.Packages = append(rep.Packages, ip)
	}

	ix := ownerIndexOf(sc)
	for _, f := range sc.Files {
		if isINF(f.Path) {
			continue
		}
		if claimed[strings.ToLower(filepath.Base(f.Path))] {
			continue
		}
		rep.StrayFiles = append(rep.StrayFiles, strayFile{
			Path: f.Path, SizeBytes: f.SizeBytes, SHA256: f.SHA256,
			NearestInf: ix.ownerFor(f.Path),
		})
	}

	if vals["json"] == "true" {
		if err := emitJSON(rep); err != nil {
			fatalf("writing json: %v", err)
		}
		return
	}

	fmt.Printf("Store: %s\n", rep.Store)
	fmt.Printf("Files: %d (%s)   Packages: %d   Digest: %s\n\n",
		rep.FileCount, humanBytes(rep.TotalBytes), len(rep.Packages), rep.StoreDigest)

	for _, p := range rep.Packages {
		fmt.Printf("PACKAGE %s (%s, %s)\n", p.File, humanBytes(p.SizeBytes), p.Encoding)
		fmt.Printf("  Provider:   %s\n", orNone(p.Provider))
		fmt.Printf("  Class:      %s  %s\n", orNone(p.Class), orNone(p.ClassGUID))
		fmt.Printf("  DriverVer:  date=%s version=%s\n", orNone(p.DriverVerDate), orNone(p.DriverVerVersion))
		fmt.Printf("  Catalog:    %s\n", orNone(p.CatalogFile))
		fmt.Printf("  Hardware IDs (%d):\n", len(p.HardwareIDs))
		for _, id := range p.HardwareIDs {
			fmt.Printf("    %s\n", id)
		}
		if len(p.HardwareIDs) == 0 {
			fmt.Println("    (none)")
		}
		fmt.Printf("  Payload files named by this .inf (%d): %s\n",
			len(p.PayloadFiles), joinOrNone(p.PayloadFiles))
		if len(p.MissingFiles) > 0 {
			fmt.Printf("  ! Named but NOT present: %s\n", strings.Join(p.MissingFiles, ", "))
		}
		fmt.Println()
	}
	if len(rep.Packages) == 0 {
		fmt.Println("(no parseable .inf packages)")
		fmt.Println()
	}

	fmt.Printf("STRAY FILES - not named by any .inf (%d):\n", len(rep.StrayFiles))
	for _, s := range rep.StrayFiles {
		fmt.Printf("  ? %-40s %9d bytes  %s  nearest .inf: %s\n",
			s.Path, s.SizeBytes, shortSum(s.SHA256), s.NearestInf)
	}
	if len(rep.StrayFiles) == 0 {
		fmt.Println("  (none)")
	}

	if len(rep.InfFailures) > 0 {
		fmt.Printf("\nUNPARSEABLE .inf FILES (%d):\n", len(rep.InfFailures))
		for _, f := range rep.InfFailures {
			fmt.Printf("  ! %s: %s\n", f.File, f.Error)
		}
	}
	if len(rep.ReadErrors) > 0 {
		fmt.Printf("\nUNREADABLE ENTRIES (%d):\n", len(rep.ReadErrors))
		for _, f := range rep.ReadErrors {
			fmt.Printf("  ! %s: %s\n", f.File, f.Error)
		}
	}
}

func joinOrNone(ss []string) string {
	if len(ss) == 0 {
		return "(none)"
	}
	return strings.Join(ss, ", ")
}
