1#![deny(missing_docs)]
4#![forbid(unsafe_code)]
5
6use std::{
7 error::Error as StdError,
8 fmt,
9 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
10 time::{Duration, SystemTime},
11};
12
13use reqwest::{Client, StatusCode, Url, header};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum ErrorKind {
18 InvalidInput,
20 UnsafeDestination,
22 Timeout,
24 Transport,
26 HttpStatus,
28 UnsupportedContent,
30 EmptyContent,
32}
33
34#[derive(Debug)]
36pub struct Error {
37 kind: ErrorKind,
38 message: String,
39 status: Option<u16>,
40}
41
42impl Error {
43 fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
44 Self {
45 kind,
46 message: message.into(),
47 status: None,
48 }
49 }
50
51 fn with_status(mut self, status: StatusCode) -> Self {
52 self.status = Some(status.as_u16());
53 self
54 }
55
56 pub const fn kind(&self) -> ErrorKind {
58 self.kind
59 }
60
61 pub const fn status(&self) -> Option<u16> {
63 self.status
64 }
65
66 pub fn message(&self) -> &str {
68 &self.message
69 }
70}
71
72impl fmt::Display for Error {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 formatter.write_str(&self.message)
75 }
76}
77
78impl StdError for Error {}
79
80pub type Result<T> = std::result::Result<T, Error>;
82
83#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct FetchLimits {
86 pub request_timeout: Duration,
88 pub max_bytes: usize,
90 pub max_characters: usize,
92 pub max_redirects: usize,
94}
95
96impl Default for FetchLimits {
97 fn default() -> Self {
98 Self {
99 request_timeout: Duration::from_secs(90),
100 max_bytes: 2_000_000,
101 max_characters: 50_000,
102 max_redirects: 5,
103 }
104 }
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct FetchedPage {
110 pub url: String,
112 pub title: Option<String>,
114 pub content_type: String,
116 pub content: String,
118 pub truncated: bool,
120 pub retrieved_at: SystemTime,
122}
123
124#[derive(Clone, Debug)]
126pub struct WebFetcher {
127 limits: FetchLimits,
128}
129
130impl WebFetcher {
131 pub fn new(limits: FetchLimits) -> Result<Self> {
133 if limits.request_timeout.is_zero() || limits.max_bytes == 0 || limits.max_characters == 0 {
134 return Err(Error::new(
135 ErrorKind::InvalidInput,
136 "web-fetch timeout and content limits must be greater than zero",
137 ));
138 }
139 Ok(Self { limits })
140 }
141
142 pub async fn fetch(&self, value: &str) -> Result<FetchedPage> {
144 let requested = parse_public_web_url(value.trim())?;
145 let fetched = fetch_page(&requested, &self.limits).await?;
146 let raw = String::from_utf8_lossy(&fetched.body);
147 let title = is_html_content(&fetched.content_type)
148 .then(|| extract_html_title(&raw))
149 .flatten();
150 let readable = if is_html_content(&fetched.content_type) {
151 html2text::from_read(raw.as_bytes(), 100).map_err(|_| {
152 Error::new(
153 ErrorKind::Transport,
154 "the page could not be converted to readable text",
155 )
156 })?
157 } else {
158 raw.into_owned()
159 };
160 let (content, character_truncated) =
161 truncate_characters(readable.trim(), self.limits.max_characters);
162 if content.is_empty() {
163 return Err(Error::new(
164 ErrorKind::EmptyContent,
165 "the page contained no readable text",
166 ));
167 }
168 Ok(FetchedPage {
169 url: fetched.url.to_string(),
170 title,
171 content_type: fetched.content_type,
172 content,
173 truncated: fetched.truncated || character_truncated,
174 retrieved_at: SystemTime::now(),
175 })
176 }
177}
178
179impl Default for WebFetcher {
180 fn default() -> Self {
181 Self::new(FetchLimits::default()).expect("default web-fetch limits are valid")
182 }
183}
184
185struct RawPage {
186 url: Url,
187 content_type: String,
188 body: Vec<u8>,
189 truncated: bool,
190}
191
192async fn fetch_page(url: &Url, limits: &FetchLimits) -> Result<RawPage> {
193 let mut current = url.clone();
194 for redirect_count in 0..=limits.max_redirects {
195 let client = safe_client(¤t, limits.request_timeout).await?;
196 let mut response = client
197 .get(current.clone())
198 .header(
199 header::ACCEPT,
200 "text/html,application/xhtml+xml,text/plain,application/json;q=0.8",
201 )
202 .send()
203 .await
204 .map_err(transport_error)?;
205 if response.status().is_redirection() {
206 if redirect_count == limits.max_redirects {
207 return Err(Error::new(
208 ErrorKind::Transport,
209 "the page exceeded the redirect limit",
210 ));
211 }
212 let location = response
213 .headers()
214 .get(header::LOCATION)
215 .and_then(|value| value.to_str().ok())
216 .ok_or_else(|| {
217 Error::new(
218 ErrorKind::Transport,
219 "the page returned an invalid redirect",
220 )
221 })?;
222 current = parse_public_web_url(
223 current
224 .join(location)
225 .map_err(|_| {
226 Error::new(
227 ErrorKind::Transport,
228 "the page returned an invalid redirect URL",
229 )
230 })?
231 .as_str(),
232 )?;
233 continue;
234 }
235 if !response.status().is_success() {
236 let status = response.status();
237 return Err(Error::new(
238 ErrorKind::HttpStatus,
239 format!("the page returned HTTP {status}"),
240 )
241 .with_status(status));
242 }
243 let content_type = response
244 .headers()
245 .get(header::CONTENT_TYPE)
246 .and_then(|value| value.to_str().ok())
247 .unwrap_or("application/octet-stream")
248 .split(';')
249 .next()
250 .unwrap_or("application/octet-stream")
251 .trim()
252 .to_ascii_lowercase();
253 if !is_supported_text_content(&content_type) {
254 return Err(Error::new(
255 ErrorKind::UnsupportedContent,
256 format!("the page returned unsupported content type {content_type}"),
257 ));
258 }
259 let mut body = Vec::new();
260 let mut truncated = false;
261 while let Some(chunk) = response.chunk().await.map_err(transport_error)? {
262 let remaining = limits.max_bytes.saturating_sub(body.len());
263 if chunk.len() > remaining {
264 body.extend_from_slice(&chunk[..remaining]);
265 truncated = true;
266 break;
267 }
268 body.extend_from_slice(&chunk);
269 if body.len() == limits.max_bytes {
270 truncated = true;
271 break;
272 }
273 }
274 return Ok(RawPage {
275 url: current,
276 content_type,
277 body,
278 truncated,
279 });
280 }
281 unreachable!("redirect loop returns or continues within its bound")
282}
283
284fn transport_error(error: reqwest::Error) -> Error {
285 if error.is_timeout() {
286 Error::new(ErrorKind::Timeout, "the page fetch timed out")
287 } else {
288 Error::new(ErrorKind::Transport, "the page could not be fetched")
289 }
290}
291
292fn parse_public_web_url(value: &str) -> Result<Url> {
293 if value.is_empty() || value.len() > 4_096 {
294 return Err(Error::new(
295 ErrorKind::InvalidInput,
296 "URL must contain between 1 and 4096 bytes",
297 ));
298 }
299 let url = Url::parse(value)
300 .map_err(|_| Error::new(ErrorKind::InvalidInput, "URL must be absolute"))?;
301 if !matches!(url.scheme(), "http" | "https") {
302 return Err(Error::new(
303 ErrorKind::InvalidInput,
304 "URL must use HTTP or HTTPS",
305 ));
306 }
307 if !url.username().is_empty() || url.password().is_some() {
308 return Err(Error::new(
309 ErrorKind::InvalidInput,
310 "URL must not contain credentials",
311 ));
312 }
313 let host = url
314 .host_str()
315 .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "URL must contain a host"))?;
316 let lookup_host = host.trim_start_matches('[').trim_end_matches(']');
317 let normalized = lookup_host.trim_end_matches('.').to_ascii_lowercase();
318 if normalized == "localhost" || normalized.ends_with(".localhost") {
319 return Err(unsafe_destination());
320 }
321 if !matches!(url.port_or_known_default(), Some(80 | 443)) {
322 return Err(Error::new(
323 ErrorKind::InvalidInput,
324 "URL must use standard HTTP or HTTPS ports",
325 ));
326 }
327 if lookup_host
328 .parse::<IpAddr>()
329 .is_ok_and(|address| !is_public_ip(address))
330 {
331 return Err(unsafe_destination());
332 }
333 Ok(url)
334}
335
336fn unsafe_destination() -> Error {
337 Error::new(
338 ErrorKind::UnsafeDestination,
339 "URL does not refer to a public web destination",
340 )
341}
342
343async fn safe_client(url: &Url, timeout: Duration) -> Result<Client> {
344 let host = url
345 .host_str()
346 .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "URL must contain a host"))?;
347 let lookup_name = host.trim_start_matches('[').trim_end_matches(']');
348 let port = url
349 .port_or_known_default()
350 .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "URL must contain a valid port"))?;
351 let addresses = tokio::net::lookup_host((lookup_name, port))
352 .await
353 .map_err(|_| Error::new(ErrorKind::Transport, "the page host could not be resolved"))?
354 .collect::<Vec<SocketAddr>>();
355 if addresses.is_empty() || addresses.iter().any(|address| !is_public_ip(address.ip())) {
356 return Err(unsafe_destination());
357 }
358 Client::builder()
359 .timeout(timeout)
360 .redirect(reqwest::redirect::Policy::none())
361 .retry(reqwest::retry::never())
362 .referer(false)
363 .no_proxy()
364 .user_agent(concat!("kcode-web-fetch/", env!("CARGO_PKG_VERSION")))
365 .resolve_to_addrs(lookup_name, &addresses)
366 .build()
367 .map_err(|_| {
368 Error::new(
369 ErrorKind::Transport,
370 "the safe page-fetch client could not be created",
371 )
372 })
373}
374
375fn is_public_ip(address: IpAddr) -> bool {
376 match address {
377 IpAddr::V4(address) => is_public_ipv4(address),
378 IpAddr::V6(address) => is_public_ipv6(address),
379 }
380}
381
382fn is_public_ipv4(address: Ipv4Addr) -> bool {
383 let [a, b, c, _] = address.octets();
384 !(a == 0
385 || a == 10
386 || a == 127
387 || (a == 100 && (64..=127).contains(&b))
388 || (a == 169 && b == 254)
389 || (a == 172 && (16..=31).contains(&b))
390 || (a == 192 && b == 0 && c == 0)
391 || (a == 192 && b == 0 && c == 2)
392 || (a == 192 && b == 168)
393 || (a == 198 && (b == 18 || b == 19))
394 || (a == 198 && b == 51 && c == 100)
395 || (a == 203 && b == 0 && c == 113)
396 || a >= 224)
397}
398
399fn is_public_ipv6(address: Ipv6Addr) -> bool {
400 if let Some(mapped) = address.to_ipv4_mapped() {
401 return is_public_ipv4(mapped);
402 }
403 let segments = address.segments();
404 !(address.is_unspecified()
405 || address.is_loopback()
406 || address.is_multicast()
407 || segments[0] & 0xfe00 == 0xfc00
408 || segments[0] & 0xffc0 == 0xfe80
409 || (segments[0] == 0x2001 && segments[1] == 0x0db8))
410}
411
412fn is_html_content(content_type: &str) -> bool {
413 matches!(content_type, "text/html" | "application/xhtml+xml")
414}
415
416fn is_supported_text_content(content_type: &str) -> bool {
417 is_html_content(content_type)
418 || content_type.starts_with("text/")
419 || content_type == "application/json"
420}
421
422fn extract_html_title(html: &str) -> Option<String> {
423 let lowercase = html.to_ascii_lowercase();
424 let start = lowercase.find("<title")?;
425 let content_start = lowercase[start..].find('>')? + start + 1;
426 let end = lowercase[content_start..].find("</title>")? + content_start;
427 let title = html[content_start..end]
428 .split_whitespace()
429 .collect::<Vec<_>>()
430 .join(" ");
431 (!title.is_empty()).then_some(title)
432}
433
434fn truncate_characters(value: &str, limit: usize) -> (String, bool) {
435 let mut iter = value.char_indices();
436 let Some((boundary, _)) = iter.nth(limit) else {
437 return (value.to_owned(), false);
438 };
439 (value[..boundary].trim_end().to_owned(), true)
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn private_and_credentialed_urls_are_rejected() {
448 for value in [
449 "http://127.0.0.1/",
450 "http://[::1]/",
451 "http://localhost/",
452 "https://user:secret@example.com/",
453 "file:///etc/passwd",
454 "https://example.com:8443/",
455 ] {
456 assert!(parse_public_web_url(value).is_err(), "accepted {value}");
457 }
458 assert!(parse_public_web_url("https://example.com/path").is_ok());
459 }
460
461 #[test]
462 fn readable_helpers_are_bounded() {
463 assert_eq!(
464 extract_html_title("<TITLE> Example page </TITLE>"),
465 Some("Example page".into())
466 );
467 assert_eq!(truncate_characters("éclair", 2), ("éc".into(), true));
468 assert_eq!(truncate_characters("short", 20), ("short".into(), false));
469 }
470}