Skip to main content

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}