1package assets
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "io/fs"
8 "os"
9 "strings"
10 "testing"
11
12 "github.com/andybalholm/brotli"
13)
14
15func TestParseManifestRejectsBadInputs(t *testing.T) {
16 t.Parallel()
17
18 good := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
19 pin := func(digest string, size int) string {
20 return fmt.Sprintf(`"sha256":%q,"size":%d`, digest, size)
21 }
22 entry := func(css, wasm, js string) string {
23 return `{"version":1,"files":{"systemviews.css":{` + css + `},"systemviews.wasm":{` + wasm + `},"wasm_exec.js":{` + js + `}}}`
24 }
25 valid := entry(pin(good, 1), pin(good, 2), pin(good, 3))
26 if _, err := ParseManifest([]byte(valid)); err != nil {
27 t.Fatalf("ParseManifest(valid) error = %v", err)
28 }
29
30 cases := map[string]string{
31 "bad version": strings.Replace(valid, `"version":1`, `"version":2`, 1),
32 "missing pin": `{"version":1,"files":{"systemviews.css":{` + pin(good, 1) + `},"systemviews.wasm":{` + pin(good, 2) + `}}}`,
33 "unknown file": strings.TrimSuffix(valid, `}`) + `,"evil.js":{` + pin(good, 4) + `}}`,
34 "empty digest": entry(pin("", 1), pin(good, 2), pin(good, 3)),
35 "short digest": entry(pin("abc", 1), pin(good, 2), pin(good, 3)),
36 "long digest": entry(pin(good+"00", 1), pin(good, 2), pin(good, 3)),
37 "nonhex digest": entry(pin(strings.Repeat("g", 64), 1), pin(good, 2),
38 pin(good, 3)),
39 "upper digest": entry(pin(strings.Repeat("A", 64), 1), pin(good, 2),
40 pin(good, 3)),
41 // Pins breaking out of the shell's quoted attributes must fail
42 // closed instead of reaching HTML/JS interpolation.
43 "quote breakout": entry(pin(good, 1),
44 pin("');alert(1);//"+strings.Repeat("0", 52), 2), pin(good, 3)),
45 "markup pin": entry(pin(good, 1),
46 pin("<script>alert(1)</"+"script>"+strings.Repeat("0", 39), 2), pin(good, 3)),
47 "not JSON": `not json`,
48 "trailing data": valid + `{}`,
49 }
50 for name, input := range cases {
51 t.Run(name, func(t *testing.T) {
52 t.Parallel()
53 if _, err := ParseManifest([]byte(input)); err == nil {
54 t.Fatalf("ParseManifest(%s) succeeded, want error", name)
55 }
56 })
57 }
58}
59
60func TestManifestBytesAreCanonical(t *testing.T) {
61 t.Parallel()
62
63 good := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
64 m := Manifest{Version: ManifestVersion, Files: map[string]FileEntry{
65 "wasm_exec.js": {SHA256: good, Size: 3},
66 "systemviews.wasm": {SHA256: good, Size: 2},
67 "systemviews.css": {SHA256: good, Size: 1},
68 }}
69 first, err := m.Bytes()
70 if err != nil {
71 t.Fatalf("Bytes() error = %v", err)
72 }
73 second, err := m.Bytes()
74 if err != nil {
75 t.Fatalf("Bytes() error = %v", err)
76 }
77 if !bytes.Equal(first, second) {
78 t.Fatal("manifest encoding is not reproducible")
79 }
80 if !bytes.HasSuffix(first, []byte("\n")) || bytes.Contains(first, []byte("1970")) {
81 t.Fatalf("manifest must end with newline and carry no timestamps:\n%s", first)
82 }
83 // Sorted keys: css < wasm < wasm_exec.
84 css := bytes.Index(first, []byte("systemviews.css"))
85 wasm := bytes.Index(first, []byte("systemviews.wasm"))
86 js := bytes.Index(first, []byte("wasm_exec.js"))
87 if css >= wasm || wasm >= js {
88 t.Fatalf("manifest keys not sorted:\n%s", first)
89 }
90 if _, err := ParseManifest(first); err != nil {
91 t.Fatalf("canonical bytes do not round-trip: %v", err)
92 }
93}
94
95// TestAssetManifestMatchesEmbeddedArtifacts validates every manifest entry
96// against the actually embedded artifacts, including raw/compressed WASM
97// agreement. It skips when the generated bundle is absent, mirroring the
98// existing WASM presence test.
99func TestAssetManifestMatchesEmbeddedArtifacts(t *testing.T) {
100 data, err := WebUIAssets.ReadFile("systemviews/" + ManifestName)
101 if err != nil {
102 if os.Getenv("DUMBER_REQUIRE_SYSTEMVIEWS_WASM") == "" {
103 t.Skipf("generated asset manifest not present; run make build-systemviews: %v", err)
104 }
105 t.Fatalf("read embedded asset manifest: %v", err)
106 }
107 m, err := ParseManifest(data)
108 if err != nil {
109 t.Fatalf("ParseManifest(embedded) error = %v", err)
110 }
111 sub, err := fs.Sub(WebUIAssets, "systemviews")
112 if err != nil {
113 t.Fatalf("fs.Sub(embedded systemviews) error = %v", err)
114 }
115 if err := m.Verify(sub); err != nil {
116 t.Fatalf("embedded artifacts do not match manifest: %v", err)
117 }
118 // The manifest pins raw bytes, but serving decodes the compressed
119 // artifact as authoritative: the embedded .br must agree too, or a
120 // stale compressed bundle would pass verification yet fail serving.
121 if err := verifyEmbeddedCompressedAgreement(sub, m); err != nil {
122 t.Fatalf("embedded compressed WASM disagrees: %v", err)
123 }
124}
125
126// verifyEmbeddedCompressedAgreement checks the embedded .br decodes to
127// exactly the pinned raw bytes under a bounded read. Test-only: production
128// agreement checks live in cmd/systemviews-assets; this keeps the assets
129// package dependency-free.
130func verifyEmbeddedCompressedAgreement(fsys fs.FS, m Manifest) error {
131 want := m.Files["systemviews.wasm"]
132 compressed, err := fs.ReadFile(fsys, "systemviews.wasm.br")
133 if err != nil {
134 return fmt.Errorf("read embedded systemviews.wasm.br: %w", err)
135 }
136 raw, err := fs.ReadFile(fsys, "systemviews.wasm")
137 if err != nil {
138 return fmt.Errorf("read embedded systemviews.wasm: %w", err)
139 }
140 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), 64*1024*1024+1))
141 if err != nil {
142 return fmt.Errorf("decompress embedded systemviews.wasm.br: %w", err)
143 }
144 if !bytes.Equal(data, raw) || int64(len(raw)) != want.Size {
145 return fmt.Errorf("embedded systemviews.wasm.br does not match pinned raw bytes")
146 }
147 return nil
148}