1use std::error::Error;
2use std::fmt;
3use std::fs;
4use std::io::{self, Read, Write};
5use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
6use std::path::{Path, PathBuf};
7use std::sync::{mpsc, Arc};
8use std::thread;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use htmd::{
12 element_handler::{HandlerResult, Handlers},
13 Element, HtmlToMarkdown,
14};
15use reqwest::blocking::{Client, Response as HttpResponse};
16use reqwest::header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT};
17use reqwest::redirect::Policy;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use url::Url;
21
22use crate::parser::detect_language;
23
24const MAX_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
25const CACHE_TTL_MS: u64 = 24 * 60 * 60 * 1000;
26const CONNECT_TIMEOUT: Duration = Duration::from_millis(30_000);
27const BODY_CHUNK_TIMEOUT: Duration = Duration::from_millis(15_000);
28const MAX_REDIRECTS: usize = 5;
29
30const TRANSIENT_RETRY_ATTEMPTS: usize = 2;
41const TRANSIENT_RETRY_BACKOFFS_MS: [u64; TRANSIENT_RETRY_ATTEMPTS] = [200, 600];
42const ACCEPT_HEADER: &str = "application/vnd.github.raw, text/markdown, text/x-markdown, text/html;q=0.9, application/json;q=0.8, text/plain;q=0.5";
43const USER_AGENT_VALUE: &str = "aft-opencode-plugin";
44const CONVERTED_MARKDOWN_CONTENT_TYPE: &str = "text/markdown; charset=utf-8";
45
46#[derive(Clone, Default)]
47pub struct UrlFetchOptions {
48 pub allow_private: bool,
49 #[doc(hidden)]
52 pub public_host_overrides: Vec<(String, Vec<IpAddr>)>,
53 #[doc(hidden)]
56 pub connect_overrides: Vec<(String, SocketAddr)>,
57 #[doc(hidden)]
59 pub atomic_write_observer: Option<Arc<dyn Fn(&Path, &Path) + Send + Sync>>,
60}
61
62#[derive(Debug, Clone)]
63pub struct UrlFetchError {
64 message: String,
65}
66
67impl UrlFetchError {
68 fn new(message: impl Into<String>) -> Self {
69 Self {
70 message: message.into(),
71 }
72 }
73}
74
75impl fmt::Display for UrlFetchError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 f.write_str(&self.message)
78 }
79}
80
81impl std::error::Error for UrlFetchError {}
82
83#[derive(Debug, Serialize, Deserialize)]
84struct CacheMeta {
85 url: String,
86 #[serde(rename = "contentType")]
87 content_type: String,
88 extension: String,
89 #[serde(rename = "fetchedAt")]
90 fetched_at: u64,
91}
92
93pub fn is_http_url(value: &str) -> bool {
94 value.starts_with("http://") || value.starts_with("https://")
95}
96
97pub fn fetch_url_to_cache(
98 url: &str,
99 storage_dir: &Path,
100 options: UrlFetchOptions,
101) -> Result<PathBuf, UrlFetchError> {
102 let parsed = Url::parse(url).map_err(|_| UrlFetchError::new(format!("Invalid URL: {url}")))?;
103 let fetch_url = rewrite_url_for_fetch(&parsed);
104 validate_public_url(&fetch_url, &options)?;
105
106 let dir = cache_dir(storage_dir);
107 fs::create_dir_all(&dir).map_err(|error| {
108 UrlFetchError::new(format!(
109 "Failed to create URL cache directory {}: {error}",
110 dir.display()
111 ))
112 })?;
113
114 let hash = hash_url(url);
115 let meta_file = meta_path(storage_dir, &hash);
116 if let Some(cached) = fresh_cached_path(storage_dir, &hash, &meta_file, &fetch_url)? {
117 return Ok(cached);
118 }
119
120 let response = fetch_with_redirects(&fetch_url, url, &options)?;
121 if !response.status().is_success() {
122 return Err(UrlFetchError::new(format!(
123 "HTTP {} {} fetching {url}",
124 response.status().as_u16(),
125 response.status().canonical_reason().unwrap_or("")
126 )));
127 }
128
129 let content_type = response
130 .headers()
131 .get(CONTENT_TYPE)
132 .and_then(|value| value.to_str().ok())
133 .unwrap_or("text/plain")
134 .to_string();
135 let (extension, from_source_path) =
136 resolve_fetch_extension(&fetch_url, &content_type).ok_or_else(|| {
137 UrlFetchError::new(format!(
138 "Unsupported content type '{content_type}' for {url}. Supported: text/html, text/markdown, application/json, text/plain; source files via URL path extension (e.g. .rs, .ts, .mjs)"
139 ))
140 })?;
141
142 if let Some(length) = response.content_length() {
143 if length > MAX_RESPONSE_BYTES {
144 return Err(UrlFetchError::new(format!(
145 "Response too large: {length} bytes (max {MAX_RESPONSE_BYTES})"
146 )));
147 }
148 }
149
150 let body = read_response_body(response, url)?;
151 if from_source_path && body_contains_nul_in_prefix(&body) {
152 return Err(UrlFetchError::new(format!(
153 "Binary content detected for source URL {url}"
154 )));
155 }
156 let (body, content_type, extension) = if extension == ".html" {
157 (
158 convert_html_body_to_markdown(&body, url)?,
159 CONVERTED_MARKDOWN_CONTENT_TYPE.to_string(),
160 ".md",
161 )
162 } else {
163 (body, content_type, extension)
164 };
165
166 let content_file = content_path(storage_dir, &hash, extension);
167 atomic_write(&content_file, &body, &options)?;
168
169 let meta = CacheMeta {
170 url: url.to_string(),
171 content_type,
172 extension: extension.to_string(),
173 fetched_at: now_ms(),
174 };
175 let meta_bytes = serde_json::to_vec(&meta).map_err(|error| {
176 UrlFetchError::new(format!("Failed to encode URL cache metadata: {error}"))
177 })?;
178 atomic_write(&meta_file, &meta_bytes, &options)?;
179
180 Ok(content_file)
181}
182
183pub fn cleanup_url_cache(storage_dir: &Path) -> Result<usize, UrlFetchError> {
184 let dir = cache_dir(storage_dir);
185 if !dir.exists() {
186 return Ok(0);
187 }
188
189 let entries = fs::read_dir(&dir).map_err(|error| {
190 UrlFetchError::new(format!(
191 "URL cache cleanup failed reading {}: {error}",
192 dir.display()
193 ))
194 })?;
195 let mut removed = 0usize;
196 let now = now_ms();
197
198 for entry in entries.flatten() {
199 let path = entry.path();
200 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
201 continue;
202 };
203 if !name.ends_with(".meta.json") {
204 continue;
205 }
206
207 let meta = fs::read_to_string(&path)
208 .ok()
209 .and_then(|content| serde_json::from_str::<CacheMeta>(&content).ok());
210 let Some(meta) = meta else {
211 if fs::remove_file(&path).is_ok() {
212 removed += 1;
213 }
214 continue;
215 };
216
217 if now.saturating_sub(meta.fetched_at) <= CACHE_TTL_MS {
218 continue;
219 }
220
221 let hash = name.trim_end_matches(".meta.json");
222 let content = content_path(storage_dir, hash, &meta.extension);
223 let _ = fs::remove_file(content);
224 if fs::remove_file(&path).is_ok() {
225 removed += 1;
226 }
227 }
228
229 Ok(removed)
230}
231
232#[doc(hidden)]
233pub fn cache_content_path_for_url(storage_dir: &Path, url: &str, extension: &str) -> PathBuf {
234 content_path(storage_dir, &hash_url(url), extension)
235}
236
237#[doc(hidden)]
238pub fn cache_meta_path_for_url(storage_dir: &Path, url: &str) -> PathBuf {
239 meta_path(storage_dir, &hash_url(url))
240}
241
242#[doc(hidden)]
243pub fn is_private_ip_for_test(ip: IpAddr) -> bool {
244 is_private_ip(ip)
245}
246
247fn cache_dir(storage_dir: &Path) -> PathBuf {
248 storage_dir.join("url_cache")
249}
250
251fn hash_url(url: &str) -> String {
252 let digest = Sha256::digest(url.as_bytes());
253 format!("{digest:x}").chars().take(16).collect()
254}
255
256fn rewrite_url_for_fetch(url: &Url) -> Url {
259 let original = url.clone();
260 let mut rewritten = url.clone();
261 rewritten.set_fragment(None);
262
263 let Some(host) = url.host_str() else {
264 return rewritten;
265 };
266 if !matches!(url.scheme(), "http" | "https") {
267 return rewritten;
268 }
269
270 let segments: Vec<&str> = url
271 .path_segments()
272 .map(|segments| segments.collect())
273 .unwrap_or_default();
274 let (raw_host, raw_path) = if matches!(host, "github.com" | "www.github.com") {
275 if segments.len() >= 5 && matches!(segments[2], "blob" | "raw") {
276 (
277 "raw.githubusercontent.com",
278 format!(
279 "/{}/{}/{}/{}",
280 segments[0],
281 segments[1],
282 segments[3],
283 segments[4..].join("/")
284 ),
285 )
286 } else {
287 return rewritten;
288 }
289 } else if host == "gitlab.com"
290 && segments.len() >= 6
291 && segments[2] == "-"
292 && segments[3] == "blob"
293 {
294 (
295 "gitlab.com",
296 format!(
297 "/{}/{}/-/raw/{}/{}",
298 segments[0],
299 segments[1],
300 segments[4],
301 segments[5..].join("/")
302 ),
303 )
304 } else {
305 return rewritten;
306 };
307
308 rewritten
309 .set_host(Some(raw_host))
310 .expect("raw content host should be a valid URL host");
311 rewritten.set_path(&raw_path);
312 log::debug!(
313 "url_fetch: rewriting web-view URL to raw content: {} -> {}",
314 original,
315 rewritten
316 );
317 rewritten
318}
319
320fn meta_path(storage_dir: &Path, hash: &str) -> PathBuf {
321 cache_dir(storage_dir).join(format!("{hash}.meta.json"))
322}
323
324fn content_path(storage_dir: &Path, hash: &str, extension: &str) -> PathBuf {
325 cache_dir(storage_dir).join(format!("{hash}{extension}"))
326}
327
328fn fresh_cached_path(
329 storage_dir: &Path,
330 hash: &str,
331 meta_file: &Path,
332 url: &Url,
333) -> Result<Option<PathBuf>, UrlFetchError> {
334 if !meta_file.exists() {
335 return Ok(None);
336 }
337
338 let meta = match fs::read_to_string(meta_file)
339 .ok()
340 .and_then(|content| serde_json::from_str::<CacheMeta>(&content).ok())
341 {
342 Some(meta) => meta,
343 None => return Ok(None),
344 };
345 let age = now_ms().saturating_sub(meta.fetched_at);
346 if meta.extension == ".html" {
347 return Ok(None);
348 }
349
350 let content_type = meta.content_type.as_str();
351 let current = resolve_fetch_extension(url, content_type);
352 let expected_ext = current.map(|(ext, _)| ext);
353 if expected_ext != Some(meta.extension.as_str()) {
354 return Ok(None);
355 }
356
357 let cached = content_path(storage_dir, hash, &meta.extension);
358 if age < CACHE_TTL_MS && cached.exists() {
359 return Ok(Some(cached));
360 }
361 Ok(None)
362}
363
364fn fetch_with_redirects(
365 start_url: &Url,
366 original_url: &str,
367 options: &UrlFetchOptions,
368) -> Result<HttpResponse, UrlFetchError> {
369 let client = build_client(options)?;
370 let mut current_url = start_url.clone();
371
372 for redirect_count in 0..=MAX_REDIRECTS {
373 validate_public_url(¤t_url, options)?;
374 let response = send_with_transient_retries(&client, ¤t_url)?;
375
376 if !response.status().is_redirection() {
377 return Ok(response);
378 }
379 if redirect_count == MAX_REDIRECTS {
380 return Err(UrlFetchError::new(format!(
381 "Too many redirects fetching {original_url}"
382 )));
383 }
384
385 let location = response
386 .headers()
387 .get(LOCATION)
388 .and_then(|value| value.to_str().ok())
389 .ok_or_else(|| {
390 UrlFetchError::new(format!(
391 "Redirect from {} missing Location header",
392 current_url.as_str()
393 ))
394 })?;
395 current_url = current_url.join(location).map_err(|error| {
396 UrlFetchError::new(format!(
397 "Invalid redirect Location '{location}' from {}: {error}",
398 current_url.as_str()
399 ))
400 })?;
401 }
402
403 Err(UrlFetchError::new(format!(
404 "Too many redirects fetching {original_url}"
405 )))
406}
407
408fn send_with_transient_retries(
417 client: &Client,
418 target: &Url,
419) -> Result<HttpResponse, UrlFetchError> {
420 let mut last_error: Option<reqwest::Error> = None;
421 for attempt in 0..=TRANSIENT_RETRY_ATTEMPTS {
422 let result = client
423 .get(target.clone())
424 .header(USER_AGENT, USER_AGENT_VALUE)
425 .header(ACCEPT, ACCEPT_HEADER)
426 .send();
427 match result {
428 Ok(response) => return Ok(response),
429 Err(error) => {
430 if attempt < TRANSIENT_RETRY_ATTEMPTS && is_transient_reqwest_error(&error) {
431 thread::sleep(Duration::from_millis(TRANSIENT_RETRY_BACKOFFS_MS[attempt]));
432 last_error = Some(error);
433 continue;
434 }
435 return Err(UrlFetchError::new(format!(
436 "Failed to fetch {}: {}",
437 target.as_str(),
438 reqwest_error_detail(&error)
439 )));
440 }
441 }
442 }
443 Err(UrlFetchError::new(format!(
446 "Failed to fetch {} after {} retries: {}",
447 target.as_str(),
448 TRANSIENT_RETRY_ATTEMPTS,
449 last_error
450 .as_ref()
451 .map(reqwest_error_detail)
452 .unwrap_or_else(|| "unknown transient error".to_string())
453 )))
454}
455
456fn is_transient_reqwest_error(error: &reqwest::Error) -> bool {
466 error.is_connect() || error.is_timeout() || error.is_request()
467}
468
469fn build_client(options: &UrlFetchOptions) -> Result<Client, UrlFetchError> {
470 let mut builder = Client::builder()
471 .redirect(Policy::none())
472 .connect_timeout(CONNECT_TIMEOUT);
473
474 for (host, address) in &options.connect_overrides {
475 builder = builder.resolve(host, *address);
476 }
477
478 let tls_config = crate::platform_tls::client_config().map_err(|error| {
479 UrlFetchError::new(format!("Failed to configure URL fetch TLS: {error}"))
480 })?;
481
482 builder
483 .use_preconfigured_tls(tls_config)
484 .build()
485 .map_err(|error| UrlFetchError::new(format!("Failed to build URL fetch client: {error}")))
486}
487
488fn validate_public_url(url: &Url, options: &UrlFetchOptions) -> Result<(), UrlFetchError> {
489 if url.scheme() != "http" && url.scheme() != "https" {
490 return Err(UrlFetchError::new(format!(
491 "Only http:// and https:// URLs are supported, got: {}:",
492 url.scheme()
493 )));
494 }
495 if options.allow_private {
496 return Ok(());
497 }
498
499 let host = url
500 .host_str()
501 .ok_or_else(|| UrlFetchError::new(format!("URL missing host: {url}")))?;
502 let host_for_parse = host
503 .trim_matches(['[', ']'])
504 .split('%')
505 .next()
506 .unwrap_or(host);
507
508 if let Ok(ip) = host_for_parse.parse::<IpAddr>() {
509 reject_private_ip(host, ip)?;
510 return Ok(());
511 }
512 if host_for_parse.contains(':') {
513 return Err(UrlFetchError::new(format!(
514 "Blocked private URL host {host} ({host_for_parse})"
515 )));
516 }
517
518 let addresses = resolve_host_ips(host_for_parse, url.port_or_known_default(), options)?;
519 if addresses.is_empty() {
520 return Err(UrlFetchError::new(format!(
521 "Failed to resolve URL host {host}"
522 )));
523 }
524 for ip in addresses {
525 reject_private_ip(host, ip)?;
526 }
527
528 Ok(())
534}
535
536fn resolve_host_ips(
537 host: &str,
538 port: Option<u16>,
539 options: &UrlFetchOptions,
540) -> Result<Vec<IpAddr>, UrlFetchError> {
541 if let Some((_, ips)) = options
542 .public_host_overrides
543 .iter()
544 .find(|(override_host, _)| override_host == host)
545 {
546 return Ok(ips.clone());
547 }
548
549 let port = port.unwrap_or(80);
550 let addrs = (host, port).to_socket_addrs().map_err(|error| {
551 UrlFetchError::new(format!("Failed to resolve URL host {host}: {error}"))
552 })?;
553 Ok(addrs.map(|addr| addr.ip()).collect())
554}
555
556fn reject_private_ip(host: &str, ip: IpAddr) -> Result<(), UrlFetchError> {
557 if is_private_ip(ip) {
558 return Err(UrlFetchError::new(format!(
559 "Blocked private URL host {host} ({ip})"
560 )));
561 }
562 Ok(())
563}
564
565pub fn is_private_or_reserved_ip(ip: IpAddr) -> bool {
571 is_private_ip(ip)
572}
573
574fn is_private_ip(ip: IpAddr) -> bool {
575 match ip {
576 IpAddr::V4(ipv4) => is_private_ipv4(ipv4),
577 IpAddr::V6(ipv6) => is_private_ipv6(ipv6),
578 }
579}
580
581fn is_private_ipv4(ip: Ipv4Addr) -> bool {
582 let [a, b, _, _] = ip.octets();
583 a == 0
584 || a == 10
585 || a == 127
586 || (a == 172 && (16..=31).contains(&b))
587 || (a == 192 && b == 168)
588 || (a == 169 && b == 254)
589 || (a == 100 && (64..=127).contains(&b))
593 || (a == 198 && (18..=19).contains(&b))
595 || a >= 224
596}
597
598fn is_private_ipv6(ip: Ipv6Addr) -> bool {
599 let segments = ip.segments();
600 let top_six_zero = segments[..6].iter().all(|segment| *segment == 0);
601 let is_mapped = segments[..5].iter().all(|segment| *segment == 0) && segments[5] == 0xffff;
602 if is_mapped || top_six_zero {
603 let embedded = Ipv4Addr::new(
604 (segments[6] >> 8) as u8,
605 (segments[6] & 0xff) as u8,
606 (segments[7] >> 8) as u8,
607 (segments[7] & 0xff) as u8,
608 );
609 return is_private_ipv4(embedded);
610 }
611
612 let first = segments[0];
613 (0xfe80..=0xfebf).contains(&first) || (0xfc00..=0xfdff).contains(&first) || first >= 0xff00
614}
615
616const BINARY_SNIFF_PREFIX: usize = 8 * 1024;
617
618fn body_contains_nul_in_prefix(body: &[u8]) -> bool {
619 let end = body.len().min(BINARY_SNIFF_PREFIX);
620 body[..end].contains(&0)
621}
622
623fn resolve_fetch_extension(url: &Url, content_type: &str) -> Option<(&'static str, bool)> {
626 if let Some(ext) = extension_from_url_path(url) {
627 return Some((ext, true));
628 }
629 resolve_extension_from_content_type(content_type).map(|ext| (ext, false))
630}
631
632fn extension_from_url_path(url: &Url) -> Option<&'static str> {
633 let path = url.path();
634 if path.is_empty() || path == "/" {
635 return None;
636 }
637 let segment = path.rsplit('/').next().unwrap_or(path);
638 let file_name = percent_decode_path_segment(segment);
639 let dot = file_name.rfind('.')?;
640 let ext = &file_name[dot + 1..];
641 if ext.is_empty() {
642 return None;
643 }
644 let probe = Path::new("file").with_extension(ext);
645 if detect_language(&probe).is_some() {
646 static_extension_for_lang_ext(ext)
647 } else {
648 None
649 }
650}
651
652fn percent_decode_path_segment(segment: &str) -> String {
653 let mut out = String::with_capacity(segment.len());
654 let bytes = segment.as_bytes();
655 let mut i = 0;
656 while i < bytes.len() {
657 if bytes[i] == b'%' && i + 2 < bytes.len() {
658 if let (Some(h1), Some(h2)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) {
659 out.push(char::from(h1 << 4 | h2));
660 i += 3;
661 continue;
662 }
663 }
664 out.push(bytes[i] as char);
665 i += 1;
666 }
667 out
668}
669
670fn from_hex(byte: u8) -> Option<u8> {
671 match byte {
672 b'0'..=b'9' => Some(byte - b'0'),
673 b'a'..=b'f' => Some(byte - b'a' + 10),
674 b'A'..=b'F' => Some(byte - b'A' + 10),
675 _ => None,
676 }
677}
678
679fn static_extension_for_lang_ext(ext: &str) -> Option<&'static str> {
681 match ext.to_ascii_lowercase().as_str() {
682 "ts" | "mts" | "cts" => Some(".ts"),
683 "tsx" => Some(".tsx"),
684 "js" => Some(".js"),
685 "jsx" => Some(".jsx"),
686 "mjs" => Some(".mjs"),
687 "cjs" => Some(".cjs"),
688 "py" | "pyi" => Some(".py"),
689 "rs" => Some(".rs"),
690 "go" => Some(".go"),
691 "c" | "h" => Some(".c"),
692 "cc" | "cpp" | "cxx" | "hpp" | "hh" => Some(".cpp"),
693 "zig" => Some(".zig"),
694 "cs" => Some(".cs"),
695 "sh" | "bash" | "zsh" => Some(".sh"),
696 "html" | "htm" => Some(".html"),
697 "md" | "markdown" | "mdx" => Some(".md"),
698 "sol" => Some(".sol"),
699 "scss" => Some(".scss"),
700 "vue" => Some(".vue"),
701 "json" | "jsonc" => Some(".json"),
702 "scala" | "sc" => Some(".scala"),
703 "java" => Some(".java"),
704 "rb" => Some(".rb"),
705 "kt" | "kts" => Some(".kt"),
706 "swift" => Some(".swift"),
707 "inc" | "php" => Some(".php"),
708 "lua" => Some(".lua"),
709 "pl" | "pm" | "t" => Some(".pl"),
710 "yaml" | "yml" => Some(".yaml"),
711 _ => None,
712 }
713}
714
715fn resolve_extension_from_content_type(content_type: &str) -> Option<&'static str> {
716 let lower = content_type.to_ascii_lowercase();
717 let media_type = lower
718 .split(';')
719 .next()
720 .unwrap_or("")
721 .split(',')
722 .next()
723 .unwrap_or("")
724 .trim();
725
726 match media_type {
727 "text/html"
728 | "application/xhtml+xml"
729 | "application/vnd.github.html"
730 | "application/vnd.github+html" => Some(".html"),
731 "text/markdown"
732 | "text/x-markdown"
733 | "application/markdown"
734 | "application/vnd.github.raw"
735 | "application/vnd.github+raw"
736 | "application/vnd.github.v3.raw"
737 | "text/plain" => Some(".md"),
738 "application/json" | "application/ld+json" => Some(".json"),
739 other if other.ends_with("+json") => Some(".json"),
740 "text/javascript" | "application/javascript" | "application/ecmascript" => Some(".js"),
741 "text/typescript" | "application/typescript" => Some(".ts"),
742 _ => None,
743 }
744}
745
746fn convert_html_body_to_markdown(body: &[u8], url: &str) -> Result<Vec<u8>, UrlFetchError> {
747 let html = String::from_utf8_lossy(body);
748 let mut markdown = html_to_markdown_converter()
749 .convert(&html)
750 .map_err(|error| {
751 UrlFetchError::new(format!(
752 "Failed to convert HTML from {url} to Markdown: {error}"
753 ))
754 })?;
755 if !markdown.ends_with('\n') {
756 markdown.push('\n');
757 }
758 Ok(markdown.into_bytes())
759}
760
761fn html_to_markdown_converter() -> HtmlToMarkdown {
762 HtmlToMarkdown::builder()
763 .skip_tags(vec![
764 "head", "script", "style", "nav", "footer", "aside", "noscript", "button",
765 ])
766 .add_handler(
767 vec!["a"],
768 |handlers: &dyn Handlers, element: Element| -> Option<HandlerResult> {
769 if is_permalink_anchor(&element)
770 || handlers.walk_children(element.node).content.trim() == "§"
771 {
772 None
773 } else if element_attr_value(&element, "href")
774 .is_some_and(|href| href.trim() == "#")
775 {
776 Some(handlers.walk_children(element.node).content.into())
777 } else {
778 handlers.fallback(element)
779 }
780 },
781 )
782 .add_handler(
783 vec!["header"],
784 |handlers: &dyn Handlers, element: Element| -> Option<HandlerResult> {
785 if should_skip_header(&element) {
786 None
787 } else {
788 handlers.fallback(element)
789 }
790 },
791 )
792 .add_handler(
793 vec!["span"],
794 |handlers: &dyn Handlers, element: Element| -> Option<HandlerResult> {
795 if element_has_class_token(&element, "token-line") {
796 let mut content = handlers.walk_children(element.node).content;
797 content.push('\n');
798 Some(content.into())
799 } else {
800 handlers.fallback(element)
801 }
802 },
803 )
804 .build()
805}
806
807fn is_permalink_anchor(element: &Element<'_>) -> bool {
808 element_has_class_token(element, "hash-link")
809 || element_has_class_token(element, "doc-anchor")
810 || element_attr_value(element, "aria-label")
811 .is_some_and(|value| value.to_ascii_lowercase().starts_with("direct link to"))
812}
813
814fn should_skip_header(element: &Element<'_>) -> bool {
815 element_has_class_token(element, "navbar")
816 || element_has_class_token(element, "site-header")
817 || element_has_class_token(element, "site-nav")
818 || element_has_class_token(element, "topbar")
819 || element_attr_value(element, "role")
820 .is_some_and(|value| value.eq_ignore_ascii_case("banner"))
821 || element_attr_value(element, "id").is_some_and(|value| {
822 let value = value.to_ascii_lowercase();
823 value.contains("navbar") || value.contains("site-header") || value.contains("site-nav")
824 })
825}
826
827fn element_has_class_token(element: &Element<'_>, token: &str) -> bool {
828 element_attr_value(element, "class")
829 .is_some_and(|value| value.split_ascii_whitespace().any(|class| class == token))
830}
831
832fn element_attr_value<'a>(element: &'a Element<'_>, name: &str) -> Option<&'a str> {
833 element
834 .attrs
835 .iter()
836 .find(|attr| attr.name.local.as_ref() == name)
837 .map(|attr| attr.value.as_ref())
838}
839
840enum BodyReadEvent {
841 Chunk(Vec<u8>),
842 Done,
843 Error(io::ErrorKind, String),
844}
845
846fn read_response_body(mut response: HttpResponse, url: &str) -> Result<Vec<u8>, UrlFetchError> {
847 let (tx, rx) = mpsc::channel();
848 thread::spawn(move || {
849 let mut buffer = [0u8; 16 * 1024];
850 loop {
851 match response.read(&mut buffer) {
852 Ok(0) => {
853 let _ = tx.send(BodyReadEvent::Done);
854 break;
855 }
856 Ok(n) => {
857 if tx.send(BodyReadEvent::Chunk(buffer[..n].to_vec())).is_err() {
858 break;
859 }
860 }
861 Err(error) => {
862 let kind = error.kind();
863 let message = error.to_string();
864 let _ = tx.send(BodyReadEvent::Error(kind, message));
865 break;
866 }
867 }
868 }
869 });
870
871 let mut chunks = Vec::new();
872 let mut total = 0u64;
873 loop {
874 match rx.recv_timeout(BODY_CHUNK_TIMEOUT) {
875 Ok(BodyReadEvent::Chunk(chunk)) => {
876 total += chunk.len() as u64;
877 if total > MAX_RESPONSE_BYTES {
878 return Err(UrlFetchError::new(format!(
879 "Response exceeded {MAX_RESPONSE_BYTES} bytes, aborted"
880 )));
881 }
882 chunks.extend_from_slice(&chunk);
883 }
884 Ok(BodyReadEvent::Done) => return Ok(chunks),
885 Ok(BodyReadEvent::Error(kind, _message)) if is_body_stall_kind(kind) => {
886 return Err(body_stall_error(url));
887 }
888 Ok(BodyReadEvent::Error(_, message)) => {
889 return Err(UrlFetchError::new(format!(
890 "Failed to read response body for {url}: {message}"
891 )));
892 }
893 Err(mpsc::RecvTimeoutError::Timeout) => return Err(body_stall_error(url)),
894 Err(mpsc::RecvTimeoutError::Disconnected) => {
895 return Err(UrlFetchError::new(format!(
896 "Failed to read response body for {url}: body reader stopped unexpectedly"
897 )));
898 }
899 }
900 }
901}
902
903fn body_stall_error(url: &str) -> UrlFetchError {
904 UrlFetchError::new(format!(
905 "Body read stalled (no data for {}ms) fetching {url}",
906 BODY_CHUNK_TIMEOUT.as_millis()
907 ))
908}
909
910fn is_body_stall_kind(kind: io::ErrorKind) -> bool {
911 matches!(kind, io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock)
912}
913
914fn atomic_write(
915 final_path: &Path,
916 bytes: &[u8],
917 options: &UrlFetchOptions,
918) -> Result<(), UrlFetchError> {
919 let parent = final_path.parent().unwrap_or_else(|| Path::new("."));
920 fs::create_dir_all(parent).map_err(|error| {
921 UrlFetchError::new(format!(
922 "Failed to create URL cache parent {}: {error}",
923 parent.display()
924 ))
925 })?;
926
927 let file_name = final_path
928 .file_name()
929 .and_then(|name| name.to_str())
930 .ok_or_else(|| {
931 UrlFetchError::new(format!("Invalid cache path: {}", final_path.display()))
932 })?;
933 let tmp_path = final_path.with_file_name(format!(
934 "{file_name}.tmp-{}-{}",
935 std::process::id(),
936 random_nonce()
937 ));
938
939 let write_result = (|| -> io::Result<()> {
940 let mut file = fs::File::create(&tmp_path)?;
941 file.write_all(bytes)?;
942 file.flush()?;
943 Ok(())
944 })();
945 if let Err(error) = write_result {
946 let _ = fs::remove_file(&tmp_path);
947 return Err(UrlFetchError::new(format!(
948 "Failed to write URL cache temp file {}: {error}",
949 tmp_path.display()
950 )));
951 }
952
953 if let Some(observer) = &options.atomic_write_observer {
954 observer(&tmp_path, final_path);
955 }
956
957 fs::rename(&tmp_path, final_path).map_err(|error| {
958 let _ = fs::remove_file(&tmp_path);
959 UrlFetchError::new(format!(
960 "Failed to finalize URL cache file {}: {error}",
961 final_path.display()
962 ))
963 })
964}
965
966fn random_nonce() -> String {
967 let mut bytes = [0u8; 8];
968 if getrandom::fill(&mut bytes).is_err() {
969 let fallback = now_ms() ^ u64::from(std::process::id());
970 bytes = fallback.to_le_bytes();
971 }
972 let mut out = String::with_capacity(bytes.len() * 2);
973 for byte in bytes {
974 use std::fmt::Write as _;
975 let _ = write!(out, "{byte:02x}");
976 }
977 out
978}
979
980fn now_ms() -> u64 {
981 SystemTime::now()
982 .duration_since(UNIX_EPOCH)
983 .unwrap_or_default()
984 .as_millis()
985 .try_into()
986 .unwrap_or(u64::MAX)
987}
988
989fn reqwest_error_detail(error: &reqwest::Error) -> String {
990 if error.is_timeout() {
991 return format!("timeout: {error}");
992 }
993 if let Some(source) = error.source() {
994 return format!("{source}");
995 }
996 error.to_string()
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use url::Url;
1003
1004 #[test]
1005 fn web_view_urls_rewrite_to_raw_content() {
1006 let cases = [
1007 (
1008 "https://github.com/owner/repo/blob/main/docs/guide.md?download=1#L42",
1009 "https://raw.githubusercontent.com/owner/repo/main/docs/guide.md?download=1",
1010 ),
1011 (
1012 "https://github.com/owner/repo/raw/v1.2/src/lib.rs?raw=1#L7",
1013 "https://raw.githubusercontent.com/owner/repo/v1.2/src/lib.rs?raw=1",
1014 ),
1015 (
1016 "https://www.github.com/owner/repo/blob/main/README.md",
1017 "https://raw.githubusercontent.com/owner/repo/main/README.md",
1018 ),
1019 (
1020 "https://gitlab.com/owner/repo/-/blob/main/docs/guide.md?plain=1#L4",
1021 "https://gitlab.com/owner/repo/-/raw/main/docs/guide.md?plain=1",
1022 ),
1023 ];
1024
1025 for (input, expected) in cases {
1026 let url = Url::parse(input).unwrap();
1027 assert_eq!(rewrite_url_for_fetch(&url).as_str(), expected);
1028 }
1029 }
1030
1031 #[test]
1032 fn non_web_view_urls_are_not_rewritten() {
1033 for input in [
1034 "https://gist.github.com/owner/abcdef/blob/main/file.md",
1035 "https://github.com/owner/repo",
1036 "https://github.com/owner/repo/blob",
1037 "https://api.github.com/repos/owner/repo",
1038 "https://notgithub.com/owner/repo/blob/main/file.md",
1039 "https://gitlab.example.com/owner/repo/-/blob/main/file.md",
1040 ] {
1041 let url = Url::parse(input).unwrap();
1042 assert_eq!(rewrite_url_for_fetch(&url).as_str(), input);
1043 }
1044 }
1045
1046 #[test]
1047 fn extension_from_path_uses_parser_mapping() {
1048 let url = Url::parse("https://example.com/pkg/index.mjs").unwrap();
1049 let (ext, from_path) = resolve_fetch_extension(&url, "text/javascript").unwrap();
1050 assert_eq!(ext, ".mjs");
1051 assert!(from_path);
1052 }
1053
1054 #[test]
1055 fn text_plain_rs_url_ignores_content_type_gate() {
1056 let url = Url::parse("https://raw.githubusercontent.com/o/r/main/lib.rs").unwrap();
1057 let (ext, from_path) = resolve_fetch_extension(&url, "text/plain").unwrap();
1058 assert_eq!(ext, ".rs");
1059 assert!(from_path);
1060 }
1061
1062 #[test]
1063 fn extensionless_javascript_maps_to_js() {
1064 let url = Url::parse("https://cdn.example/bundle").unwrap();
1065 let (ext, from_path) = resolve_fetch_extension(&url, "text/javascript").unwrap();
1066 assert_eq!(ext, ".js");
1067 assert!(!from_path);
1068 }
1069
1070 #[test]
1071 fn extensionless_plain_stays_md() {
1072 let url = Url::parse("https://example.com/readme").unwrap();
1073 let (ext, _) = resolve_fetch_extension(&url, "text/plain").unwrap();
1074 assert_eq!(ext, ".md");
1075 }
1076
1077 #[test]
1078 fn query_and_fragment_do_not_break_path_extension() {
1079 let url = Url::parse("https://example.com/src/file.ts?v=2#L10").unwrap();
1080 let (ext, from_path) = resolve_fetch_extension(&url, "text/plain").unwrap();
1081 assert_eq!(ext, ".ts");
1082 assert!(from_path);
1083 }
1084
1085 #[test]
1086 fn percent_encoded_path_segment() {
1087 let url = Url::parse("https://example.com/foo%2Fbar.rs").unwrap();
1088 let (ext, _) = resolve_fetch_extension(&url, "text/plain").unwrap();
1090 assert_eq!(ext, ".rs");
1091 }
1092
1093 #[test]
1094 fn binary_sniff_detects_nul() {
1095 let mut body = vec![b'f', b'n', 0, b' '];
1096 assert!(body_contains_nul_in_prefix(&body));
1097 body = vec![b'h'; 9000];
1098 assert!(!body_contains_nul_in_prefix(&body));
1099 }
1100
1101 #[test]
1102 fn unsupported_pdf_still_errors_via_resolve() {
1103 let url = Url::parse("https://example.com/doc.pdf").unwrap();
1104 assert!(resolve_fetch_extension(&url, "application/pdf").is_none());
1105 }
1106
1107 #[test]
1108 fn is_permalink_anchor_doc_anchor_class_drops_anchor() {
1109 let md = html_to_markdown_converter()
1110 .convert("<a class=\"doc-anchor\" href=\"#stdio-transport\">x</a>")
1111 .unwrap();
1112 assert_eq!(
1113 md.trim(),
1114 "",
1115 "doc-anchor permalink should be dropped: {md:?}"
1116 );
1117 }
1118
1119 #[test]
1120 fn html_to_markdown_rustdoc_heading_cleanup() {
1121 let html = "<main><h1>Function <span class=\"fn\">stdio</span> <button id=\"copy-path\">Copy item path</button></h1><h2 class=\"location\"><a href=\"#\">stdio</a></h2><h2><a class=\"doc-anchor\" href=\"#stdio-transport\">§</a>StdIO Transport</h2></main>";
1122 let md = html_to_markdown_converter().convert(html).unwrap();
1123 assert!(md.contains("# Function stdio"), "{md:?}");
1124 assert!(!md.contains("Copy item path"), "{md:?}");
1125 assert!(md.contains("## stdio"), "{md:?}");
1126 assert!(!md.contains("[stdio](#)"), "{md:?}");
1127 assert!(md.contains("## StdIO Transport"), "{md:?}");
1128 assert!(!md.contains('§'), "{md:?}");
1129 assert!(!md.contains("](#stdio-transport)"), "{md:?}");
1130 }
1131
1132 #[test]
1133 fn html_to_markdown_docusaurus_hash_link_still_dropped() {
1134 let html = "<h2>The Retain Pipeline<a class=\"hash-link\" href=\"#x\" aria-label=\"Direct link to The Retain Pipeline\">\u{200b}</a></h2>";
1135 let md = html_to_markdown_converter().convert(html).unwrap();
1136 assert!(md.contains("## The Retain Pipeline"), "{md:?}");
1137 assert!(!md.contains('\u{200b}'), "{md:?}");
1138 }
1139}