Skip to main content

ssg/plugins/
audit.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Automated 10-Pillar Quality Gate and Master Compliance Audit Plugin.
5//!
6//! Ports the portfolio master quality gate audit (`audit.sh`) natively into
7//! the SSG compilation pipeline. Evaluates 10 pillars of web quality:
8//!
9//! 1. Output & Essential Files (`robots.txt`, `sitemap.xml`, `manifest.json`, `rss.xml`, `search-index.json`)
10//! 2. Meta Leaks & Content Hygiene (no unescaped tags in `<head>`, no escaped entity leaks in `<body>`)
11//! 3. CSP & Security Integrity (valid Content-Security-Policy with `'unsafe-inline'`)
12//! 4. SRI Hashes Sync (verifies SHA-384 cryptographic integrity against compiled assets)
13//! 5. Hero Banner Subpage Isolation (prevents full-screen hero leakage onto subpages)
14//! 6. Apple HIG Navbar & Footer Hygiene (valid navbar links, "Made with SSG" in footer, not in top navbar)
15//! 7. Theme, Search & Lightbox Engines (search index excludes utility pages; client runtime presence)
16//! 8. Forms & Link Integrity (functional form actions on contact pages)
17//! 9. `CloudCDN` Asset Resolution (valid CDN paths)
18//! 10. Accessibility & Semantic Hierarchy (`lang` attribute, `<h1>` heading, no empty headings)
19//!
20//! Emits `quality-gate-report.json` in the build output directory.
21
22use crate::error::SsgError;
23use crate::plugin::{Plugin, PluginContext};
24use base64::Engine;
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha384};
27use std::collections::{BTreeMap, HashMap};
28use std::fs;
29use std::path::{Path, PathBuf};
30
31/// Pillar result status and issue list.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct PillarResult {
34    /// Whether this pillar passed without blocking issues.
35    pub pass: bool,
36    /// Detailed list of detected issues for this pillar.
37    pub issues: Vec<String>,
38}
39
40impl PillarResult {
41    /// Creates a passing pillar result.
42    #[must_use]
43    pub const fn new_pass() -> Self {
44        Self {
45            pass: true,
46            issues: Vec::new(),
47        }
48    }
49
50    /// Records an issue on this pillar, marking it failed.
51    pub fn add_issue(&mut self, issue: impl Into<String>) {
52        self.pass = false;
53        self.issues.push(issue.into());
54    }
55}
56
57/// Comprehensive Quality Gate Audit Report.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct QualityGateReport {
60    /// Total number of HTML pages scanned.
61    pub pages_scanned: usize,
62    /// Number of passing pillars out of 10.
63    pub passed_pillars: usize,
64    /// Total number of evaluated pillars (always 10).
65    pub total_pillars: usize,
66    /// Percentage pass rate (0.0 - 100.0).
67    pub pass_rate: f64,
68    /// Total number of issues found across all pillars.
69    pub total_issues: usize,
70    /// Map of pillar name to result.
71    pub pillars: BTreeMap<String, PillarResult>,
72}
73
74/// Plugin that runs the 10-pillar master quality gate audit on compiled sites.
75#[derive(Debug, Clone, Copy, Default)]
76pub struct AuditPlugin;
77
78impl AuditPlugin {
79    /// Computes the SHA-384 Subresource Integrity string for raw bytes.
80    #[must_use]
81    pub fn compute_sri(bytes: &[u8]) -> String {
82        let mut hasher = Sha384::new();
83        hasher.update(bytes);
84        let digest = hasher.finalize();
85        format!(
86            "sha384-{}",
87            base64::engine::general_purpose::STANDARD.encode(digest)
88        )
89    }
90
91    /// Yields the text of every opening tag in `html`, `<` to `>`.
92    ///
93    /// Quote-aware: a `>` inside an attribute value does not end the
94    /// tag. That matters because SRI checking has to read two
95    /// attributes of the *same* element, and the only way to be sure
96    /// they belong together is to bound the element first. Splitting on
97    /// lines does not bound anything once the HTML is minified.
98    fn opening_tags(html: &str) -> Vec<&str> {
99        let bytes = html.as_bytes();
100        let mut tags = Vec::new();
101        let mut i = 0usize;
102        while i < bytes.len() {
103            if bytes[i] != b'<' {
104                i += 1;
105                continue;
106            }
107            let start = i;
108            i += 1;
109            let mut quote: Option<u8> = None;
110            while i < bytes.len() {
111                let c = bytes[i];
112                match quote {
113                    Some(q) if c == q => quote = None,
114                    Some(_) => {}
115                    None if c == b'"' || c == b'\'' => quote = Some(c),
116                    None if c == b'>' => break,
117                    None => {}
118                }
119                i += 1;
120            }
121            if i < bytes.len() {
122                // `start..=i` spans `<` through `>`; slice on char
123                // boundaries so non-ASCII attribute values are safe.
124                if let Some(tag) = html.get(start..=i) {
125                    tags.push(tag);
126                }
127                i += 1;
128            }
129        }
130        tags
131    }
132
133    /// Reads a double- or single-quoted attribute value out of one tag.
134    ///
135    /// Matches on an attribute *boundary* — the name must be preceded by
136    /// whitespace — so `href` does not match inside `data-href`, and
137    /// `src` does not match inside `data-src` or `srcset`.
138    fn tag_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
139        let mut from = 0usize;
140        while let Some(pos) = tag[from..].find(name) {
141            let at = from + pos;
142            let after = at + name.len();
143            let preceded_by_space = tag[..at]
144                .chars()
145                .next_back()
146                .is_some_and(char::is_whitespace);
147            let rest = tag.get(after..).unwrap_or("");
148            if preceded_by_space && rest.starts_with('=') {
149                let value = rest.get(1..).unwrap_or("");
150                let q = value.as_bytes().first().copied();
151                if q == Some(b'"') || q == Some(b'\'') {
152                    let q = q.unwrap_or(b'"') as char;
153                    let body = value.get(1..).unwrap_or("");
154                    if let Some(end) = body.find(q) {
155                        return body.get(..end);
156                    }
157                }
158            }
159            from = after.max(at + 1);
160        }
161        None
162    }
163
164    /// Runs the 10-pillar audit against a compiled site directory.
165    #[must_use]
166    pub fn audit_directory(site_dir: &Path) -> QualityGateReport {
167        let mut pillars = BTreeMap::new();
168        let pillar_names = [
169            "1. Output & Essential Files",
170            "2. Meta Leaks & Content Hygiene",
171            "3. CSP & Security Integrity",
172            "4. SRI Hashes Sync",
173            "5. Hero Banner Subpage Isolation",
174            "6. Apple HIG Navbar & Footer Hygiene",
175            "7. Theme, Search & Lightbox Engines",
176            "8. Forms & Link Integrity",
177            "9. CloudCDN Asset Resolution",
178            "10. Accessibility & Semantic Hierarchy",
179        ];
180
181        for name in pillar_names {
182            let _ = pillars.insert(name.to_string(), PillarResult::new_pass());
183        }
184
185        if !site_dir.exists() {
186            for p in pillars.values_mut() {
187                p.add_issue(format!(
188                    "Site directory not found: {}",
189                    site_dir.display()
190                ));
191            }
192            return QualityGateReport {
193                pages_scanned: 0,
194                passed_pillars: 0,
195                total_pillars: 10,
196                pass_rate: 0.0,
197                total_issues: 10,
198                pillars,
199            };
200        }
201
202        // 1. Output & Essential Files
203        let req_files = [
204            "robots.txt",
205            "sitemap.xml",
206            "manifest.json",
207            "rss.xml",
208            "search-index.json",
209        ];
210        for rf in req_files {
211            if !site_dir.join(rf).is_file() {
212                if let Some(p) = pillars.get_mut("1. Output & Essential Files")
213                {
214                    p.add_issue(format!("Missing essential file: {rf}"));
215                }
216            }
217        }
218
219        // 2. Search Index Hygiene
220        let sindex_path = site_dir.join("search-index.json");
221        if sindex_path.is_file() {
222            if let Ok(content) = fs::read_to_string(&sindex_path) {
223                if let Ok(val) =
224                    serde_json::from_str::<serde_json::Value>(&content)
225                {
226                    let entries = if let Some(arr) = val.as_array() {
227                        Some(arr)
228                    } else {
229                        val.get("entries").and_then(serde_json::Value::as_array)
230                    };
231
232                    if let Some(entries) = entries {
233                        for entry in entries {
234                            if let Some(url) = entry
235                                .get("url")
236                                .and_then(serde_json::Value::as_str)
237                            {
238                                let u_lower = url.to_lowercase();
239                                if u_lower.contains("/404")
240                                    || u_lower.contains("/offline")
241                                    || u_lower.contains("/thanks")
242                                    || u_lower.contains("404.html")
243                                    || u_lower.contains("offline.html")
244                                    || u_lower.contains("thanks.html")
245                                {
246                                    if let Some(p) = pillars.get_mut(
247                                        "7. Theme, Search & Lightbox Engines",
248                                    ) {
249                                        p.add_issue(format!(
250                                            "search-index.json contains utility page: {url}"
251                                        ));
252                                    }
253                                }
254                            }
255                        }
256                    }
257                }
258            }
259        }
260
261        // Collect all compiled asset hashes for SRI verification
262        let mut asset_hashes: HashMap<String, String> = HashMap::new();
263        let mut html_files: Vec<PathBuf> = Vec::new();
264
265        let mut stack = vec![site_dir.to_path_buf()];
266        while let Some(dir) = stack.pop() {
267            if let Ok(entries) = fs::read_dir(&dir) {
268                for entry in entries.flatten() {
269                    let path = entry.path();
270                    if path.is_dir() {
271                        let name = path
272                            .file_name()
273                            .unwrap_or_default()
274                            .to_string_lossy();
275                        if !name.starts_with('.')
276                            && name != "_layouts"
277                            && name != "templates"
278                            && name != "node_modules"
279                        {
280                            stack.push(path);
281                        }
282                    } else if path.is_file() {
283                        let ext = path
284                            .extension()
285                            .unwrap_or_default()
286                            .to_string_lossy();
287                        if ext == "html" {
288                            html_files.push(path);
289                        } else if ext == "js" || ext == "css" {
290                            if let Ok(bytes) = fs::read(&path) {
291                                let hash = Self::compute_sri(&bytes);
292                                let fname = path
293                                    .file_name()
294                                    .unwrap_or_default()
295                                    .to_string_lossy()
296                                    .to_string();
297                                let rel = path
298                                    .strip_prefix(site_dir)
299                                    .unwrap_or(&path)
300                                    .to_string_lossy()
301                                    .replace('\\', "/");
302                                let _ = asset_hashes
303                                    .insert(format!("/{rel}"), hash.clone());
304                                let _ = asset_hashes.insert(fname, hash);
305                            }
306                        }
307                    }
308                }
309            }
310        }
311
312        // `fs::read_dir` yields entries in whatever order the filesystem
313        // gives, which differs between ext4 and APFS. Issues are pushed in
314        // this order, so an unsorted walk made `quality-gate-report.json`
315        // differ between Linux and macOS for identical input — the
316        // determinism gate compares the two trees and failed on exactly this
317        // one file. Sorting makes the report a function of the site, not of
318        // the machine that built it.
319        html_files.sort();
320
321        if html_files.is_empty() {
322            if let Some(p) = pillars.get_mut("1. Output & Essential Files") {
323                p.add_issue("No compiled HTML files found in output directory");
324            }
325        }
326
327        // Deep HTML Scan
328        for path in &html_files {
329            let rel = path
330                .strip_prefix(site_dir)
331                .unwrap_or(path)
332                .to_string_lossy()
333                .replace('\\', "/");
334
335            let Ok(html) = fs::read_to_string(path) else {
336                continue;
337            };
338
339            // A. Head hygiene
340            if let Some(start) = html.find("<head") {
341                if let Some(end) = html[start..].find("</head>") {
342                    let head_txt = &html[start..start + end];
343                    if head_txt.contains("<div")
344                        || head_txt.contains("<p")
345                        || head_txt.contains("<span")
346                    {
347                        if let Some(p) =
348                            pillars.get_mut("2. Meta Leaks & Content Hygiene")
349                        {
350                            p.add_issue(format!(
351                                "{rel}: Unescaped HTML container inside <head>"
352                            ));
353                        }
354                    }
355                    if head_txt.contains("&lt;div")
356                        || head_txt.contains("&lt;h")
357                    {
358                        if let Some(p) =
359                            pillars.get_mut("2. Meta Leaks & Content Hygiene")
360                        {
361                            p.add_issue(format!(
362                                "{rel}: Leaked escaped entity in <head>"
363                            ));
364                        }
365                    }
366                }
367            }
368
369            // Body hygiene
370            if html.contains(".class=\"") || html.contains(".class=\\\"") {
371                if let Some(p) =
372                    pillars.get_mut("2. Meta Leaks & Content Hygiene")
373                {
374                    p.add_issue(format!(
375                        "{rel}: Leaked .class= template artifact"
376                    ));
377                }
378            }
379            if html.contains("&lt;div")
380                || html.contains("&lt;h2")
381                || html.contains("&lt;p&gt;")
382                || html.contains("&lt;img")
383            {
384                if let Some(p) =
385                    pillars.get_mut("2. Meta Leaks & Content Hygiene")
386                {
387                    p.add_issue(format!(
388                        "{rel}: Escaped HTML entities leaked in body content"
389                    ));
390                }
391            }
392
393            // B. CSP Integrity
394            if !html.to_lowercase().contains("content-security-policy") {
395                if let Some(p) = pillars.get_mut("3. CSP & Security Integrity")
396                {
397                    p.add_issue(format!(
398                        "{rel}: Missing Content-Security-Policy meta tag"
399                    ));
400                }
401            } else if !html.contains("script-src")
402                && !html.contains("default-src")
403            {
404                if let Some(p) = pillars.get_mut("3. CSP & Security Integrity")
405                {
406                    p.add_issue(format!(
407                        "{rel}: CSP missing essential directives"
408                    ));
409                }
410            }
411
412            // C. SRI Verification
413            //
414            // Per element, not per line. The previous pass walked
415            // `html.lines()` and took the *first* `src="` and the
416            // *first* `integrity="` on any line that mentioned SRI —
417            // which are only the same element on pretty-printed HTML.
418            // Minified output puts a whole `<head>` on one line, and
419            // then the check compared one tag's `src` against another
420            // tag's `integrity`.
421            //
422            // That was both a false positive and a false negative. Six
423            // of the nine published themes reported
424            // `SRI mismatch for /theme-init.<hash>.js` whose integrity
425            // was in fact correct, and every SRI after the first on a
426            // minified line was never verified at all — a genuinely
427            // wrong hash there would have passed silently.
428            for tag in Self::opening_tags(&html) {
429                let Some(int_val) = Self::tag_attr(tag, "integrity") else {
430                    continue;
431                };
432                // `src` for <script>, `href` for <link rel=stylesheet>.
433                let Some(url) = Self::tag_attr(tag, "src")
434                    .or_else(|| Self::tag_attr(tag, "href"))
435                else {
436                    continue;
437                };
438                if url.starts_with("http://") || url.starts_with("https://") {
439                    continue;
440                }
441                let expected = asset_hashes
442                    .get(url)
443                    .or_else(|| asset_hashes.get(url.trim_start_matches('/')));
444                if let Some(exp) = expected {
445                    if exp != int_val {
446                        if let Some(p) = pillars.get_mut("4. SRI Hashes Sync") {
447                            p.add_issue(format!(
448                                "{rel}: SRI mismatch for {url}"
449                            ));
450                        }
451                    }
452                }
453            }
454
455            // D. Hero banner subpage isolation
456            if rel != "index.html"
457                && html.contains("class=\"hero-banner-container\"")
458            {
459                if let Some(p) =
460                    pillars.get_mut("5. Hero Banner Subpage Isolation")
461                {
462                    p.add_issue(format!(
463                        "{rel}: Subpage has full-screen hero banner"
464                    ));
465                }
466            }
467
468            // E. Navbar & Footer Hygiene
469            if !is_taxonomy_page(&rel) {
470                if !has_responsive_navbar(&html) {
471                    if let Some(p) =
472                        pillars.get_mut("6. Apple HIG Navbar & Footer Hygiene")
473                    {
474                        p.add_issue(format!(
475                            "{rel}: Missing responsive navbar"
476                        ));
477                    }
478                }
479
480                // Check footer contains Made with SSG
481                if html.contains("<footer") && !html.contains("made-with-ssg") {
482                    if let Some(p) =
483                        pillars.get_mut("6. Apple HIG Navbar & Footer Hygiene")
484                    {
485                        p.add_issue(format!(
486                            "{rel}: Footer missing 'Made with SSG' link"
487                        ));
488                    }
489                }
490            }
491
492            // F. Forms integrity on contact page
493            //
494            // Taxonomy pages are excluded: a site tagging articles "contact"
495            // emits `tags/contact/index.html`, which the substring match read
496            // as the contact page and then failed for carrying no form. The
497            // tag index is generated and never has one.
498            if rel.to_lowercase().contains("contact")
499                && !is_taxonomy_page(&rel)
500                && !html.contains("http-equiv=\"refresh\"")
501                && (!html.contains("<form") || !html.contains("action="))
502            {
503                {
504                    if let Some(p) =
505                        pillars.get_mut("8. Forms & Link Integrity")
506                    {
507                        p.add_issue(format!("{rel}: Contact page missing functional form action"));
508                    }
509                }
510            }
511
512            // G. Accessibility & Semantic Hierarchy
513            if !html.contains("lang=") {
514                if let Some(p) =
515                    pillars.get_mut("10. Accessibility & Semantic Hierarchy")
516                {
517                    p.add_issue(format!("{rel}: Missing html lang attribute"));
518                }
519            }
520            if !html.contains("<h1") {
521                if let Some(p) =
522                    pillars.get_mut("10. Accessibility & Semantic Hierarchy")
523                {
524                    p.add_issue(format!(
525                        "{rel}: Missing first-level <h1> heading"
526                    ));
527                }
528            }
529        }
530
531        let total_issues: usize =
532            pillars.values().map(|p| p.issues.len()).sum();
533        let passed_pillars: usize = pillars.values().filter(|p| p.pass).count();
534        let pass_rate = if pillars.is_empty() {
535            0.0
536        } else {
537            (passed_pillars as f64 / pillars.len() as f64) * 100.0
538        };
539
540        QualityGateReport {
541            pages_scanned: html_files.len(),
542            passed_pillars,
543            total_pillars: 10,
544            pass_rate,
545            total_issues,
546            pillars,
547        }
548    }
549}
550
551/// Whether a path is a generated taxonomy page rather than an authored one.
552///
553/// Tag indexes are emitted by the taxonomy plugin, so the navbar and footer
554/// hygiene rules do not apply to them. Matching only a leading `tags/` missed
555/// every translated copy: with `url_prefix = "sub_path"` the French set is
556/// emitted under `fr/tags/`, so a bilingual site failed the pillar on exactly
557/// the pages an English-only one was excused. Allow one leading locale segment
558/// before the taxonomy root.
559fn is_taxonomy_page(rel: &str) -> bool {
560    if rel.starts_with("tags/") {
561        return true;
562    }
563    // The locale segment is only considered second: `tags` is itself short and
564    // alphabetic, so stripping a leading segment first would consume the very
565    // directory being looked for and report `tags/index.html` as authored.
566    match rel.split_once('/') {
567        // A locale segment is short and alphabetic: `fr/`, `en/`, `pt-br/`.
568        Some((first, rest))
569            if (2..=5).contains(&first.len())
570                && first
571                    .chars()
572                    .all(|c| c.is_ascii_alphabetic() || c == '-') =>
573        {
574            rest.starts_with("tags/")
575        }
576        _ => false,
577    }
578}
579
580/// Whether a page exposes a responsive navigation bar carrying a brand link.
581///
582/// The pillar this backs is about structure, not about any one CSS framework.
583/// Matching on the literal `navbar`/`navbar-brand` class pair only recognised
584/// Bootstrap-shaped markup, so themes that ship a semantic `<nav>` landmark
585/// with a `class="brand"` home link — which is the more accessible form, and
586/// what every first-party theme uses — were reported as having no navbar at
587/// all. Accept either spelling: the legacy class pair, or a `<nav>` landmark
588/// combined with a recognisable brand link.
589fn has_responsive_navbar(html: &str) -> bool {
590    let bootstrap = html.contains("navbar") && html.contains("navbar-brand");
591
592    let has_nav_landmark = html.contains("<nav")
593        || html.contains("role=\"navigation\"")
594        || html.contains("role='navigation'");
595
596    // A brand link is the site identity anchored in the header: a `brand`
597    // class, or an explicit home relation.
598    let has_brand = html.contains("class=\"brand\"")
599        || html.contains("class='brand'")
600        || html.contains("navbar-brand")
601        || html.contains("class=\"site-title\"")
602        || html.contains("rel=\"home\"")
603        || html.contains("rel='home'");
604
605    bootstrap || (has_nav_landmark && has_brand)
606}
607
608impl Plugin for AuditPlugin {
609    fn name(&self) -> &'static str {
610        "audit"
611    }
612
613    fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
614        Ok(())
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use tempfile::TempDir;
622
623    #[test]
624    fn test_audit_plugin_name() {
625        let plugin = AuditPlugin;
626        assert_eq!(plugin.name(), "audit");
627    }
628
629    /// Builds a site directory with one minified page whose `<head>`
630    /// holds several SRI-bearing elements on a single line — the shape
631    /// the real generator emits, and the shape the old line-based check
632    /// mishandled.
633    fn minified_site(script_integrity: &str) -> TempDir {
634        let dir = TempDir::new().expect("tempdir");
635        let root = dir.path();
636        let css = b"body{margin:0}";
637        let js = b"document.documentElement.classList.remove('no-js');";
638        fs::write(root.join("style.css"), css).expect("css");
639        fs::write(root.join("theme-init.js"), js).expect("js");
640        let css_sri = AuditPlugin::compute_sri(css);
641        // One line, stylesheet first, script second: the stylesheet's
642        // `integrity` precedes the script's `src`.
643        let html = format!(
644            "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
645             <title>t</title><meta http-equiv=\"Content-Security-Policy\" \
646             content=\"default-src 'self'; script-src 'self'\">\
647             <link rel=\"stylesheet\" href=\"/style.css\" \
648             integrity=\"{css_sri}\" crossorigin=\"anonymous\">\
649             <script src=\"/theme-init.js\" integrity=\"{script_integrity}\" \
650             crossorigin=\"anonymous\"></script></head><body><h1>t</h1>\
651             </body></html>"
652        );
653        fs::write(root.join("index.html"), html).expect("html");
654        dir
655    }
656
657    fn sri_issues(report: &QualityGateReport) -> Vec<String> {
658        report
659            .pillars
660            .get("4. SRI Hashes Sync")
661            .map(|p| p.issues.clone())
662            .unwrap_or_default()
663    }
664
665    /// The false positive. Every hash on the page is correct, but the
666    /// stylesheet's `integrity` appears before the script's `src` on the
667    /// same line. The old check paired them and reported a mismatch
668    /// against a script whose integrity was right — which is what made
669    /// six of the nine published themes score 9/10.
670    #[test]
671    fn correct_hashes_on_one_minified_line_raise_no_sri_issue() {
672        let js = b"document.documentElement.classList.remove('no-js');";
673        let dir = minified_site(&AuditPlugin::compute_sri(js));
674        let report = AuditPlugin::audit_directory(dir.path());
675        assert!(
676            sri_issues(&report).is_empty(),
677            "correct hashes must not be reported: {:?}",
678            sri_issues(&report)
679        );
680    }
681
682    /// The false negative, which matters more, and which needs a
683    /// fixture the old check would have got *right* on its first pair.
684    ///
685    /// Two scripts on one line. The first carries a correct hash, so the
686    /// old line-based pass matched `src`+`integrity` on it and stopped —
687    /// it only ever read the first of each per line. The second script's
688    /// hash is wrong and was never looked at. A wrong SRI hash makes the
689    /// browser refuse to run the script, so silence here is the
690    /// expensive failure.
691    #[test]
692    fn a_wrong_hash_after_a_correct_one_on_the_same_line_is_caught() {
693        let dir = TempDir::new().expect("tempdir");
694        let root = dir.path();
695        let first = b"console.log('first');";
696        let second = b"console.log('second');";
697        fs::write(root.join("first.js"), first).expect("first");
698        fs::write(root.join("second.js"), second).expect("second");
699        let good = AuditPlugin::compute_sri(first);
700        let html = format!(
701            "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
702             <title>t</title><meta http-equiv=\"Content-Security-Policy\" \
703             content=\"default-src 'self'; script-src 'self'\">\
704             <script src=\"/first.js\" integrity=\"{good}\" \
705             crossorigin=\"anonymous\"></script>\
706             <script src=\"/second.js\" \
707             integrity=\"sha384-notTheHashOfSecondJsAtAllNotEvenClose\" \
708             crossorigin=\"anonymous\"></script></head><body><h1>t</h1>\
709             </body></html>"
710        );
711        fs::write(root.join("index.html"), html).expect("html");
712
713        let report = AuditPlugin::audit_directory(root);
714        let issues = sri_issues(&report);
715        assert!(
716            issues.iter().any(|i| i.contains("/second.js")),
717            "the wrong hash on the second script must be reported, \
718             got: {issues:?}"
719        );
720        assert!(
721            !issues.iter().any(|i| i.contains("/first.js")),
722            "the correct first script must not be reported: {issues:?}"
723        );
724    }
725
726    /// `tag_attr` matches on an attribute boundary, so a `data-` prefixed
727    /// look-alike is not mistaken for the real attribute.
728    #[test]
729    fn tag_attr_does_not_match_a_prefixed_lookalike() {
730        let tag = "<script data-src=\"/decoy.js\" src=\"/real.js\">";
731        assert_eq!(AuditPlugin::tag_attr(tag, "src"), Some("/real.js"));
732        let only_decoy = "<script data-src=\"/decoy.js\">";
733        assert_eq!(AuditPlugin::tag_attr(only_decoy, "src"), None);
734    }
735
736    /// A `>` inside an attribute value must not end the tag early.
737    #[test]
738    fn opening_tags_are_bounded_quote_aware() {
739        let html = "<a title=\"a > b\" href=\"/x\">text</a>";
740        let tags = AuditPlugin::opening_tags(html);
741        assert_eq!(AuditPlugin::tag_attr(tags[0], "href"), Some("/x"));
742    }
743
744    #[test]
745    fn test_compute_sri() {
746        let data = b"console.log('hello world');";
747        let sri = AuditPlugin::compute_sri(data);
748        assert!(sri.starts_with("sha384-"));
749    }
750
751    #[test]
752    fn test_taxonomy_page_matches_plain_tags_root() {
753        assert!(is_taxonomy_page("tags/index.html"));
754        assert!(is_taxonomy_page("tags/method/index.html"));
755    }
756
757    #[test]
758    fn test_taxonomy_page_matches_locale_prefixed_tags() {
759        // `url_prefix = "sub_path"` emits the translated set under the locale,
760        // which the pillar must excuse exactly as it does the default one.
761        assert!(is_taxonomy_page("fr/tags/index.html"));
762        assert!(is_taxonomy_page("fr/tags/editorial/index.html"));
763        assert!(is_taxonomy_page("pt-br/tags/index.html"));
764    }
765
766    #[test]
767    fn test_taxonomy_page_rejects_authored_pages() {
768        assert!(!is_taxonomy_page("index.html"));
769        assert!(!is_taxonomy_page("about/index.html"));
770        assert!(!is_taxonomy_page("fr/a-propos/index.html"));
771        // A content page that merely starts with the same letters is authored.
772        assert!(!is_taxonomy_page("tagging-guide/index.html"));
773    }
774
775    /// Issues must be recorded in lexical path order, not filesystem order.
776    ///
777    /// `fs::read_dir` returns entries in whatever order the filesystem gives:
778    /// ext4 and APFS disagree, so without a sort the same input produced a
779    /// different `quality-gate-report.json` on Linux and macOS, and the
780    /// cross-OS determinism gate failed on exactly that one file.
781    ///
782    /// This asserts the ordering property directly rather than comparing two
783    /// builds. A comparison test passes vacuously on a filesystem that
784    /// happens to return entries in a stable order — which APFS does, so it
785    /// proved nothing locally while the real divergence was against Linux.
786    #[test]
787    fn issues_are_recorded_in_lexical_path_order() {
788        let temp = TempDir::new().unwrap();
789        let sdir = temp.path();
790
791        fs::write(sdir.join("robots.txt"), "User-agent: *").unwrap();
792        fs::write(sdir.join("sitemap.xml"), "<urlset></urlset>").unwrap();
793        fs::write(sdir.join("manifest.json"), "{}").unwrap();
794        fs::write(sdir.join("rss.xml"), "<rss></rss>").unwrap();
795        fs::write(sdir.join("search-index.json"), "[]").unwrap();
796
797        // Names chosen so creation order and lexical order differ.
798        for name in ["zulu", "alpha", "mike", "bravo"] {
799            let sub = sdir.join(name);
800            fs::create_dir_all(&sub).unwrap();
801            fs::write(
802                sub.join("index.html"),
803                "<html><body><p>no lang, no h1</p></body></html>",
804            )
805            .unwrap();
806        }
807
808        let report = AuditPlugin::audit_directory(sdir);
809        let pillar = report
810            .pillars
811            .get("10. Accessibility & Semantic Hierarchy")
812            .expect("accessibility pillar is always present");
813        assert!(
814            pillar.issues.len() >= 4,
815            "expected an issue per page, got {:?}",
816            pillar.issues
817        );
818
819        let paths: Vec<&str> = pillar
820            .issues
821            .iter()
822            .filter_map(|i| i.split(':').next())
823            .collect();
824        let mut sorted = paths.clone();
825        sorted.sort_unstable();
826        assert_eq!(
827            paths, sorted,
828            "issues are not in lexical path order; the file walk is unsorted"
829        );
830    }
831
832    #[test]
833    fn test_navbar_accepts_bootstrap_class_pair() {
834        let html = r#"<nav class="navbar"><a class="navbar-brand" href="/">Home</a></nav>"#;
835        assert!(has_responsive_navbar(html));
836    }
837
838    #[test]
839    fn test_navbar_accepts_semantic_nav_with_brand() {
840        // The shape every first-party theme ships: a `<nav>` landmark and a
841        // `brand` home link, with no Bootstrap class names anywhere.
842        let html = r#"<header><a class="brand" href="/">Lucid</a>
843  <nav aria-label="Main"><ul><li><a href="/install/">Install</a></li></ul></nav>
844</header>"#;
845        assert!(has_responsive_navbar(html));
846    }
847
848    #[test]
849    fn test_navbar_accepts_navigation_role_with_home_rel() {
850        let html =
851            r#"<div role="navigation"><a rel="home" href="/">Site</a></div>"#;
852        assert!(has_responsive_navbar(html));
853    }
854
855    #[test]
856    fn test_navbar_rejects_page_without_navigation() {
857        let html =
858            r#"<header><h1>Just a title</h1></header><main><p>Body</p></main>"#;
859        assert!(!has_responsive_navbar(html));
860    }
861
862    #[test]
863    fn test_navbar_rejects_nav_landmark_without_brand() {
864        // A bare nav with no site identity is still an incomplete header, so
865        // the pillar should keep flagging it.
866        let html = r#"<nav aria-label="Main"><ul><li><a href="/a/">A</a></li></ul></nav>"#;
867        assert!(!has_responsive_navbar(html));
868    }
869
870    #[test]
871    fn test_audit_directory_non_existent() {
872        let p = Path::new("/non/existent/path/here");
873        let report = AuditPlugin::audit_directory(p);
874        assert_eq!(report.passed_pillars, 0);
875        assert_eq!(report.total_issues, 10);
876    }
877
878    #[test]
879    fn test_audit_directory_clean_site() {
880        let temp = TempDir::new().unwrap();
881        let sdir = temp.path();
882
883        // Write essential files
884        fs::write(sdir.join("robots.txt"), "User-agent: *\nDisallow:").unwrap();
885        fs::write(sdir.join("sitemap.xml"), "<urlset></urlset>").unwrap();
886        fs::write(sdir.join("manifest.json"), "{}").unwrap();
887        fs::write(sdir.join("rss.xml"), "<rss></rss>").unwrap();
888        fs::write(sdir.join("search-index.json"), "[]").unwrap();
889
890        // Write index.html
891        let html = r#"<!DOCTYPE html>
892<html lang="en-GB">
893<head>
894  <meta charset="utf-8">
895  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';">
896  <title>Clean Test Site</title>
897</head>
898<body>
899  <nav class="navbar"><a class="navbar-brand" href="/">Home</a></nav>
900  <main id="main">
901    <h1>Clean Test Site</h1>
902    <p>Welcome to the clean site.</p>
903  </main>
904  <footer>
905    <a href="/made-with-ssg/index.html">Made with SSG</a>
906  </footer>
907</body>
908</html>"#;
909        fs::write(sdir.join("index.html"), html).unwrap();
910
911        let report = AuditPlugin::audit_directory(sdir);
912        assert_eq!(report.passed_pillars, 10);
913        assert_eq!(report.total_issues, 0);
914        // `pass_rate` is a computed f64; compare within epsilon rather
915        // than with `==`, which is what clippy::float_cmp guards.
916        assert!((report.pass_rate - 100.0).abs() < f64::EPSILON);
917    }
918}