1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
17use sha2::{Digest, Sha256};
18
19const NAME: &str = "search_index";
20
21#[derive(Debug, Clone, Copy)]
31pub struct SearchIndexGate;
32
33impl AuditGate for SearchIndexGate {
34 fn name(&self) -> &'static str {
35 NAME
36 }
37
38 fn explain(&self) -> &'static str {
39 "Asserts dist/search/embeddings.bin exists and that its SHA-256 \
40 matches the value recorded in dist/search/manifest.json. The \
41 producing epic is E1 (issue #545); the gate skips with an \
42 info note until E1 lands the embeddings artefact."
43 }
44
45 fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
46 let mut findings = Vec::new();
47
48 let bin_a = site.root.join("search").join("embeddings.bin");
49 let manifest_a = site.root.join("search").join("manifest.json");
50 let bin_b =
51 site.root.join("dist").join("search").join("embeddings.bin");
52 let manifest_b =
53 site.root.join("dist").join("search").join("manifest.json");
54
55 let (bin, manifest) = if bin_a.exists() {
56 (bin_a, manifest_a)
57 } else if bin_b.exists() {
58 (bin_b, manifest_b)
59 } else {
60 findings.push(
61 Finding::new(
62 NAME,
63 Severity::Info,
64 "search embeddings absent (depends on E1 — issue #545)",
65 )
66 .with_code("SEARCH-INPUT-MISSING"),
67 );
68 return findings;
69 };
70
71 let Ok(bin_bytes) = std::fs::read(&bin) else {
73 findings.push(
74 Finding::new(
75 NAME,
76 Severity::Error,
77 "could not read embeddings.bin",
78 )
79 .with_code("SEARCH-READ-ERROR")
80 .with_path(bin.to_string_lossy().into_owned()),
81 );
82 return findings;
83 };
84 if bin_bytes.is_empty() {
85 findings.push(
86 Finding::new(NAME, Severity::Error, "embeddings.bin is empty")
87 .with_code("SEARCH-EMPTY")
88 .with_path(bin.to_string_lossy().into_owned()),
89 );
90 }
91
92 let Ok(manifest_text) = std::fs::read_to_string(&manifest) else {
94 findings.push(
95 Finding::new(
96 NAME,
97 Severity::Error,
98 "manifest.json missing or unreadable",
99 )
100 .with_code("SEARCH-MANIFEST-MISSING")
101 .with_path(manifest.to_string_lossy().into_owned()),
102 );
103 return findings;
104 };
105 let Ok(manifest_json) =
106 serde_json::from_str::<serde_json::Value>(&manifest_text)
107 else {
108 findings.push(
109 Finding::new(
110 NAME,
111 Severity::Error,
112 "manifest.json is not valid JSON",
113 )
114 .with_code("SEARCH-MANIFEST-INVALID")
115 .with_path(manifest.to_string_lossy().into_owned()),
116 );
117 return findings;
118 };
119 let expected_hash = manifest_json
120 .get("embeddings_sha256")
121 .and_then(|v| v.as_str())
122 .map(str::to_string);
123 let Some(expected) = expected_hash else {
124 findings.push(
125 Finding::new(
126 NAME,
127 Severity::Warn,
128 "manifest.json missing `embeddings_sha256` field",
129 )
130 .with_code("SEARCH-HASH-MISSING")
131 .with_path(manifest.to_string_lossy().into_owned()),
132 );
133 return findings;
134 };
135
136 let mut hasher = Sha256::new();
137 hasher.update(&bin_bytes);
138 let actual = hex_encode(&hasher.finalize());
139 if !actual.eq_ignore_ascii_case(&expected) {
140 findings.push(
141 Finding::new(
142 NAME,
143 Severity::Error,
144 format!(
145 "embeddings.bin hash {actual} ≠ manifest {expected}"
146 ),
147 )
148 .with_code("SEARCH-HASH-MISMATCH")
149 .with_path(bin.to_string_lossy().into_owned()),
150 );
151 }
152
153 findings
154 }
155}
156
157fn hex_encode(bytes: &[u8]) -> String {
158 const HEX: &[u8; 16] = b"0123456789abcdef";
159 let mut s = String::with_capacity(bytes.len() * 2);
160 for &b in bytes {
161 s.push(HEX[(b >> 4) as usize] as char);
162 s.push(HEX[(b & 0xf) as usize] as char);
163 }
164 s
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use std::path::PathBuf;
171
172 fn empty_site() -> Site {
173 Site {
174 root: PathBuf::from("/nonexistent"),
175 html_files: Vec::new(),
176 }
177 }
178
179 #[test]
180 fn absent_inputs_emit_info_skip() {
181 let f = SearchIndexGate.run(&empty_site(), &AuditOptions::default());
182 assert_eq!(f.len(), 1);
183 assert_eq!(f[0].severity, Severity::Info);
184 assert_eq!(f[0].code.as_deref(), Some("SEARCH-INPUT-MISSING"));
185 }
186
187 #[test]
188 fn matching_hash_passes() {
189 let tmp = tempfile::tempdir().unwrap();
190 let search = tmp.path().join("search");
191 std::fs::create_dir_all(&search).unwrap();
192 let body: Vec<u8> = b"vectors".to_vec();
193 std::fs::write(search.join("embeddings.bin"), &body).unwrap();
194 let mut h = Sha256::new();
195 h.update(&body);
196 let hash = hex_encode(&h.finalize());
197 let manifest = format!(r#"{{"embeddings_sha256":"{hash}"}}"#);
198 std::fs::write(search.join("manifest.json"), manifest).unwrap();
199 let site = Site {
200 root: tmp.path().to_path_buf(),
201 html_files: Vec::new(),
202 };
203 let f = SearchIndexGate.run(&site, &AuditOptions::default());
204 std::mem::forget(tmp);
205 assert!(f.is_empty(), "got {f:?}");
206 }
207
208 #[test]
209 fn mismatched_hash_flagged() {
210 let tmp = tempfile::tempdir().unwrap();
211 let search = tmp.path().join("search");
212 std::fs::create_dir_all(&search).unwrap();
213 std::fs::write(search.join("embeddings.bin"), "x").unwrap();
214 std::fs::write(
215 search.join("manifest.json"),
216 r#"{"embeddings_sha256":"deadbeef"}"#,
217 )
218 .unwrap();
219 let site = Site {
220 root: tmp.path().to_path_buf(),
221 html_files: Vec::new(),
222 };
223 let f = SearchIndexGate.run(&site, &AuditOptions::default());
224 std::mem::forget(tmp);
225 assert!(f
226 .iter()
227 .any(|x| x.code.as_deref() == Some("SEARCH-HASH-MISMATCH")));
228 }
229
230 #[test]
231 fn dist_layout_is_discovered() {
232 let tmp = tempfile::tempdir().unwrap();
233 let search = tmp.path().join("dist").join("search");
234 std::fs::create_dir_all(&search).unwrap();
235 let body: Vec<u8> = b"vectors".to_vec();
236 std::fs::write(search.join("embeddings.bin"), &body).unwrap();
237 let mut h = Sha256::new();
238 h.update(&body);
239 let hash = hex_encode(&h.finalize());
240 std::fs::write(
241 search.join("manifest.json"),
242 format!(r#"{{"embeddings_sha256":"{hash}"}}"#),
243 )
244 .unwrap();
245 let site = Site {
246 root: tmp.path().to_path_buf(),
247 html_files: Vec::new(),
248 };
249 let f = SearchIndexGate.run(&site, &AuditOptions::default());
250 std::mem::forget(tmp);
251 assert!(f.is_empty(), "got {f:?}");
252 }
253
254 #[test]
255 fn empty_bin_flagged() {
256 let tmp = tempfile::tempdir().unwrap();
257 let search = tmp.path().join("search");
258 std::fs::create_dir_all(&search).unwrap();
259 std::fs::write(search.join("embeddings.bin"), b"").unwrap();
260 let mut h = Sha256::new();
261 h.update(b"");
262 let hash = hex_encode(&h.finalize());
263 std::fs::write(
264 search.join("manifest.json"),
265 format!(r#"{{"embeddings_sha256":"{hash}"}}"#),
266 )
267 .unwrap();
268 let site = Site {
269 root: tmp.path().to_path_buf(),
270 html_files: Vec::new(),
271 };
272 let f = SearchIndexGate.run(&site, &AuditOptions::default());
273 std::mem::forget(tmp);
274 assert!(f.iter().any(|x| x.code.as_deref() == Some("SEARCH-EMPTY")));
275 }
276
277 #[test]
278 fn missing_manifest_flags_error() {
279 let tmp = tempfile::tempdir().unwrap();
280 let search = tmp.path().join("search");
281 std::fs::create_dir_all(&search).unwrap();
282 std::fs::write(search.join("embeddings.bin"), b"data").unwrap();
283 let site = Site {
284 root: tmp.path().to_path_buf(),
285 html_files: Vec::new(),
286 };
287 let f = SearchIndexGate.run(&site, &AuditOptions::default());
288 std::mem::forget(tmp);
289 assert!(f
290 .iter()
291 .any(|x| x.code.as_deref() == Some("SEARCH-MANIFEST-MISSING")));
292 }
293
294 #[test]
295 fn invalid_manifest_json_flags_error() {
296 let tmp = tempfile::tempdir().unwrap();
297 let search = tmp.path().join("search");
298 std::fs::create_dir_all(&search).unwrap();
299 std::fs::write(search.join("embeddings.bin"), b"data").unwrap();
300 std::fs::write(search.join("manifest.json"), "{ this is not json")
301 .unwrap();
302 let site = Site {
303 root: tmp.path().to_path_buf(),
304 html_files: Vec::new(),
305 };
306 let f = SearchIndexGate.run(&site, &AuditOptions::default());
307 std::mem::forget(tmp);
308 assert!(f
309 .iter()
310 .any(|x| x.code.as_deref() == Some("SEARCH-MANIFEST-INVALID")));
311 }
312
313 #[test]
314 fn manifest_without_hash_field_warns() {
315 let tmp = tempfile::tempdir().unwrap();
316 let search = tmp.path().join("search");
317 std::fs::create_dir_all(&search).unwrap();
318 std::fs::write(search.join("embeddings.bin"), b"data").unwrap();
319 std::fs::write(search.join("manifest.json"), r#"{"version":1}"#)
320 .unwrap();
321 let site = Site {
322 root: tmp.path().to_path_buf(),
323 html_files: Vec::new(),
324 };
325 let f = SearchIndexGate.run(&site, &AuditOptions::default());
326 std::mem::forget(tmp);
327 let hm = f
328 .iter()
329 .find(|x| x.code.as_deref() == Some("SEARCH-HASH-MISSING"))
330 .expect("hash-missing finding");
331 assert_eq!(hm.severity, Severity::Warn);
332 }
333
334 #[test]
335 fn case_insensitive_hash_comparison() {
336 let tmp = tempfile::tempdir().unwrap();
337 let search = tmp.path().join("search");
338 std::fs::create_dir_all(&search).unwrap();
339 let body: Vec<u8> = b"vectors".to_vec();
340 std::fs::write(search.join("embeddings.bin"), &body).unwrap();
341 let mut h = Sha256::new();
342 h.update(&body);
343 let hash = hex_encode(&h.finalize()).to_uppercase();
344 std::fs::write(
345 search.join("manifest.json"),
346 format!(r#"{{"embeddings_sha256":"{hash}"}}"#),
347 )
348 .unwrap();
349 let site = Site {
350 root: tmp.path().to_path_buf(),
351 html_files: Vec::new(),
352 };
353 let f = SearchIndexGate.run(&site, &AuditOptions::default());
354 std::mem::forget(tmp);
355 assert!(f.is_empty(), "uppercase hash should match: {f:?}");
356 }
357
358 #[test]
359 fn unreadable_embeddings_bin_flags_read_error() {
360 let tmp = tempfile::tempdir().unwrap();
363 let search = tmp.path().join("search");
364 std::fs::create_dir_all(search.join("embeddings.bin")).unwrap();
365 let site = Site {
366 root: tmp.path().to_path_buf(),
367 html_files: Vec::new(),
368 };
369 let f = SearchIndexGate.run(&site, &AuditOptions::default());
370 std::mem::forget(tmp);
371 assert_eq!(f.len(), 1);
372 assert_eq!(f[0].code.as_deref(), Some("SEARCH-READ-ERROR"));
373 assert_eq!(f[0].severity, Severity::Error);
374 }
375
376 #[test]
377 fn hex_encode_round_trip_is_lowercase_hex() {
378 let s = hex_encode(&[0x00, 0x0f, 0xab, 0xff]);
379 assert_eq!(s, "000fabff");
380 }
381
382 #[test]
383 fn metadata_methods_exposed() {
384 let g = SearchIndexGate;
385 assert_eq!(g.name(), "search_index");
386 assert!(g.explain().contains("embeddings"));
387 let _copy: SearchIndexGate = g;
388 let _clone = g;
389 assert!(format!("{g:?}").contains("SearchIndexGate"));
390 }
391}