| //! What the fetch tool does with a page, apart from fetching it. | |
| //! | |
| //! Fetch runs in two hosts (PLAN M19): as a WASI component a launcher or a tool server | |
| //! runs, and natively in the tool server, where the project's own trusted code needs no | |
| //! runtime around it. The two make the same requests and give the same answer, so what | |
| //! is not the request lives here, once: the configuration and the arguments, the URL | |
| //! split into what a request is made of, where a `Location` points, HTML turned into | |
| //! the text a reader would see, and the answer shaped from what came back. Neither host | |
| //! decides where a request may go; a gate does, before it leaves. | |
| //! | |
| //! Config, JSON, all optional: `{"max_bytes": 2000000, "max_redirects": 5}`. | |
| //! Args, JSON: `{"url": "https://..."}`. | |
| //! Answer, JSON: `{"url", "final_url", "status", "content_type", "title", "text", | |
| //! "truncated", "redirects"}`, where `redirects` lists the URLs followed to, in order. | |
| use serde::Deserialize; | |
| /// What a request asks for: HTML first, then text, then JSON. | |
| pub const ACCEPT: &str = "text/html, text/plain;q=0.9, application/json;q=0.8, */*;q=0.1"; | |
| /// Who a request says it is. | |
| pub const USER_AGENT: &str = "durable-memory-fetch/0.1"; | |
| /// The owner's configuration for the tool. | |
| #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] | |
| pub struct Config { | |
| /// How much of a body is read; the rest is dropped and the answer says so. | |
| #[serde(default = "max_bytes")] | |
| pub max_bytes: usize, | |
| /// How many redirects are followed before the call fails. | |
| #[serde(default = "max_redirects")] | |
| pub max_redirects: usize, | |
| } | |
| fn max_bytes() -> usize { | |
| 2_000_000 | |
| } | |
| fn max_redirects() -> usize { | |
| 5 | |
| } | |
| impl Default for Config { | |
| fn default() -> Config { | |
| Config { | |
| max_bytes: max_bytes(), | |
| max_redirects: max_redirects(), | |
| } | |
| } | |
| } | |
| impl Config { | |
| /// The configuration as the grant carries it: JSON, or nothing for the defaults. | |
| /// | |
| /// # Errors | |
| /// | |
| /// Text that is not the configuration's JSON. | |
| pub fn parse(text: &str) -> Result<Config, String> { | |
| if text.trim().is_empty() { | |
| return Ok(Config::default()); | |
| } | |
| serde_json::from_str(text).map_err(|e| format!("config: {e}")) | |
| } | |
| } | |
| /// The agent's arguments. | |
| #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] | |
| pub struct Args { | |
| /// The page. | |
| pub url: String, | |
| /// Whether the page is to be rendered first, opened in the owner's browser and read | |
| /// as its scripts built it. Only a fetch the browser runs (`browser = true`) can; | |
| /// the other hosts refuse it rather than fetch the bytes and call them rendered. | |
| #[serde(default)] | |
| pub render: bool, | |
| } | |
| impl Args { | |
| /// The arguments as the agent sent them, the URL checked to be one that is fetched. | |
| /// | |
| /// # Errors | |
| /// | |
| /// Text that is not the arguments' JSON, or a URL that is not http or https. | |
| pub fn parse(text: &str) -> Result<Args, String> { | |
| let args: Args = serde_json::from_str(text).map_err(|e| format!("args: {e}"))?; | |
| split(&args.url)?; | |
| Ok(args) | |
| } | |
| } | |
| /// `scheme`, `authority` and `path?query` of an http or https URL, the scheme in lower | |
| /// case and the fragment dropped; anything else is refused here, before a gate is asked. | |
| /// | |
| /// # Errors | |
| /// | |
| /// No scheme, a scheme other than http or https, or no host. | |
| pub fn split(url: &str) -> Result<(String, String, String), String> { | |
| let (scheme, rest) = url | |
| .split_once("://") | |
| .ok_or_else(|| format!("{url:?} is not an absolute URL"))?; | |
| let scheme = scheme.to_ascii_lowercase(); | |
| if scheme != "https" && scheme != "http" { | |
| return Err(format!("{scheme}: is not fetched; http and https only")); | |
| } | |
| let rest = rest.split('#').next().unwrap_or(""); | |
| let (authority, path) = match rest.find(['/', '?']) { | |
| Some(i) if rest.as_bytes()[i] == b'/' => (rest[..i].to_string(), rest[i..].to_string()), | |
| Some(i) => (rest[..i].to_string(), format!("/{}", &rest[i..])), | |
| None => (rest.to_string(), "/".to_string()), | |
| }; | |
| if authority.is_empty() { | |
| return Err(format!("{url:?} has no host")); | |
| } | |
| Ok((scheme, authority, path)) | |
| } | |
| /// Whether a status is a redirect the tool follows. | |
| #[must_use] | |
| pub fn is_redirect(status: u16) -> bool { | |
| matches!(status, 301 | 302 | 303 | 307 | 308) | |
| } | |
| /// Where a `Location` points, from the URL that answered with it: absolute, | |
| /// scheme-relative, root-relative, or relative to the answering URL's directory. | |
| #[must_use] | |
| pub fn resolve(base: &str, location: &str) -> String { | |
| let location = location.trim(); | |
| if location.contains("://") { | |
| return location.to_string(); | |
| } | |
| let (scheme, rest) = base.split_once("://").unwrap_or(("https", base)); | |
| if let Some(stripped) = location.strip_prefix("//") { | |
| return format!("{scheme}://{stripped}"); | |
| } | |
| let rest = rest.split(['#', '?']).next().unwrap_or(""); | |
| let (authority, path) = match rest.find('/') { | |
| Some(i) => (&rest[..i], &rest[i..]), | |
| None => (rest, "/"), | |
| }; | |
| if location.starts_with('/') { | |
| return format!("{scheme}://{authority}{location}"); | |
| } | |
| let dir = &path[..=path.rfind('/').unwrap_or(0)]; | |
| format!("{scheme}://{authority}{dir}{location}") | |
| } | |
| /// Elements whose content is never text for a reader. | |
| const SKIPPED: &[&str] = &["script", "style", "noscript", "svg", "template", "head"]; | |
| /// Elements that start a line of their own. | |
| const BLOCKS: &[&str] = &[ | |
| "p", | |
| "div", | |
| "br", | |
| "tr", | |
| "h1", | |
| "h2", | |
| "h3", | |
| "h4", | |
| "h5", | |
| "h6", | |
| "section", | |
| "article", | |
| "pre", | |
| "blockquote", | |
| "table", | |
| "ul", | |
| "ol", | |
| "hr", | |
| "header", | |
| "footer", | |
| "nav", | |
| "main", | |
| "aside", | |
| "dl", | |
| "dt", | |
| "dd", | |
| "figure", | |
| "figcaption", | |
| ]; | |
| /// The tag name in the text between `<` and `>`, lowercased already, and whether it | |
| /// closes. | |
| fn tag_name(tag: &str) -> (bool, &str) { | |
| let (closing, rest) = match tag.strip_prefix('/') { | |
| Some(r) => (true, r), | |
| None => (false, tag), | |
| }; | |
| let end = rest | |
| .find(|c: char| !c.is_ascii_alphanumeric()) | |
| .unwrap_or(rest.len()); | |
| (closing, &rest[..end]) | |
| } | |
| /// The text of a page's `<title>`, decoded and on one line. | |
| fn title_of(html: &str, lower: &str) -> String { | |
| let Some(start) = lower.find("<title") else { | |
| return String::new(); | |
| }; | |
| let Some(open_end) = lower[start..].find('>').map(|e| start + e + 1) else { | |
| return String::new(); | |
| }; | |
| let close = lower[open_end..] | |
| .find("</title") | |
| .map_or(html.len(), |e| open_end + e); | |
| decode_entities(&html[open_end..close]) | |
| .split_whitespace() | |
| .collect::<Vec<_>>() | |
| .join(" ") | |
| } | |
| /// Where scanning resumes after a skipped element that opened at `end` (its `>`). | |
| fn past_closing(lower: &str, end: usize, name: &str) -> usize { | |
| let close = format!("</{name}"); | |
| lower[end..].find(&close).map_or(lower.len(), |p| { | |
| let q = end + p; | |
| lower[q..].find('>').map_or(lower.len(), |g| q + g + 1) | |
| }) | |
| } | |
| /// Whether the text at `i`, which starts with `<`, opens markup rather than being a | |
| /// literal less-than sign. | |
| fn is_markup(html: &str, i: usize) -> bool { | |
| html[i + 1..] | |
| .chars() | |
| .next() | |
| .is_some_and(|c| c.is_ascii_alphabetic() || c == '/' || c == '!' || c == '?') | |
| } | |
| /// A page's title and its text: what a reader would see, line by line. The title | |
| /// apart, scripts, styles and the head are dropped, block elements go on lines of | |
| /// their own, list items as `- `, and entities are decoded. | |
| #[must_use] | |
| pub fn html_to_text(html: &str) -> (String, String) { | |
| let lower = html.to_ascii_lowercase(); | |
| let title = title_of(html, &lower); | |
| let mut out = String::new(); | |
| let mut pre = false; | |
| let mut i = 0; | |
| while i < html.len() { | |
| if html[i..].starts_with("<!--") { | |
| i = lower[i..].find("-->").map_or(html.len(), |e| i + e + 3); | |
| continue; | |
| } | |
| if html[i..].starts_with('<') && is_markup(html, i) { | |
| let Some(end) = html[i..].find('>').map(|e| i + e) else { | |
| break; | |
| }; | |
| let (closing, name) = tag_name(&lower[i + 1..end]); | |
| if !closing && SKIPPED.contains(&name) && !lower[i..end].ends_with('/') { | |
| i = past_closing(&lower, end, name); | |
| continue; | |
| } | |
| if name == "pre" { | |
| pre = !closing; | |
| } | |
| if name == "li" && !closing { | |
| out.push_str("\n- "); | |
| } else if BLOCKS.contains(&name) { | |
| out.push('\n'); | |
| } | |
| i = end + 1; | |
| continue; | |
| } | |
| // a run starts with any character, `¶` or `—` among them: step past it whole | |
| let step = html[i..].chars().next().map_or(1, char::len_utf8); | |
| let next = html[i + step..] | |
| .find('<') | |
| .map_or(html.len(), |p| i + step + p); | |
| let run = &html[i..next]; | |
| if pre { | |
| out.push_str(run); | |
| } else { | |
| out.push_str(&run.replace(['\n', '\r', '\t'], " ")); | |
| } | |
| i = next; | |
| } | |
| (title, tidy(&decode_entities(&out))) | |
| } | |
| /// Spaces collapsed within lines, lines trimmed, at most one blank line in a row. | |
| fn tidy(text: &str) -> String { | |
| let mut out = String::new(); | |
| let mut blank = 0; | |
| for line in text.lines() { | |
| let line = line.split_whitespace().collect::<Vec<_>>().join(" "); | |
| if line.is_empty() { | |
| blank += 1; | |
| if blank == 1 && !out.is_empty() { | |
| out.push('\n'); | |
| } | |
| continue; | |
| } | |
| blank = 0; | |
| out.push_str(&line); | |
| out.push('\n'); | |
| } | |
| out.trim().to_string() | |
| } | |
| /// One entity's text, without its `&` and `;`, if it is one this tool knows. | |
| fn entity(name: &str) -> Option<char> { | |
| match name { | |
| "amp" => Some('&'), | |
| "lt" => Some('<'), | |
| "gt" => Some('>'), | |
| "quot" => Some('"'), | |
| "apos" => Some('\''), | |
| "nbsp" => Some(' '), | |
| _ => { | |
| let num = name.strip_prefix('#')?; | |
| let code = match num.strip_prefix(['x', 'X']) { | |
| Some(hex) => u32::from_str_radix(hex, 16).ok()?, | |
| None => num.parse().ok()?, | |
| }; | |
| char::from_u32(code).map(|c| if c == '\u{a0}' { ' ' } else { c }) | |
| } | |
| } | |
| } | |
| /// Entities decoded: the named few and every numeric one. Anything else is left as | |
| /// written. | |
| fn decode_entities(text: &str) -> String { | |
| let mut out = String::with_capacity(text.len()); | |
| let mut rest = text; | |
| while let Some(amp) = rest.find('&') { | |
| out.push_str(&rest[..amp]); | |
| let after = &rest[amp + 1..]; | |
| let decoded = after | |
| .find(';') | |
| .filter(|&semi| semi > 0 && semi <= 10) | |
| .and_then(|semi| entity(&after[..semi]).map(|c| (c, semi))); | |
| match decoded { | |
| Some((c, semi)) => { | |
| out.push(c); | |
| rest = &after[semi + 1..]; | |
| } | |
| None => { | |
| out.push('&'); | |
| rest = after; | |
| } | |
| } | |
| } | |
| out.push_str(rest); | |
| out | |
| } | |
| /// What a content type's main part says to do with the body. | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| pub enum Kind { | |
| /// HTML: made text, the title apart. | |
| Html, | |
| /// Plain text, JSON or XML: as it is. | |
| Text, | |
| } | |
| /// What to do with a body of this content type; `None` is not text and not answered. | |
| #[must_use] | |
| pub fn kind_of(content_type: &str) -> Option<Kind> { | |
| let main = content_type | |
| .split(';') | |
| .next() | |
| .unwrap_or("") | |
| .trim() | |
| .to_ascii_lowercase(); | |
| match main.as_str() { | |
| "text/html" | "application/xhtml+xml" => Some(Kind::Html), | |
| "application/json" | "application/xml" | "" => Some(Kind::Text), | |
| t if t.starts_with("text/") || t.ends_with("+json") || t.ends_with("+xml") => { | |
| Some(Kind::Text) | |
| } | |
| _ => None, | |
| } | |
| } | |
| /// What came back for a URL, after the redirects: what the answer is shaped from. | |
| #[derive(Clone, Debug)] | |
| pub struct Fetched<'a> { | |
| /// The URL asked for. | |
| pub url: &'a str, | |
| /// The URL that answered, after the redirects. | |
| pub final_url: &'a str, | |
| /// Its status. | |
| pub status: u16, | |
| /// Its `Content-Type`, or empty. | |
| pub content_type: &'a str, | |
| /// Its body, up to the cap. | |
| pub body: &'a [u8], | |
| /// Whether the body was cut at the cap. | |
| pub truncated: bool, | |
| /// The URLs followed to, in order. | |
| pub redirects: &'a [String], | |
| } | |
| /// The tool's answer, JSON, for what came back. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A status of 400 or above, or a body that is not text. | |
| pub fn answer(f: &Fetched<'_>) -> Result<String, String> { | |
| if f.status >= 400 { | |
| return Err(format!("HTTP {} from {}", f.status, f.final_url)); | |
| } | |
| let body = String::from_utf8_lossy(f.body); | |
| let (title, text) = match kind_of(f.content_type) { | |
| Some(Kind::Html) => html_to_text(&body), | |
| Some(Kind::Text) => (String::new(), body.into_owned()), | |
| None => return Err(format!("not text: {} at {}", f.content_type, f.final_url)), | |
| }; | |
| Ok(serde_json::json!({ | |
| "url": f.url, | |
| "final_url": f.final_url, | |
| "status": f.status, | |
| "content_type": f.content_type, | |
| "title": title, | |
| "text": text, | |
| "truncated": f.truncated, | |
| "redirects": f.redirects, | |
| }) | |
| .to_string()) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn a_run_of_text_may_start_with_any_character() { | |
| let (_, text) = html_to_text("<p>¶ Pilcrow</p><p>—dash<b>é</b></p>"); | |
| assert_eq!(text, "¶ Pilcrow\n\n—dashé"); | |
| } | |
| #[test] | |
| fn locations_resolve_against_the_url_that_answered() { | |
| let base = "https://a.example/docs/page.html?x=1"; | |
| assert_eq!(resolve(base, "https://b.example/"), "https://b.example/"); | |
| assert_eq!(resolve(base, "//c.example/x"), "https://c.example/x"); | |
| assert_eq!(resolve(base, "/root"), "https://a.example/root"); | |
| assert_eq!( | |
| resolve(base, "other.html"), | |
| "https://a.example/docs/other.html" | |
| ); | |
| assert_eq!(resolve("https://a.example", "x"), "https://a.example/x"); | |
| assert!(is_redirect(302) && is_redirect(308) && !is_redirect(200) && !is_redirect(404)); | |
| } | |
| #[test] | |
| fn html_becomes_lines_of_text_with_the_title_apart() { | |
| let html = "<html><head><title>T & U</title><style>p{}</style></head>\ | |
| <body><script>evil()</script><h1>Head</h1><p>One <two>!</p>\ | |
| <ul><li>a</li><li>b c</li></ul><!-- gone --><p>x < y</p>\ | |
| <pre>kept\n as is</pre><p>A&unknown;</p></body></html>"; | |
| let (title, text) = html_to_text(html); | |
| assert_eq!(title, "T & U"); | |
| assert_eq!( | |
| text, | |
| "Head\n\nOne <two>!\n\n- a\n- b c\n\nx < y\n\nkept\nas is\n\nA&unknown;" | |
| ); | |
| let (title, text) = html_to_text("<head><title>unclosed"); | |
| assert_eq!((title.as_str(), text.as_str()), ("unclosed", "")); | |
| } | |
| #[test] | |
| fn only_http_and_https_are_split() { | |
| assert!(split("ftp://x/y").is_err()); | |
| assert!(split("file:///etc/passwd").is_err()); | |
| assert!(split("no-scheme").is_err()); | |
| let (scheme, authority, path) = split("HTTPS://h.example?q=1#frag").unwrap(); | |
| assert_eq!( | |
| (scheme.as_str(), authority.as_str(), path.as_str()), | |
| ("https", "h.example", "/?q=1") | |
| ); | |
| let (_, authority, path) = split("http://h.example:8080").unwrap(); | |
| assert_eq!((authority.as_str(), path.as_str()), ("h.example:8080", "/")); | |
| } | |
| #[test] | |
| fn config_and_args_are_json_with_defaults() { | |
| assert_eq!(Config::parse("").unwrap(), Config::default()); | |
| assert_eq!(Config::parse("{}").unwrap().max_bytes, 2_000_000); | |
| assert_eq!( | |
| Config::parse(r#"{"max_bytes": 10}"#).unwrap(), | |
| Config { | |
| max_bytes: 10, | |
| max_redirects: 5 | |
| } | |
| ); | |
| assert!(Config::parse("nope").unwrap_err().starts_with("config:")); | |
| assert_eq!( | |
| Args::parse(r#"{"url": "https://a.example/"}"#).unwrap().url, | |
| "https://a.example/" | |
| ); | |
| assert!(Args::parse(r#"{"url": "ftp://a.example/"}"#).is_err()); | |
| assert!(Args::parse(r#"{"page": 1}"#) | |
| .unwrap_err() | |
| .starts_with("args:")); | |
| } | |
| #[test] | |
| fn the_answer_is_text_or_a_refusal() { | |
| let redirects = vec!["https://a.example/final".to_string()]; | |
| let page = Fetched { | |
| url: "https://a.example/", | |
| final_url: "https://a.example/final", | |
| status: 200, | |
| content_type: "text/html; charset=utf-8", | |
| body: b"<head><title>T</title></head><p>Body</p>", | |
| truncated: true, | |
| redirects: &redirects, | |
| }; | |
| let v: serde_json::Value = serde_json::from_str(&answer(&page).unwrap()).unwrap(); | |
| assert_eq!(v["title"], "T"); | |
| assert_eq!(v["text"], "Body"); | |
| assert_eq!(v["truncated"], true); | |
| assert_eq!(v["redirects"][0], "https://a.example/final"); | |
| let json = Fetched { | |
| content_type: "application/json", | |
| body: br#"{"a": 1}"#, | |
| ..page.clone() | |
| }; | |
| let v: serde_json::Value = serde_json::from_str(&answer(&json).unwrap()).unwrap(); | |
| assert_eq!(v["text"], r#"{"a": 1}"#); | |
| assert_eq!(v["title"], ""); | |
| let image = Fetched { | |
| content_type: "image/png", | |
| ..page.clone() | |
| }; | |
| assert_eq!( | |
| answer(&image).unwrap_err(), | |
| "not text: image/png at https://a.example/final" | |
| ); | |
| let missing = Fetched { | |
| status: 404, | |
| ..page | |
| }; | |
| assert_eq!( | |
| answer(&missing).unwrap_err(), | |
| "HTTP 404 from https://a.example/final" | |
| ); | |
| assert_eq!(kind_of("application/ld+json"), Some(Kind::Text)); | |
| assert_eq!(kind_of("Text/Plain; charset=x"), Some(Kind::Text)); | |
| assert_eq!(kind_of("application/octet-stream"), None); | |
| } | |
| } |