package assets // Content manifest for served systemview assets (Plan 05 P3.1). // // The manifest pins the SHA-256 and size of every uncompressed immutable // asset the CEF scheme handler may serve with long-lived cache headers. // It is generated by cmd/systemviews-assets after the WASM build as an // untracked build artifact; verification compares it against the actually // embedded artifacts, never against Git state. Encoding is canonical JSON with // sorted keys and no timestamps or paths, so identical inputs always // produce identical bytes. import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "io/fs" ) // ManifestVersion is the only accepted manifest schema version. const ManifestVersion = 1 // ManifestName is the manifest file name inside assets/systemviews. const ManifestName = "asset-manifest.json" // ManifestFiles lists every served uncompressed asset pinned by the // manifest, in canonical order. var ManifestFiles = []string{"systemviews.css", "systemviews.wasm", "wasm_exec.js"} // FileEntry pins one asset's content identity. type FileEntry struct { SHA256 string `json:"sha256"` Size int64 `json:"size"` } // Manifest pins the content identity of servable immutable assets. type Manifest struct { Version int `json:"version"` Files map[string]FileEntry `json:"files"` } // ParseManifest decodes and validates manifest bytes. Unknown files, // missing pins, malformed digests, and version mismatches are all hard // errors: a trust root must fail closed, never serve unverified immutable // content. Every pin must be exactly 64 lowercase hex characters: pins // are interpolated into shell HTML/JS, so anything else is rejected // before any shell use rather than escaped after it. func ParseManifest(data []byte) (Manifest, error) { var m Manifest dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(&m); err != nil { return Manifest{}, fmt.Errorf("decode asset manifest: %w", err) } var extra json.RawMessage if err := dec.Decode(&extra); err != io.EOF { return Manifest{}, fmt.Errorf("asset manifest has trailing data") } if m.Version != ManifestVersion { return Manifest{}, fmt.Errorf("unsupported asset manifest version %d", m.Version) } if len(m.Files) != len(ManifestFiles) { return Manifest{}, fmt.Errorf("asset manifest pins %d files, want %d", len(m.Files), len(ManifestFiles)) } for _, name := range ManifestFiles { entry, ok := m.Files[name] if !ok { return Manifest{}, fmt.Errorf("asset manifest missing pin for %s", name) } if !validDigest(entry.SHA256) { return Manifest{}, fmt.Errorf("asset manifest has invalid pin for %s", name) } if entry.Size < 0 { return Manifest{}, fmt.Errorf("asset manifest has invalid size for %s", name) } } return m, nil } // validDigest reports whether s is a SHA-256 hex digest: exactly 64 // lowercase hexadecimal characters, nothing else. func validDigest(s string) bool { if len(s) != 64 { return false } for i := 0; i < len(s); i++ { digit := s[i] >= '0' && s[i] <= '9' lowerHex := s[i] >= 'a' && s[i] <= 'f' if !digit && !lowerHex { return false } } return true } // Bytes renders the canonical manifest encoding: indented JSON with sorted // keys, no timestamps or paths, terminated by a single newline. func (m Manifest) Bytes() ([]byte, error) { data, err := json.MarshalIndent(m, "", " ") if err != nil { return nil, fmt.Errorf("encode asset manifest: %w", err) } return append(data, '\n'), nil } // Verify checks every pinned file against its actual bytes in fsys. Names // resolve at the filesystem root; callers pass fs.Sub when artifacts live // under a prefix (e.g. the embedded systemviews directory). func (m Manifest) Verify(fsys fs.FS) error { for _, name := range ManifestFiles { want := m.Files[name] data, err := fs.ReadFile(fsys, name) if err != nil { return fmt.Errorf("read pinned asset %s: %w", name, err) } sum := sha256.Sum256(data) if got := hex.EncodeToString(sum[:]); got != want.SHA256 { return fmt.Errorf("pinned asset %s digest mismatch", name) } if int64(len(data)) != want.Size { return fmt.Errorf("pinned asset %s size mismatch: got %d, want %d", name, len(data), want.Size) } } return nil }