Skip to main content

ssg/audit/gates/
search_index.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Localized semantic vector search index integrity (depends on E1 —
5//! issue #545).
6//!
7//! Asserts `dist/search/embeddings.bin` exists, has the expected
8//! dimensions, and that its content hash matches the recorded hash in
9//! `dist/search/manifest.json`. When the producing epic (E1) hasn't
10//! merged yet, the gate emits a single info-level finding and skips —
11//! exactly as the issue body specifies.
12//!
13//! `dist/` is checked at the site root first, then under `<root>/dist/`
14//! since v0.0.44 output adapters can land either layout.
15
16use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
17use sha2::{Digest, Sha256};
18
19const NAME: &str = "search_index";
20
21/// Semantic vector search index integrity gate.
22///
23/// # Examples
24///
25/// ```
26/// use ssg::audit::AuditGate;
27/// use ssg::audit::gates::search_index::SearchIndexGate;
28/// assert_eq!(SearchIndexGate.name(), "search_index");
29/// ```
30#[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        // Verify bin size > 0.
72        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        // Manifest hash check.
93        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        // A directory named embeddings.bin passes the exists() probe
361        // but fails fs::read, driving the SEARCH-READ-ERROR arm.
362        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}