pub const MAX_SUBRESOURCES: usize = 64;
const FETCHED_RELS: [&str; 5] = ["stylesheet", "icon", "preload", "modulepreload", "prefetch"];
pub fn scan(html: &[u8], max: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i < html.len() && out.len() < max {
if html[i] != b'<' {
i += 1;
continue;
}
if html[i..].starts_with(b"<!--") {
i = match find(html, i + 4, b"-->") {
Some(at) => at + 3,
None => break,
};
continue;
}
let (name, after_name) = tag_name(html, i + 1);
if name.is_empty() {
i += 1;
continue;
}
let (attrs, after_tag) = attributes(html, after_name);
match name.as_str() {
"link" => {
let fetched = value(&attrs, "rel").is_some_and(|rel| {
rel.split_whitespace()
.any(|r| FETCHED_RELS.contains(&r.to_ascii_lowercase().as_str()))
});
if fetched {
push(&mut out, value(&attrs, "href"));
}
}
"script" | "img" | "source" | "audio" | "video" | "iframe" | "embed" => {
push(&mut out, value(&attrs, "src"));
}
_ => {}
}
i = match name.as_str() {
"script" | "style" => {
find_close(html, after_tag, name.as_bytes()).unwrap_or(html.len())
}
_ => after_tag,
};
}
out
}
fn push(out: &mut Vec<String>, raw: Option<&str>) {
if let Some(url) = raw.and_then(usable) {
if !out.contains(&url) {
out.push(url);
}
}
}
fn usable(raw: &str) -> Option<String> {
let cut = raw.find(['?', '#']).unwrap_or(raw.len());
let url = raw[..cut].trim();
if url.is_empty() {
return None;
}
if url.starts_with("//") {
return None;
}
if let Some(colon) = url.find(':') {
if !url[..colon].contains('/') {
return None;
}
}
Some(url.to_string())
}
fn find(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
if from >= haystack.len() || needle.len() > haystack.len() - from {
return None;
}
haystack[from..]
.windows(needle.len())
.position(|w| w == needle)
.map(|at| from + at)
}
fn find_close(html: &[u8], from: usize, name: &[u8]) -> Option<usize> {
let mut i = from;
while i + 2 + name.len() <= html.len() {
if html[i] == b'<'
&& html[i + 1] == b'/'
&& html[i + 2..i + 2 + name.len()].eq_ignore_ascii_case(name)
{
return Some(i);
}
i += 1;
}
None
}
fn skip_space(html: &[u8], from: usize) -> usize {
let mut i = from;
while i < html.len() && html[i].is_ascii_whitespace() {
i += 1;
}
i
}
fn tag_name(html: &[u8], from: usize) -> (String, usize) {
let mut end = from;
while end < html.len() && html[end].is_ascii_alphanumeric() {
end += 1;
}
let name = String::from_utf8_lossy(&html[from..end]).to_ascii_lowercase();
(name, end)
}
fn attributes(html: &[u8], from: usize) -> (Vec<(String, String)>, usize) {
let mut attrs = Vec::new();
let mut i = from;
while i < html.len() {
while i < html.len() && (html[i].is_ascii_whitespace() || html[i] == b'/') {
i += 1;
}
if i >= html.len() || html[i] == b'>' {
break;
}
let start = i;
while i < html.len() && !html[i].is_ascii_whitespace() && html[i] != b'=' && html[i] != b'>'
{
i += 1;
}
let name = String::from_utf8_lossy(&html[start..i]).to_ascii_lowercase();
i = skip_space(html, i);
if i >= html.len() || html[i] != b'=' {
attrs.push((name, String::new()));
continue;
}
i += 1;
i = skip_space(html, i);
if i >= html.len() {
break;
}
let (raw, next) = match html[i] {
q @ (b'"' | b'\'') => {
let start = i + 1;
let end = find(html, start, &[q]).unwrap_or(html.len());
(&html[start..end], (end + 1).min(html.len()))
}
_ => {
let start = i;
let mut end = i;
while end < html.len() && !html[end].is_ascii_whitespace() && html[end] != b'>' {
end += 1;
}
(&html[start..end], end)
}
};
attrs.push((name, String::from_utf8_lossy(raw).into_owned()));
i = next;
}
(attrs, (i + 1).min(html.len()))
}
fn value<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
attrs
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
const MANY: usize = 1000;
#[test]
fn finds_the_three_references_a_generated_page_actually_has() {
let html = br#"<!doctype html><html><head>
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head><body><img src="plot.png" alt="a plot"></body></html>"#;
assert_eq!(scan(html, MANY), ["style.css", "app.js", "plot.png"]);
}
#[test]
fn reads_single_quoted_and_unquoted_values() {
let html = br#"<img src='a.png'><img src=b.png><img src = "c.png">"#;
assert_eq!(scan(html, MANY), ["a.png", "b.png", "c.png"]);
}
#[test]
fn tag_and_attribute_names_are_matched_regardless_of_case() {
let html = br#"<LINK REL="Stylesheet" HREF="a.css"><IMG SRC="b.png">"#;
assert_eq!(scan(html, MANY), ["a.css", "b.png"]);
}
#[test]
fn a_link_the_browser_would_not_fetch_is_left_alone() {
let html = br#"<link rel="stylesheet" href="yes.css">
<link rel="alternate" href="no.pdf">
<link rel="canonical" href="no.html">
<link href="no-rel.css">
<link rel="icon" href="yes.ico">
<link rel="preload modulepreload" href="yes.mjs">"#;
assert_eq!(scan(html, MANY), ["yes.css", "yes.ico", "yes.mjs"]);
}
#[test]
fn ordinary_links_are_not_subresources() {
let html = br#"<a href="other.html">other</a><form action="post.cgi"></form>"#;
assert!(scan(html, MANY).is_empty());
}
#[test]
fn references_to_somewhere_else_are_skipped() {
let html = br##"<img src="https://example.com/a.png">
<img src="//example.com/b.png">
<img src="data:image/gif;base64,R0lGOD">
<script src="http://example.com/c.js"></script>
<img src="">
<img src="#">"##;
assert!(scan(html, MANY).is_empty());
}
#[test]
fn a_query_or_fragment_is_cut_off() {
let html = br#"<link rel="stylesheet" href="style.css?v=3">
<img src="sprite.svg#icon">"#;
assert_eq!(scan(html, MANY), ["style.css", "sprite.svg"]);
}
#[test]
fn a_colon_after_a_slash_is_not_a_scheme() {
let html = br#"<img src="plots/t:0.5.png">"#;
assert_eq!(scan(html, MANY), ["plots/t:0.5.png"]);
}
#[test]
fn a_script_body_is_not_read_as_markup() {
let html = br#"<script src="a.js">if (x<y) { var s = "<img src=fake.png>"; }</script>
<img src="real.png">"#;
assert_eq!(scan(html, MANY), ["a.js", "real.png"]);
}
#[test]
fn a_style_body_is_not_read_as_markup() {
let html = br#"<style>a::before { content: "<img src=fake.png>"; }</style>
<img src="real.png">"#;
assert_eq!(scan(html, MANY), ["real.png"]);
}
#[test]
fn a_comment_cannot_smuggle_a_reference() {
let html = br#"<!-- <img src="commented.png"> --><img src="real.png">"#;
assert_eq!(scan(html, MANY), ["real.png"]);
}
#[test]
fn the_same_reference_is_only_returned_once() {
let html = br#"<img src="a.png"><img src="a.png"><img src="a.png">"#;
assert_eq!(scan(html, MANY), ["a.png"]);
}
#[test]
fn the_cap_is_honoured() {
let html: Vec<u8> = (0..100)
.map(|i| format!("<img src=\"{i}.png\">"))
.collect::<String>()
.into_bytes();
assert_eq!(scan(&html, 10).len(), 10);
assert_eq!(scan(&html, MAX_SUBRESOURCES).len(), MAX_SUBRESOURCES);
}
#[test]
fn truncated_markup_does_not_panic() {
for html in [
&b"<img src=\"a.png"[..],
b"<img src=",
b"<img",
b"<",
b"<!--",
b"<!-- <img src=\"a.png\">",
b"<script src=\"a.js\">unclosed",
b"<link rel=",
b"<img src='",
] {
let _ = scan(html, MANY);
}
}
#[test]
fn an_empty_document_yields_nothing() {
assert!(scan(b"", MANY).is_empty());
assert!(scan(b"no markup at all", MANY).is_empty());
}
}