cuttlefish_host/fetch.rs
1//! Downloading a URL into the job's directory.
2//!
3//! A corpus that lives on the web is still a corpus. Without this, every job
4//! reading public data has to be preceded by a hand-written download script
5//! — which is what real users did: 120 lines of Python to pull CMS listings
6//! before cuttlefish saw a single byte. That work is outside the pipeline,
7//! so it gets none of what the pipeline provides: no capability check, no
8//! per-item failure isolation, no resume, no ledger.
9//!
10//! The result is a *file in the job directory*, opened as an ordinary
11//! handle. That is the whole design: a fetched resource is indistinguishable
12//! downstream from a local one, so `slice`, `identify`, `document_text`,
13//! `page_image` and the rest work on it with no further changes.
14
15use std::path::{Path, PathBuf};
16
17/// Largest response this will keep.
18///
19/// A URL is attacker-influenced in a way a local path is not: a spec author
20/// grants a prefix, and what sits behind it can change size without warning.
21/// The cap is checked *while* streaming rather than after, so a response
22/// claiming to be modest and then continuing forever is stopped rather than
23/// discovered once the disk is full.
24const MAX_BYTES: u64 = 256 * 1024 * 1024;
25
26/// How long to wait for the whole response.
27const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
28
29/// Fetch `url` into `job_dir/fetched/`, returning the file's path.
30pub async fn fetch_to_file(url: &str, job_dir: &Path) -> anyhow::Result<PathBuf> {
31 let dir = job_dir.join("fetched");
32 std::fs::create_dir_all(&dir)
33 .map_err(|e| anyhow::anyhow!("creating {}: {e}", dir.display()))?;
34
35 let target = dir.join(file_name_for(url));
36
37 // A URL already fetched by this job is not fetched twice. This matters
38 // more than it looks: a Rhai script is replayed from the top for each
39 // host-call answer, and a fan-out re-runs items after a resume — so
40 // without this, one logical read becomes many requests against somebody
41 // else's server.
42 if target.exists() {
43 return Ok(target);
44 }
45
46 let client = reqwest::Client::builder()
47 .timeout(TIMEOUT)
48 // Named so the other end can tell what is calling and throttle or
49 // block it deliberately rather than guessing.
50 .user_agent(concat!("cuttlefish/", env!("CARGO_PKG_VERSION")))
51 .build()
52 .map_err(|e| anyhow::anyhow!("building the HTTP client: {e}"))?;
53
54 let response = client
55 .get(url)
56 .send()
57 .await
58 .map_err(|e| anyhow::anyhow!("fetching {url}: {e}"))?;
59
60 let status = response.status();
61 if !status.is_success() {
62 // The status is the whole diagnosis for a fetch: 404 means the URL is
63 // wrong, 403 means the grant is fine and the server declined, and
64 // conflating them sends the reader to the wrong place.
65 anyhow::bail!("fetching {url}: server returned {status}");
66 }
67
68 // Streamed rather than `bytes()`, so the ceiling can stop a response
69 // mid-flight instead of after it has already been held in memory.
70 let mut written: u64 = 0;
71 let mut out = std::fs::File::create(&target)
72 .map_err(|e| anyhow::anyhow!("creating {}: {e}", target.display()))?;
73 let mut stream = response;
74
75 loop {
76 let chunk = match stream.chunk().await {
77 Ok(Some(c)) => c,
78 Ok(None) => break,
79 Err(e) => {
80 // A partial file left behind would later read as a cache hit
81 // and be handed downstream as though it were complete.
82 let _ = std::fs::remove_file(&target);
83 anyhow::bail!("reading the response for {url}: {e}");
84 }
85 };
86 written += chunk.len() as u64;
87 if written > MAX_BYTES {
88 let _ = std::fs::remove_file(&target);
89 anyhow::bail!(
90 "{url} exceeded the {MAX_BYTES}-byte fetch ceiling; \
91 it was stopped mid-download rather than filling the disk"
92 );
93 }
94 use std::io::Write as _;
95 out.write_all(&chunk)
96 .map_err(|e| anyhow::anyhow!("writing {}: {e}", target.display()))?;
97 }
98
99 Ok(target)
100}
101
102/// A stable, filesystem-safe name for a URL.
103///
104/// Hashed rather than derived from the path, because two URLs can share a
105/// last path segment and a query string is part of the identity — deriving
106/// from either would collide, and a collision means one fetch silently
107/// serving another's bytes. The suffix is kept when there is one, purely so
108/// a person looking in the job directory can tell a PDF from a page.
109fn file_name_for(url: &str) -> String {
110 use sha2::{Digest, Sha256};
111 let mut hasher = Sha256::new();
112 hasher.update(url.as_bytes());
113 let digest = hasher.finalize();
114 let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
115
116 let extension = url
117 .rsplit('/')
118 .next()
119 .and_then(|last| last.split(['?', '#']).next())
120 .and_then(|last| last.rsplit_once('.'))
121 .map(|(_, ext)| ext)
122 .filter(|ext| ext.len() <= 8 && ext.chars().all(|c| c.is_ascii_alphanumeric()))
123 .unwrap_or("");
124
125 if extension.is_empty() {
126 short
127 } else {
128 format!("{short}.{extension}")
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn a_name_is_stable_and_keeps_a_useful_suffix() {
138 let a = file_name_for("https://x.org/docs/report.pdf");
139 assert_eq!(a, file_name_for("https://x.org/docs/report.pdf"));
140 assert!(a.ends_with(".pdf"), "{a}");
141 }
142
143 #[test]
144 fn urls_sharing_a_last_segment_do_not_collide() {
145 // The failure this guards: two fetches writing the same file, so one
146 // silently serves the other's bytes.
147 let a = file_name_for("https://x.org/2024/data.json");
148 let b = file_name_for("https://x.org/2025/data.json");
149 assert_ne!(a, b);
150 }
151
152 #[test]
153 fn a_query_string_is_part_of_the_identity() {
154 assert_ne!(
155 file_name_for("https://x.org/list?page=1"),
156 file_name_for("https://x.org/list?page=2"),
157 );
158 }
159
160 #[test]
161 fn a_url_with_no_sensible_suffix_still_gets_a_name() {
162 let n = file_name_for("https://x.org/transmittals");
163 assert!(!n.is_empty() && !n.contains('/'), "{n}");
164 // A path segment that is not really an extension must not become one.
165 assert!(!file_name_for("https://x.org/a.verylongextension").contains('.'));
166 }
167}