mithril-go/internal/manifest/manifest.go
Kayos 599085eaa9 wrap: chain verify + manifest verify + LICENSE + final docs
- internal/chain: end-to-end chain verification. Walks head → genesis,
  verifies every cert (Ed25519 or STM as appropriate), and checks
  continuity at every boundary:
    epoch:    same or +1 from previous
    hash:     current.previous_hash == previous.hash
    AVK:      same epoch  → equal aggregate_verification_key
              new epoch   → matches previous.protocol_message.next_aggregate_verification_key
- cmd: 'verify chain' subcommand + 'verify manifest <dir>' for SHA-checking
  downloaded immutable files
- internal/manifest: per-file SHA-256 verification against the digests.json
  shipped in the snapshot's digests archive
- MCP: 8th tool 'mithril_verify_chain' for agent-driven full-chain verify
- README: complete rewrite — status table, architecture, gotchas, MCP
  tool surface, exit code contract, build instructions
- LICENSE: Apache-2.0 (matches upstream Mithril)

Verified end to end against live networks:
  preprod  chain  90 certs (89 STM + 1 genesis)  1124 wins   ✓
  mainnet  chain  89 certs (88 STM + 1 genesis)  210921 wins ✓

That's the wrap. Pure-Go consensus-correct Mithril client, single 10 MB
static binary, MCP-native, no CGo, no upstream Rust runtime.
2026-04-23 16:15:47 -07:00

139 lines
3.9 KiB
Go

// Package manifest verifies downloaded Mithril snapshot files against the
// per-immutable digest manifest extracted from the snapshot's `digests`
// archive.
//
// The manifest is a flat JSON array of {immutable_file_name, digest}
// entries — one per file in the chain DB's immutable directory. Each
// digest is the SHA-256 of the file contents.
package manifest
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// Entry is one record from the digests JSON.
type Entry struct {
ImmutableFileName string `json:"immutable_file_name"`
Digest string `json:"digest"` // hex-encoded SHA-256
}
// Load reads + parses a digests.json file.
func Load(path string) ([]Entry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []Entry
if err := json.NewDecoder(f).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
// LocateDigests finds the digests.json file inside the extracted digests
// directory (named like `<network>-e<epoch>-i<imm>.digests.json`).
func LocateDigests(digestsDir string) (string, error) {
entries, err := os.ReadDir(digestsDir)
if err != nil {
return "", err
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".digests.json") {
return filepath.Join(digestsDir, e.Name()), nil
}
}
return "", fmt.Errorf("no digests.json found under %s", digestsDir)
}
// Result captures the per-file outcome of a manifest verification pass.
type Result struct {
TotalEntries int `json:"total_entries"`
Verified int `json:"verified"`
Missing []string `json:"missing,omitempty"` // files in manifest, not on disk
Mismatched []string `json:"mismatched,omitempty"` // SHA didn't match
Unverifiable []string `json:"unverifiable,omitempty"` // failed to read
Extra []string `json:"extra,omitempty"` // files on disk, not in manifest
OK bool `json:"ok"`
}
// Verify walks the manifest and checks each immutable file under dbDir.
// Files in the immutable subdirectory are at `<dbDir>/immutable/<name>`.
func Verify(entries []Entry, dbDir string) (*Result, error) {
r := &Result{TotalEntries: len(entries)}
immDir := filepath.Join(dbDir, "immutable")
// Build set of expected names for the extra-file diff.
expected := make(map[string]struct{}, len(entries))
for _, e := range entries {
expected[e.ImmutableFileName] = struct{}{}
}
for _, e := range entries {
path := filepath.Join(immDir, e.ImmutableFileName)
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
r.Missing = append(r.Missing, e.ImmutableFileName)
continue
}
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": "+err.Error())
continue
}
if fi.IsDir() {
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": is a directory")
continue
}
got, err := sha256OfFile(path)
if err != nil {
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": "+err.Error())
continue
}
if got != e.Digest {
r.Mismatched = append(r.Mismatched, fmt.Sprintf("%s: got %s, want %s",
e.ImmutableFileName, got, e.Digest))
continue
}
r.Verified++
}
// Identify any extra files in immutable/ that aren't in the manifest.
if entries, err := os.ReadDir(immDir); err == nil {
for _, fe := range entries {
if fe.IsDir() {
continue
}
if _, ok := expected[fe.Name()]; !ok {
r.Extra = append(r.Extra, fe.Name())
}
}
}
sort.Strings(r.Missing)
sort.Strings(r.Mismatched)
sort.Strings(r.Unverifiable)
sort.Strings(r.Extra)
r.OK = len(r.Missing) == 0 && len(r.Mismatched) == 0 && len(r.Unverifiable) == 0
return r, nil
}
func sha256OfFile(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
}