Skip to main content

ssg/audit/gates/
util.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Internal helpers shared across audit gates.
5//!
6//! Provides a quote-aware HTML tag end-detector (so SVG data URLs and
7//! `?…&…` query strings inside `src` / `href` attributes do not
8//! truncate tags) plus a small attribute extractor that mirrors
9//! `accessibility::extract_attr_value`'s behaviour.
10
11/// Returns the absolute end index (one past the closing `>`) of the
12/// HTML tag that starts at `tag_start`. Skips `>` characters inside
13/// quoted attribute values (single and double quotes).
14///
15/// # Examples
16///
17/// ```
18/// use ssg::audit::gates::find_tag_end;
19/// let html = "<br>x";
20/// assert_eq!(find_tag_end(html, 0), 4);
21/// ```
22#[allow(dead_code)]
23/// Scan to the end of an HTML tag.
24///
25/// Skips `>` inside quoted attribute values.
26///
27/// Re-exported from `ssg-a11y`, which owns the single implementation. This
28/// crate previously carried three copies of it and `ssg-a11y` a fourth; they
29/// were byte-identical apart from visibility, and when clippy 1.98 added
30/// `missing_const_for_fn` the lint fired on each one separately — four
31/// sequential CI round-trips for one function (ssg#711).
32///
33/// The dependency already ran this way (`ssg` -> `ssg-a11y`), so sharing costs
34/// no new edge and leaves that crate standalone, as its description promises.
35pub use ssg_a11y::find_tag_end;
36
37/// Reads a single attribute value out of a tag.
38///
39/// Accepts double, single, or unquoted attribute values, plus
40/// valueless (boolean) attributes — HTML minifiers collapse `alt=""`
41/// to a bare `alt`, which returns `Some(String::new())`. Match is
42/// case-insensitive on the attribute name, case-preserving on the
43/// value, and tokenises real attribute boundaries so `data-href=`
44/// never satisfies a lookup for `href`.
45///
46/// The slightly odd name (`hreflang_attr`) is historical — this used
47/// to live in the hreflang gate; promoted to a shared helper when the
48/// CSP gate needed the same parser.
49///
50/// # Examples
51///
52/// ```
53/// use ssg::audit::gates::hreflang_attr;
54/// let tag = r#"<link rel="alternate" hreflang="en" href="/en/">"#;
55/// assert_eq!(hreflang_attr(tag, "hreflang"), Some("en".to_string()));
56/// assert_eq!(hreflang_attr(tag, "href"), Some("/en/".to_string()));
57/// // Minified valueless attribute (`alt=""` collapsed to `alt`).
58/// assert_eq!(hreflang_attr("<img alt src=a.png>", "alt"), Some(String::new()));
59/// ```
60pub fn hreflang_attr(tag: &str, name: &str) -> Option<String> {
61    let bytes = tag.as_bytes();
62    let mut i = usize::from(bytes.first() == Some(&b'<'));
63    // Skip the tag name itself.
64    while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>'
65    {
66        i += 1;
67    }
68    while i < bytes.len() {
69        // Skip whitespace and self-closing slashes between attributes.
70        while i < bytes.len()
71            && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/')
72        {
73            i += 1;
74        }
75        if i >= bytes.len() || bytes[i] == b'>' {
76            return None;
77        }
78        // Attribute name.
79        let name_start = i;
80        while i < bytes.len()
81            && !bytes[i].is_ascii_whitespace()
82            && bytes[i] != b'='
83            && bytes[i] != b'>'
84            && bytes[i] != b'/'
85        {
86            i += 1;
87        }
88        let attr_name = &tag[name_start..i];
89        // Optional `= value` (whitespace around `=` tolerated).
90        let mut j = i;
91        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
92            j += 1;
93        }
94        if j >= bytes.len() || bytes[j] != b'=' {
95            // Valueless attribute (e.g. minified bare `alt`).
96            if attr_name.eq_ignore_ascii_case(name) {
97                return Some(String::new());
98            }
99            continue;
100        }
101        // Value start (skip `=` and any whitespace after it).
102        let mut k = j + 1;
103        while k < bytes.len() && bytes[k].is_ascii_whitespace() {
104            k += 1;
105        }
106        let (value_start, value_end, resume) =
107            if k < bytes.len() && (bytes[k] == b'"' || bytes[k] == b'\'') {
108                let quote = bytes[k] as char;
109                let vstart = k + 1;
110                // Unterminated quote: bail — the tag is malformed.
111                let close = vstart + tag[vstart..].find(quote)?;
112                (vstart, close, close + 1)
113            } else {
114                let mut vend = k;
115                while vend < bytes.len()
116                    && !bytes[vend].is_ascii_whitespace()
117                    && bytes[vend] != b'>'
118                {
119                    vend += 1;
120                }
121                (k, vend, vend)
122            };
123        if attr_name.eq_ignore_ascii_case(name) {
124            return Some(tag[value_start..value_end].to_string());
125        }
126        i = resume;
127    }
128    None
129}
130
131/// Returns a copy of `html` with the *contents* of every `<script>` and
132/// `<style>` element blanked to spaces (byte offsets are preserved;
133/// newlines survive so line-based diagnostics stay stable).
134///
135/// Tag-scanning gates use this so markup embedded in JS string literals
136/// — e.g. a client-side search overlay building `<a href="…">` result
137/// rows — is never mistaken for document links.
138///
139/// # Examples
140///
141/// ```
142/// use ssg::audit::gates::strip_script_and_style;
143/// let html = r#"<script>var s='<a href="/x">';</script><a href="/y">y</a>"#;
144/// let stripped = strip_script_and_style(html);
145/// assert!(!stripped.contains("/x"));
146/// assert!(stripped.contains("/y"));
147/// ```
148#[must_use]
149pub fn strip_script_and_style(html: &str) -> String {
150    let mut out = html.as_bytes().to_vec();
151    let lower = html.to_ascii_lowercase();
152    for (open, close) in &[("<script", "</script"), ("<style", "</style")] {
153        let mut cursor = 0;
154        while let Some(rel) = lower[cursor..].find(open) {
155            let abs = cursor + rel;
156            let content_start = find_tag_end(&lower, abs);
157            let content_end = lower[content_start..]
158                .find(close)
159                .map_or(lower.len(), |e| content_start + e);
160            for b in &mut out[content_start..content_end] {
161                if !b.is_ascii_whitespace() {
162                    *b = b' ';
163                }
164            }
165            cursor = content_end.max(abs + open.len());
166        }
167    }
168    // Replaced regions start/end on char boundaries (str::find results)
169    // and are fully space-filled, so the buffer stays valid UTF-8; the
170    // lossy conversion is a no-op safety net.
171    String::from_utf8_lossy(&out).into_owned()
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn find_tag_end_handles_quoted_gt() {
180        let h = "<img src=\"data:>foo\" alt=\"x\">rest";
181        let end = find_tag_end(h, 0);
182        assert_eq!(&h[..end], "<img src=\"data:>foo\" alt=\"x\">");
183    }
184
185    #[test]
186    fn hreflang_attr_handles_quotes() {
187        let tag = r#"<link rel='alternate' hreflang="en" href=/en/>"#;
188        assert_eq!(hreflang_attr(tag, "hreflang"), Some("en".to_string()));
189        assert_eq!(hreflang_attr(tag, "href"), Some("/en/".to_string()));
190    }
191
192    #[test]
193    fn hreflang_attr_missing_returns_none() {
194        assert_eq!(hreflang_attr("<a>", "href"), None);
195    }
196
197    #[test]
198    fn find_tag_end_returns_len_when_unterminated() {
199        let h = "<img src=\"x\" alt=\"y\"";
200        assert_eq!(find_tag_end(h, 0), h.len());
201    }
202
203    #[test]
204    fn find_tag_end_skips_gt_inside_single_quotes() {
205        let h = "<a href='data:>foo'>rest";
206        let end = find_tag_end(h, 0);
207        assert_eq!(&h[..end], "<a href='data:>foo'>");
208    }
209
210    #[test]
211    fn find_tag_end_simple_unquoted() {
212        let h = "<br>x";
213        assert_eq!(find_tag_end(h, 0), 4);
214    }
215
216    #[test]
217    fn hreflang_attr_handles_single_quotes() {
218        let tag = r#"<a href='single-quoted'>"#;
219        assert_eq!(
220            hreflang_attr(tag, "href"),
221            Some("single-quoted".to_string())
222        );
223    }
224
225    #[test]
226    fn hreflang_attr_unquoted_value_terminated_by_space() {
227        let tag = "<a href=/path other=1>";
228        assert_eq!(hreflang_attr(tag, "href"), Some("/path".to_string()));
229    }
230
231    #[test]
232    fn hreflang_attr_unquoted_value_terminated_by_gt() {
233        let tag = "<a href=/end>";
234        assert_eq!(hreflang_attr(tag, "href"), Some("/end".to_string()));
235    }
236
237    #[test]
238    fn hreflang_attr_case_insensitive_match() {
239        let tag = r#"<A HREF="upper">"#;
240        assert_eq!(hreflang_attr(tag, "href"), Some("upper".to_string()));
241    }
242
243    #[test]
244    fn hreflang_attr_unterminated_double_quote_returns_none() {
245        let tag = r#"<a href="never-closed"#;
246        assert_eq!(hreflang_attr(tag, "href"), None);
247    }
248
249    #[test]
250    fn hreflang_attr_unterminated_single_quote_returns_none() {
251        let tag = r#"<a href='never-closed"#;
252        assert_eq!(hreflang_attr(tag, "href"), None);
253    }
254
255    #[test]
256    fn hreflang_attr_valueless_attribute_returns_empty_value() {
257        // Minifiers collapse `alt=""` to a bare `alt` token.
258        let tag = "<img alt height=33 role=presentation width=100>";
259        assert_eq!(hreflang_attr(tag, "alt"), Some(String::new()));
260        assert_eq!(hreflang_attr(tag, "height"), Some("33".to_string()));
261        assert_eq!(hreflang_attr(tag, "width"), Some("100".to_string()));
262    }
263
264    #[test]
265    fn hreflang_attr_rejects_substring_attribute_names() {
266        // `data-href` must not satisfy a lookup for `href`, and
267        // `hreflang` must not satisfy a lookup for `lang`.
268        let tag = r#"<a data-href="/wrong" hreflang="en-GB">"#;
269        assert_eq!(hreflang_attr(tag, "href"), None);
270        assert_eq!(hreflang_attr(tag, "lang"), None);
271        assert_eq!(hreflang_attr(tag, "hreflang"), Some("en-GB".to_string()));
272    }
273
274    #[test]
275    fn hreflang_attr_unquoted_mixed_case_value_preserved() {
276        // Minified output: unquoted, original casing kept in value.
277        let tag = "<meta http-equiv=Content-Security-Policy content=x>";
278        assert_eq!(
279            hreflang_attr(tag, "HTTP-EQUIV"),
280            Some("Content-Security-Policy".to_string())
281        );
282    }
283
284    #[test]
285    fn hreflang_attr_tolerates_whitespace_around_equals() {
286        let tag = r#"<a href = "/spaced">"#;
287        assert_eq!(hreflang_attr(tag, "href"), Some("/spaced".to_string()));
288    }
289
290    #[test]
291    fn hreflang_attr_ignores_tag_name_prefix() {
292        // The tag name itself must never be parsed as an attribute.
293        assert_eq!(hreflang_attr("<content x=1>", "content"), None);
294    }
295
296    #[test]
297    fn hreflang_attr_unterminated_tag_after_last_attr_returns_none() {
298        // The scan consumes the trailing unquoted value and runs off
299        // the (gt-less) end of the tag without a match.
300        assert_eq!(hreflang_attr("<a foo=bar", "href"), None);
301        assert_eq!(hreflang_attr(r#"<a foo="bar""#, "href"), None);
302    }
303
304    #[test]
305    fn strip_script_and_style_blanks_embedded_markup() {
306        let html = "<script>var h='<a href=\"/ghost\">x</a>';</script>\
307                    <style>a[href=\"/styled\"]{}</style>\
308                    <a href=\"/real\">r</a>";
309        let s = strip_script_and_style(html);
310        assert_eq!(s.len(), html.len(), "offsets must be preserved");
311        assert!(!s.contains("/ghost"));
312        assert!(!s.contains("/styled"));
313        assert!(s.contains("/real"));
314    }
315
316    #[test]
317    fn strip_script_and_style_preserves_newlines() {
318        let html = "<script>\nline1\nline2\n</script>\n<p>after</p>";
319        let s = strip_script_and_style(html);
320        assert_eq!(
321            s.matches('\n').count(),
322            html.matches('\n').count(),
323            "newlines inside stripped regions must survive"
324        );
325        assert!(s.contains("<p>after</p>"));
326    }
327
328    #[test]
329    fn strip_script_and_style_unclosed_script_blanks_to_eof() {
330        let html = "<p>keep</p><script>var x='<a href=\"/js\">';";
331        let s = strip_script_and_style(html);
332        assert!(s.contains("keep"));
333        assert!(!s.contains("/js"));
334    }
335
336    #[test]
337    fn strip_script_and_style_handles_multibyte_content() {
338        let html = "<script>var s='héllo — “quoted”';</script><p>café</p>";
339        let s = strip_script_and_style(html);
340        assert!(s.contains("café"));
341        assert!(!s.contains("héllo"));
342    }
343}