1package assets
2
3// Content manifest for served systemview assets (Plan 05 P3.1).
4//
5// The manifest pins the SHA-256 and size of every uncompressed immutable
6// asset the CEF scheme handler may serve with long-lived cache headers.
7// It is generated by cmd/systemviews-assets after the WASM build as an
8// untracked build artifact; verification compares it against the actually
9// embedded artifacts, never against Git state. Encoding is canonical JSON with
10// sorted keys and no timestamps or paths, so identical inputs always
11// produce identical bytes.
12
13import (
14 "bytes"
15 "crypto/sha256"
16 "encoding/hex"
17 "encoding/json"
18 "fmt"
19 "io"
20 "io/fs"
21)
22
23// ManifestVersion is the only accepted manifest schema version.
24const ManifestVersion = 1
25
26// ManifestName is the manifest file name inside assets/systemviews.
27const ManifestName = "asset-manifest.json"
28
29// ManifestFiles lists every served uncompressed asset pinned by the
30// manifest, in canonical order.
31var ManifestFiles = []string{"systemviews.css", "systemviews.wasm", "wasm_exec.js"}
32
33// FileEntry pins one asset's content identity.
34type FileEntry struct {
35 SHA256 string `json:"sha256"`
36 Size int64 `json:"size"`
37}
38
39// Manifest pins the content identity of servable immutable assets.
40type Manifest struct {
41 Version int `json:"version"`
42 Files map[string]FileEntry `json:"files"`
43}
44
45// ParseManifest decodes and validates manifest bytes. Unknown files,
46// missing pins, malformed digests, and version mismatches are all hard
47// errors: a trust root must fail closed, never serve unverified immutable
48// content. Every pin must be exactly 64 lowercase hex characters: pins
49// are interpolated into shell HTML/JS, so anything else is rejected
50// before any shell use rather than escaped after it.
51func ParseManifest(data []byte) (Manifest, error) {
52 var m Manifest
53 dec := json.NewDecoder(bytes.NewReader(data))
54 dec.DisallowUnknownFields()
55 if err := dec.Decode(&m); err != nil {
56 return Manifest{}, fmt.Errorf("decode asset manifest: %w", err)
57 }
58 var extra json.RawMessage
59 if err := dec.Decode(&extra); err != io.EOF {
60 return Manifest{}, fmt.Errorf("asset manifest has trailing data")
61 }
62 if m.Version != ManifestVersion {
63 return Manifest{}, fmt.Errorf("unsupported asset manifest version %d", m.Version)
64 }
65 if len(m.Files) != len(ManifestFiles) {
66 return Manifest{}, fmt.Errorf("asset manifest pins %d files, want %d", len(m.Files), len(ManifestFiles))
67 }
68 for _, name := range ManifestFiles {
69 entry, ok := m.Files[name]
70 if !ok {
71 return Manifest{}, fmt.Errorf("asset manifest missing pin for %s", name)
72 }
73 if !validDigest(entry.SHA256) {
74 return Manifest{}, fmt.Errorf("asset manifest has invalid pin for %s", name)
75 }
76 if entry.Size < 0 {
77 return Manifest{}, fmt.Errorf("asset manifest has invalid size for %s", name)
78 }
79 }
80 return m, nil
81}
82
83// validDigest reports whether s is a SHA-256 hex digest: exactly 64
84// lowercase hexadecimal characters, nothing else.
85func validDigest(s string) bool {
86 if len(s) != 64 {
87 return false
88 }
89 for i := 0; i < len(s); i++ {
90 digit := s[i] >= '0' && s[i] <= '9'
91 lowerHex := s[i] >= 'a' && s[i] <= 'f'
92 if !digit && !lowerHex {
93 return false
94 }
95 }
96 return true
97}
98
99// Bytes renders the canonical manifest encoding: indented JSON with sorted
100// keys, no timestamps or paths, terminated by a single newline.
101func (m Manifest) Bytes() ([]byte, error) {
102 data, err := json.MarshalIndent(m, "", " ")
103 if err != nil {
104 return nil, fmt.Errorf("encode asset manifest: %w", err)
105 }
106 return append(data, '\n'), nil
107}
108
109// Verify checks every pinned file against its actual bytes in fsys. Names
110// resolve at the filesystem root; callers pass fs.Sub when artifacts live
111// under a prefix (e.g. the embedded systemviews directory).
112func (m Manifest) Verify(fsys fs.FS) error {
113 for _, name := range ManifestFiles {
114 want := m.Files[name]
115 data, err := fs.ReadFile(fsys, name)
116 if err != nil {
117 return fmt.Errorf("read pinned asset %s: %w", name, err)
118 }
119 sum := sha256.Sum256(data)
120 if got := hex.EncodeToString(sum[:]); got != want.SHA256 {
121 return fmt.Errorf("pinned asset %s digest mismatch", name)
122 }
123 if int64(len(data)) != want.Size {
124 return fmt.Errorf("pinned asset %s size mismatch: got %d, want %d", name, len(data), want.Size)
125 }
126 }
127 return nil
128}