Skip to main content

ssh_browser/prefetch/
mod.rs

1//! What an HTML document is about to ask for.
2//!
3//! This exists because of a limit in the browser, not in the remote. A page with forty
4//! subresources is not forty requests made at once: HTTP/1.1 allows six connections per
5//! origin, so it is seven waves, each discovered only once the previous one came back. Seven
6//! waves is seven remote round trips, and the count grows with the number of subresources —
7//! which is exactly what invariant 1 says must not happen.
8//!
9//! Reading the page's own references first collapses that to one batch. The waves then arrive
10//! to find everything already held.
11//!
12//! The scanner is a heuristic and does not need to be better than one, because **it can only
13//! affect speed, never correctness**. Every subresource is still served by a real request
14//! through the real guards: a reference missed here is fetched normally, and a reference
15//! invented here is a read that fails and is dropped. Nothing it does can make a page wrong.
16//! That is what makes hand-scanning acceptable here when it would not be if the scan decided
17//! what a reader is allowed to see.
18
19/// How many references to take from one document.
20///
21/// A cap, because the scan happens before the page is answered: a document listing a thousand
22/// lazy-loaded images would otherwise spend the reader's time fetching what they may never
23/// scroll to.
24pub const MAX_SUBRESOURCES: usize = 64;
25
26/// The `rel` values that mean the browser will actually fetch the `href`.
27///
28/// Checked rather than assumed, so `rel="alternate"` pointing at a large download does not
29/// get read just for sitting in a `<link>`.
30const FETCHED_RELS: [&str; 5] = ["stylesheet", "icon", "preload", "modulepreload", "prefetch"];
31
32/// Collect the subresources an HTML document refers to, in document order, without repeats.
33pub fn scan(html: &[u8], max: usize) -> Vec<String> {
34    let mut out: Vec<String> = Vec::new();
35    let mut i = 0;
36
37    while i < html.len() && out.len() < max {
38        if html[i] != b'<' {
39            i += 1;
40            continue;
41        }
42        // A comment may contain anything at all, including something shaped like a tag.
43        if html[i..].starts_with(b"<!--") {
44            i = match find(html, i + 4, b"-->") {
45                Some(at) => at + 3,
46                None => break,
47            };
48            continue;
49        }
50
51        let (name, after_name) = tag_name(html, i + 1);
52        if name.is_empty() {
53            i += 1;
54            continue;
55        }
56        let (attrs, after_tag) = attributes(html, after_name);
57
58        match name.as_str() {
59            "link" => {
60                // A `rel` value is case-insensitive in HTML, so it is folded before being
61                // compared and not only the attribute's name.
62                let fetched = value(&attrs, "rel").is_some_and(|rel| {
63                    rel.split_whitespace()
64                        .any(|r| FETCHED_RELS.contains(&r.to_ascii_lowercase().as_str()))
65                });
66                if fetched {
67                    push(&mut out, value(&attrs, "href"));
68                }
69            }
70            "script" | "img" | "source" | "audio" | "video" | "iframe" | "embed" => {
71                push(&mut out, value(&attrs, "src"));
72            }
73            _ => {}
74        }
75
76        // The body of a script or a style is not markup. A `<` inside a JavaScript string
77        // would otherwise read as the start of a tag and derail everything after it.
78        i = match name.as_str() {
79            "script" | "style" => {
80                find_close(html, after_tag, name.as_bytes()).unwrap_or(html.len())
81            }
82            _ => after_tag,
83        };
84    }
85
86    out
87}
88
89fn push(out: &mut Vec<String>, raw: Option<&str>) {
90    if let Some(url) = raw.and_then(usable) {
91        if !out.contains(&url) {
92            out.push(url);
93        }
94    }
95}
96
97/// Keep only references this origin could serve, stripped of what the remote never sees.
98fn usable(raw: &str) -> Option<String> {
99    let cut = raw.find(['?', '#']).unwrap_or(raw.len());
100    let url = raw[..cut].trim();
101    if url.is_empty() {
102        return None;
103    }
104    // Protocol-relative, so somewhere else by definition.
105    if url.starts_with("//") {
106        return None;
107    }
108    // A scheme is somewhere else too, `data:` and `mailto:` included. The colon has to come
109    // before any slash to be a scheme, or a filename like `t:0.5.png` would read as one.
110    if let Some(colon) = url.find(':') {
111        if !url[..colon].contains('/') {
112            return None;
113        }
114    }
115    Some(url.to_string())
116}
117
118fn find(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
119    if from >= haystack.len() || needle.len() > haystack.len() - from {
120        return None;
121    }
122    haystack[from..]
123        .windows(needle.len())
124        .position(|w| w == needle)
125        .map(|at| from + at)
126}
127
128/// Where `</name` starts, matched without regard to case.
129fn find_close(html: &[u8], from: usize, name: &[u8]) -> Option<usize> {
130    let mut i = from;
131    while i + 2 + name.len() <= html.len() {
132        if html[i] == b'<'
133            && html[i + 1] == b'/'
134            && html[i + 2..i + 2 + name.len()].eq_ignore_ascii_case(name)
135        {
136            return Some(i);
137        }
138        i += 1;
139    }
140    None
141}
142
143/// Past any run of whitespace.
144fn skip_space(html: &[u8], from: usize) -> usize {
145    let mut i = from;
146    while i < html.len() && html[i].is_ascii_whitespace() {
147        i += 1;
148    }
149    i
150}
151
152/// Read a tag name, lowercased, and say where it ended.
153fn tag_name(html: &[u8], from: usize) -> (String, usize) {
154    let mut end = from;
155    while end < html.len() && html[end].is_ascii_alphanumeric() {
156        end += 1;
157    }
158    let name = String::from_utf8_lossy(&html[from..end]).to_ascii_lowercase();
159    (name, end)
160}
161
162/// Read a tag's attributes up to its `>`, and say where the tag ended.
163fn attributes(html: &[u8], from: usize) -> (Vec<(String, String)>, usize) {
164    let mut attrs = Vec::new();
165    let mut i = from;
166
167    while i < html.len() {
168        while i < html.len() && (html[i].is_ascii_whitespace() || html[i] == b'/') {
169            i += 1;
170        }
171        if i >= html.len() || html[i] == b'>' {
172            break;
173        }
174
175        let start = i;
176        while i < html.len() && !html[i].is_ascii_whitespace() && html[i] != b'=' && html[i] != b'>'
177        {
178            i += 1;
179        }
180        let name = String::from_utf8_lossy(&html[start..i]).to_ascii_lowercase();
181
182        i = skip_space(html, i);
183        if i >= html.len() || html[i] != b'=' {
184            // A bare attribute such as `defer`, which carries no value.
185            attrs.push((name, String::new()));
186            continue;
187        }
188        i += 1;
189        i = skip_space(html, i);
190        if i >= html.len() {
191            break;
192        }
193
194        let (raw, next) = match html[i] {
195            q @ (b'"' | b'\'') => {
196                let start = i + 1;
197                let end = find(html, start, &[q]).unwrap_or(html.len());
198                (&html[start..end], (end + 1).min(html.len()))
199            }
200            _ => {
201                let start = i;
202                let mut end = i;
203                while end < html.len() && !html[end].is_ascii_whitespace() && html[end] != b'>' {
204                    end += 1;
205                }
206                (&html[start..end], end)
207            }
208        };
209        attrs.push((name, String::from_utf8_lossy(raw).into_owned()));
210        i = next;
211    }
212
213    (attrs, (i + 1).min(html.len()))
214}
215
216fn value<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
217    attrs
218        .iter()
219        .find(|(n, _)| n == name)
220        .map(|(_, v)| v.as_str())
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    const MANY: usize = 1000;
228
229    #[test]
230    fn finds_the_three_references_a_generated_page_actually_has() {
231        let html = br#"<!doctype html><html><head>
232            <link rel="stylesheet" href="style.css">
233            <script src="app.js" defer></script>
234            </head><body><img src="plot.png" alt="a plot"></body></html>"#;
235        assert_eq!(scan(html, MANY), ["style.css", "app.js", "plot.png"]);
236    }
237
238    #[test]
239    fn reads_single_quoted_and_unquoted_values() {
240        let html = br#"<img src='a.png'><img src=b.png><img src = "c.png">"#;
241        assert_eq!(scan(html, MANY), ["a.png", "b.png", "c.png"]);
242    }
243
244    #[test]
245    fn tag_and_attribute_names_are_matched_regardless_of_case() {
246        let html = br#"<LINK REL="Stylesheet" HREF="a.css"><IMG SRC="b.png">"#;
247        assert_eq!(scan(html, MANY), ["a.css", "b.png"]);
248    }
249
250    /// A `<link>` the browser would not fetch must not be fetched here either, or a
251    /// `rel="alternate"` pointing at a large download becomes part of loading the page.
252    #[test]
253    fn a_link_the_browser_would_not_fetch_is_left_alone() {
254        let html = br#"<link rel="stylesheet" href="yes.css">
255            <link rel="alternate" href="no.pdf">
256            <link rel="canonical" href="no.html">
257            <link href="no-rel.css">
258            <link rel="icon" href="yes.ico">
259            <link rel="preload modulepreload" href="yes.mjs">"#;
260        assert_eq!(scan(html, MANY), ["yes.css", "yes.ico", "yes.mjs"]);
261    }
262
263    /// An `<a href>` is a page the reader has not asked for. Following those would turn
264    /// opening one document into crawling the whole tree.
265    #[test]
266    fn ordinary_links_are_not_subresources() {
267        let html = br#"<a href="other.html">other</a><form action="post.cgi"></form>"#;
268        assert!(scan(html, MANY).is_empty());
269    }
270
271    #[test]
272    fn references_to_somewhere_else_are_skipped() {
273        // Doubled delimiter: `src="#"` would otherwise close a `br#"..."#` literal.
274        let html = br##"<img src="https://example.com/a.png">
275            <img src="//example.com/b.png">
276            <img src="data:image/gif;base64,R0lGOD">
277            <script src="http://example.com/c.js"></script>
278            <img src="">
279            <img src="#">"##;
280        assert!(scan(html, MANY).is_empty());
281    }
282
283    /// The remote is asked for a path, and neither the query nor the fragment is part of one.
284    #[test]
285    fn a_query_or_fragment_is_cut_off() {
286        let html = br#"<link rel="stylesheet" href="style.css?v=3">
287            <img src="sprite.svg#icon">"#;
288        assert_eq!(scan(html, MANY), ["style.css", "sprite.svg"]);
289    }
290
291    /// A path containing a colon is still a path. Reading it as a scheme would silently stop
292    /// prefetching for anybody whose filenames contain one, which on a sweep of parameters is
293    /// most of them.
294    #[test]
295    fn a_colon_after_a_slash_is_not_a_scheme() {
296        let html = br#"<img src="plots/t:0.5.png">"#;
297        assert_eq!(scan(html, MANY), ["plots/t:0.5.png"]);
298    }
299
300    /// The failure this guards against: a `<` inside JavaScript read as the start of a tag
301    /// derails the scan from there on, so everything after the script is silently lost.
302    #[test]
303    fn a_script_body_is_not_read_as_markup() {
304        let html = br#"<script src="a.js">if (x<y) { var s = "<img src=fake.png>"; }</script>
305            <img src="real.png">"#;
306        assert_eq!(scan(html, MANY), ["a.js", "real.png"]);
307    }
308
309    #[test]
310    fn a_style_body_is_not_read_as_markup() {
311        let html = br#"<style>a::before { content: "<img src=fake.png>"; }</style>
312            <img src="real.png">"#;
313        assert_eq!(scan(html, MANY), ["real.png"]);
314    }
315
316    #[test]
317    fn a_comment_cannot_smuggle_a_reference() {
318        let html = br#"<!-- <img src="commented.png"> --><img src="real.png">"#;
319        assert_eq!(scan(html, MANY), ["real.png"]);
320    }
321
322    #[test]
323    fn the_same_reference_is_only_returned_once() {
324        let html = br#"<img src="a.png"><img src="a.png"><img src="a.png">"#;
325        assert_eq!(scan(html, MANY), ["a.png"]);
326    }
327
328    #[test]
329    fn the_cap_is_honoured() {
330        let html: Vec<u8> = (0..100)
331            .map(|i| format!("<img src=\"{i}.png\">"))
332            .collect::<String>()
333            .into_bytes();
334        assert_eq!(scan(&html, 10).len(), 10);
335        assert_eq!(scan(&html, MAX_SUBRESOURCES).len(), MAX_SUBRESOURCES);
336    }
337
338    /// Truncated markup is what a partial write or a template error produces, and the remote
339    /// chooses this input. The scan has to stop rather than run off the end of the buffer.
340    #[test]
341    fn truncated_markup_does_not_panic() {
342        for html in [
343            &b"<img src=\"a.png"[..],
344            b"<img src=",
345            b"<img",
346            b"<",
347            b"<!--",
348            b"<!-- <img src=\"a.png\">",
349            b"<script src=\"a.js\">unclosed",
350            b"<link rel=",
351            b"<img src='",
352        ] {
353            let _ = scan(html, MANY);
354        }
355    }
356
357    #[test]
358    fn an_empty_document_yields_nothing() {
359        assert!(scan(b"", MANY).is_empty());
360        assert!(scan(b"no markup at all", MANY).is_empty());
361    }
362}