Skip to main content

max / makenotwork

22.7 KB · 606 lines History Blame Raw
1 //! Link preview, server-side OpenGraph metadata fetch for post URLs.
2
3 use pulldown_cmark::{Event, Parser, Tag};
4 use reqwest::header::CONTENT_TYPE;
5
6 /// Maximum number of URLs to extract per post.
7 const MAX_URLS: usize = 3;
8
9 /// Maximum response body size to read (1 MB).
10 const MAX_BODY_SIZE: usize = 1_048_576;
11
12 /// Check if an IP address is private/reserved (not safe for SSRF).
13 fn is_private_ip(ip: std::net::IpAddr) -> bool {
14 match ip {
15 std::net::IpAddr::V4(v4) => {
16 let o = v4.octets();
17 v4.is_loopback()
18 || v4.is_private()
19 || v4.is_link_local()
20 || v4.is_broadcast()
21 || v4.is_unspecified()
22 || o[0] == 0 // 0.0.0.0/8 "this network"
23 || o[0] == 100 && (o[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT / Tailscale)
24 || o[0] == 192 && o[1] == 0 && o[2] == 0 // 192.0.0.0/24 IETF protocol assignments
25 || o[0] == 198 && (o[1] & 0xFE) == 18 // 198.18.0.0/15 benchmarking
26 || o[0] >= 240 // 240.0.0.0/4 reserved / experimental
27 }
28 std::net::IpAddr::V6(v6) => {
29 let seg = v6.segments();
30 v6.is_loopback()
31 || v6.is_unspecified()
32 || (seg[0] & 0xfe00) == 0xfc00 // ULA fd00::/7
33 || (seg[0] & 0xffc0) == 0xfe80 // link-local
34 // NAT64 64:ff9b::/96 embeds a v4 address, check the embedded v4.
35 || (seg[0] == 0x0064 && seg[1] == 0xff9b
36 && is_private_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
37 (seg[6] >> 8) as u8, seg[6] as u8, (seg[7] >> 8) as u8, seg[7] as u8,
38 ))))
39 // v4-mapped (::ffff:a.b.c.d) and v4-compatible (::a.b.c.d) both
40 // carry an embedded v4 that reqwest may connect to directly.
41 || matches!(v6.to_ipv4(), Some(v4) if is_private_ip(std::net::IpAddr::V4(v4)))
42 }
43 }
44 }
45
46 /// Cheap, synchronous pre-check that a URL is safe to fetch: http(s) scheme and
47 /// not an obvious private/literal-IP target. Hostnames are NOT resolved here,
48 /// that (and the authoritative anti-rebinding check) happens at connect time in
49 /// [`SsrfSafeResolver`], so this stays non-blocking and usable from reqwest's
50 /// synchronous redirect callback.
51 ///
52 /// The host is parsed with the `url` crate, the SAME parser reqwest uses to
53 /// decide what to connect to. An earlier hand-rolled parser split the authority
54 /// itself and fed the host to `std::net::IpAddr::parse`, which only accepts the
55 /// dotted-quad form; integer/hex/octal encodings (`http://2130706433/`,
56 /// `http://0x7f000001/`, `http://0177.0.0.1/`) failed that parse, fell through as
57 /// "a hostname (resolved safely later)", and reqwest then normalized them into
58 /// literal internal IPs the `SsrfSafeResolver` never sees (it is consulted only
59 /// for *name* hosts). Parsing here with `url::Url` closes that divergence: we
60 /// inspect exactly the `Host` reqwest will act on, so an IP literal in any
61 /// encoding is caught by `is_private_ip` and a domain defers to the resolver.
62 fn validate_url(url: &str) -> bool {
63 let Ok(parsed) = url::Url::parse(url) else {
64 return false;
65 };
66 match parsed.scheme() {
67 "http" | "https" => {}
68 _ => return false,
69 }
70 // Only allow the standard web ports. A public host that legitimately serves
71 // OG metadata does so on 80/443; an explicit non-web port (e.g. :6379) means
72 // the link is trying to use us as a request-forwarder to some other service.
73 // `port()` is `None` for the scheme default (80/443), which is fine.
74 if let Some(p) = parsed.port()
75 && p != 80
76 && p != 443
77 {
78 return false;
79 }
80 // Inspect the concrete host reqwest will connect to. Any IP literal,
81 // dotted-quad, integer, hex, octal, or IPv6, is normalized by the `url`
82 // crate into `Host::Ipv4`/`Host::Ipv6` here, so the encoding tricks that
83 // defeated `IpAddr::parse` can no longer hide behind the "it's a hostname"
84 // branch. A real domain defers to `SsrfSafeResolver` at connect time.
85 match parsed.host() {
86 Some(url::Host::Ipv4(v4)) => !is_private_ip(std::net::IpAddr::V4(v4)),
87 Some(url::Host::Ipv6(v6)) => !is_private_ip(std::net::IpAddr::V6(v6)),
88 Some(url::Host::Domain(domain)) => {
89 !domain.is_empty() && !domain.eq_ignore_ascii_case("localhost")
90 }
91 None => false,
92 }
93 }
94
95 /// Extract unique http/https URLs from markdown text via pulldown_cmark link parsing.
96 /// Returns at most `MAX_URLS` URLs.
97 pub fn extract_urls(input: &str) -> Vec<String> {
98 let parser = Parser::new(input);
99 let mut seen = std::collections::HashSet::new();
100 let mut urls = Vec::new();
101
102 for event in parser {
103 if let Event::Start(Tag::Link { dest_url, .. }) = event {
104 let url = dest_url.to_string();
105 if (url.starts_with("http://") || url.starts_with("https://"))
106 && seen.insert(url.clone())
107 {
108 urls.push(url);
109 if urls.len() >= MAX_URLS {
110 break;
111 }
112 }
113 }
114 }
115
116 urls
117 }
118
119 /// Connect-time DNS resolver that drops private/reserved addresses.
120 ///
121 /// `validate_url` resolves and checks during validation, but reqwest re-resolves
122 /// when it actually connects, a DNS-rebinding host could pass validation and
123 /// then connect internally. Filtering here, at the resolver reqwest uses to
124 /// connect, closes that TOCTOU: reqwest can only connect to an address this
125 /// resolver returned, and we never return a private one. Resolution is async
126 /// (`tokio::net::lookup_host`), so it also avoids blocking the runtime.
127 struct SsrfSafeResolver;
128
129 impl reqwest::dns::Resolve for SsrfSafeResolver {
130 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
131 Box::pin(async move {
132 let host = name.as_str().to_string();
133 let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
134 let public: Vec<std::net::SocketAddr> =
135 addrs.filter(|a| !is_private_ip(a.ip())).collect();
136 if public.is_empty() {
137 return Err("no public address for host (SSRF guard)".into());
138 }
139 let iter: reqwest::dns::Addrs = Box::new(public.into_iter());
140 Ok(iter)
141 })
142 }
143 }
144
145 /// Build a reqwest client for link preview fetching with SSRF-safe redirect
146 /// policy and a connect-time resolver that refuses private addresses.
147 pub fn build_preview_client() -> reqwest::Client {
148 reqwest::Client::builder()
149 // Client-level backstop so every call site is bounded even if a
150 // per-request `.timeout()` is omitted (call sites still set a tighter
151 // 5s; this only catches a future caller that forgets).
152 .timeout(std::time::Duration::from_secs(10))
153 .connect_timeout(std::time::Duration::from_secs(5))
154 .dns_resolver(std::sync::Arc::new(SsrfSafeResolver))
155 .redirect(reqwest::redirect::Policy::custom(|attempt| {
156 if !validate_url(attempt.url().as_str()) || attempt.previous().len() >= 5 {
157 attempt.stop()
158 } else {
159 attempt.follow()
160 }
161 }))
162 .build()
163 .expect("failed to build preview HTTP client")
164 }
165
166 /// Strategy for fetching link previews. The `Noop` variant lets tests skip
167 /// real HTTP without monkey-patching `tokio::spawn`; production constructs
168 /// `Http(build_preview_client())`.
169 #[derive(Clone)]
170 pub enum LinkPreviewFetcher {
171 Http(reqwest::Client),
172 Noop,
173 }
174
175 impl LinkPreviewFetcher {
176 pub async fn fetch(&self, url: &str) -> Option<(Option<String>, Option<String>)> {
177 match self {
178 Self::Http(client) => fetch_og_metadata(client, url).await,
179 Self::Noop => None,
180 }
181 }
182 }
183
184 /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`.
185 /// Best-effort: returns None on any error (timeout, too large, parse failure).
186 #[tracing::instrument(skip_all)]
187 pub async fn fetch_og_metadata(
188 http: &reqwest::Client,
189 url: &str,
190 ) -> Option<(Option<String>, Option<String>)> {
191 if !validate_url(url) {
192 tracing::warn!(%url, "link preview blocked: url failed scheme/host SSRF validation");
193 return None;
194 }
195
196 let resp = match http
197 .get(url)
198 .timeout(std::time::Duration::from_secs(5))
199 .header("User-Agent", "Multithreaded/LinkPreview")
200 .send()
201 .await
202 {
203 Ok(r) => r,
204 Err(e) => {
205 tracing::debug!(%url, error = ?e, "link preview fetch failed (transport/timeout/blocked resolver)");
206 return None;
207 }
208 };
209
210 if !resp.status().is_success() {
211 tracing::debug!(%url, status = %resp.status(), "link preview fetch: non-success status");
212 return None;
213 }
214
215 // Only parse HTML content. Default-deny: a response with no Content-Type
216 // (or an unreadable one) is not treated as HTML.
217 let is_html = resp
218 .headers()
219 .get(CONTENT_TYPE)
220 .and_then(|ct| ct.to_str().ok())
221 .is_some_and(|ct| ct.starts_with("text/html"));
222 if !is_html {
223 return None;
224 }
225
226 // Read body in chunks, capping at MAX_BODY_SIZE
227 let mut body = Vec::new();
228 let mut stream = resp;
229 while body.len() < MAX_BODY_SIZE {
230 let Some(chunk) = stream.chunk().await.ok()? else {
231 break;
232 };
233 let remaining = MAX_BODY_SIZE - body.len();
234 body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
235 }
236
237 let html = String::from_utf8_lossy(&body);
238
239 let og_title = extract_og_meta(&html, "og:title");
240 let og_desc = extract_og_meta(&html, "og:description");
241
242 // Fall back to <title> tag if no og:title
243 let title = og_title.or_else(|| extract_html_title(&html));
244
245 if title.is_some() || og_desc.is_some() {
246 Some((title, og_desc))
247 } else {
248 None
249 }
250 }
251
252 /// Normalize an external image response's `Content-Type` to one of the four
253 /// formats we re-serve, or `None` to reject (the proxy default-denies anything
254 /// that isn't a recognised image type, so it can't be turned into a relay for
255 /// arbitrary content).
256 fn allowed_image_content_type(ct: &str) -> Option<&'static str> {
257 match ct.split(';').next().unwrap_or("").trim() {
258 "image/png" => Some("image/png"),
259 "image/jpeg" => Some("image/jpeg"),
260 "image/gif" => Some("image/gif"),
261 "image/webp" => Some("image/webp"),
262 _ => None,
263 }
264 }
265
266 /// Fetch an external image for the same-origin image proxy.
267 ///
268 /// Same SSRF guarantees as [`fetch_og_metadata`] (scheme/port pre-check +
269 /// connect-time `SsrfSafeResolver` that refuses private addresses, even across
270 /// redirects), capped at `MAX_BODY_SIZE` and a 5s timeout. Returns the bytes and
271 /// a normalised image content-type, or `None` on any failure or a non-image
272 /// response. Best-effort by design: the caller maps `None` to a 502.
273 #[tracing::instrument(skip_all)]
274 pub async fn fetch_image(http: &reqwest::Client, url: &str) -> Option<(Vec<u8>, &'static str)> {
275 if !validate_url(url) {
276 tracing::warn!(%url, "image proxy blocked: url failed scheme/host SSRF validation");
277 return None;
278 }
279
280 let resp = match http
281 .get(url)
282 .timeout(std::time::Duration::from_secs(5))
283 .header("User-Agent", "Multithreaded/ImageProxy")
284 .send()
285 .await
286 {
287 Ok(r) => r,
288 Err(e) => {
289 tracing::debug!(%url, error = ?e, "image proxy fetch failed (transport/timeout/blocked resolver)");
290 return None;
291 }
292 };
293
294 if !resp.status().is_success() {
295 tracing::debug!(%url, status = %resp.status(), "image proxy fetch: non-success status");
296 return None;
297 }
298
299 // Default-deny: only re-serve a recognised image content-type.
300 let content_type = resp
301 .headers()
302 .get(CONTENT_TYPE)
303 .and_then(|ct| ct.to_str().ok())
304 .and_then(allowed_image_content_type)?;
305
306 // Read the body in chunks, hard-capped at MAX_BODY_SIZE (1 MB).
307 let mut body = Vec::new();
308 let mut stream = resp;
309 while body.len() < MAX_BODY_SIZE {
310 let Some(chunk) = stream.chunk().await.ok()? else {
311 break;
312 };
313 let remaining = MAX_BODY_SIZE - body.len();
314 body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
315 }
316
317 if body.is_empty() {
318 return None;
319 }
320 Some((body, content_type))
321 }
322
323 /// Extract a `<meta property="..." content="...">` value from HTML.
324 fn extract_og_meta(html: &str, property: &str) -> Option<String> {
325 static OG_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
326 regex_lite::Regex::new(
327 r#"<meta\s[^>]*?property\s*=\s*"([^"]*)"[^>]*?content\s*=\s*"([^"]*)"[^>]*?>"#,
328 )
329 .unwrap()
330 });
331 static OG_RE_REV: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
332 regex_lite::Regex::new(
333 r#"<meta\s[^>]*?content\s*=\s*"([^"]*)"[^>]*?property\s*=\s*"([^"]*)"[^>]*?>"#,
334 )
335 .unwrap()
336 });
337
338 // Try property-first order
339 for caps in OG_RE.captures_iter(html) {
340 if &caps[1] == property {
341 let val = caps[2].trim().to_string();
342 if !val.is_empty() {
343 return Some(val);
344 }
345 }
346 }
347 // Try content-first order (some sites put content before property)
348 for caps in OG_RE_REV.captures_iter(html) {
349 if &caps[2] == property {
350 let val = caps[1].trim().to_string();
351 if !val.is_empty() {
352 return Some(val);
353 }
354 }
355 }
356 None
357 }
358
359 /// Extract the `<title>` tag content from HTML.
360 fn extract_html_title(html: &str) -> Option<String> {
361 static TITLE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
362 regex_lite::Regex::new(r"<title[^>]*>([^<]+)</title>").unwrap()
363 });
364 TITLE_RE.captures(html).map(|c| c[1].trim().to_string())
365 }
366
367 #[cfg(test)]
368 mod tests {
369 use super::*;
370
371 #[test]
372 fn extract_urls_from_markdown() {
373 let input = "Check [this](https://example.com) and [that](https://other.com/page).";
374 let urls = extract_urls(input);
375 assert_eq!(urls, vec!["https://example.com", "https://other.com/page"]);
376 }
377
378 #[test]
379 fn image_proxy_content_type_allowlist() {
380 // Recognised image types pass (with parameters stripped); everything else
381 // is refused so the proxy can't re-serve HTML/SVG/arbitrary content.
382 assert_eq!(allowed_image_content_type("image/png"), Some("image/png"));
383 assert_eq!(
384 allowed_image_content_type("image/jpeg; charset=binary"),
385 Some("image/jpeg")
386 );
387 assert_eq!(allowed_image_content_type("image/webp"), Some("image/webp"));
388 assert_eq!(allowed_image_content_type("image/svg+xml"), None);
389 assert_eq!(allowed_image_content_type("text/html"), None);
390 assert_eq!(allowed_image_content_type(""), None);
391 }
392
393 #[test]
394 fn extract_urls_skips_non_http() {
395 let input = "[mail](mailto:a@b.com) [site](https://x.com)";
396 let urls = extract_urls(input);
397 assert_eq!(urls, vec!["https://x.com"]);
398 }
399
400 #[test]
401 fn extract_urls_caps_at_three() {
402 let input = "[a](https://1.com) [b](https://2.com) [c](https://3.com) [d](https://4.com)";
403 let urls = extract_urls(input);
404 assert_eq!(urls.len(), 3);
405 }
406
407 #[test]
408 fn extract_urls_deduplicates() {
409 let input = "[a](https://same.com) [b](https://same.com)";
410 let urls = extract_urls(input);
411 assert_eq!(urls, vec!["https://same.com"]);
412 }
413
414 #[test]
415 fn extract_urls_no_links() {
416 let urls = extract_urls("no links here");
417 assert!(urls.is_empty());
418 }
419
420 #[test]
421 fn og_meta_property_first() {
422 let html = r#"<meta property="og:title" content="My Page">"#;
423 assert_eq!(
424 extract_og_meta(html, "og:title"),
425 Some("My Page".to_string())
426 );
427 }
428
429 #[test]
430 fn og_meta_content_first() {
431 let html = r#"<meta content="Description here" property="og:description">"#;
432 assert_eq!(
433 extract_og_meta(html, "og:description"),
434 Some("Description here".to_string())
435 );
436 }
437
438 #[test]
439 fn og_meta_missing() {
440 let html = r#"<meta property="og:image" content="img.png">"#;
441 assert_eq!(extract_og_meta(html, "og:title"), None);
442 }
443
444 #[test]
445 fn html_title_fallback() {
446 let html = "<html><head><title>Page Title</title></head></html>";
447 assert_eq!(extract_html_title(html), Some("Page Title".to_string()));
448 }
449
450 #[test]
451 fn html_title_missing() {
452 let html = "<html><head></head></html>";
453 assert_eq!(extract_html_title(html), None);
454 }
455
456 // validate_url tests
457
458 #[test]
459 fn validate_url_allows_https() {
460 assert!(validate_url("https://example.com"));
461 assert!(validate_url("https://example.com/path?q=1"));
462 }
463
464 #[test]
465 fn validate_url_allows_http() {
466 assert!(validate_url("http://example.com"));
467 }
468
469 #[test]
470 fn validate_url_blocks_non_http_schemes() {
471 assert!(!validate_url("ftp://example.com"));
472 assert!(!validate_url("file:///etc/passwd"));
473 assert!(!validate_url("javascript:alert(1)"));
474 assert!(!validate_url("data:text/html,<h1>hi</h1>"));
475 }
476
477 #[test]
478 fn validate_url_blocks_localhost() {
479 assert!(!validate_url("http://localhost"));
480 assert!(!validate_url("http://localhost:8080"));
481 assert!(!validate_url("http://127.0.0.1"));
482 assert!(!validate_url("http://127.0.0.1:3000"));
483 assert!(!validate_url("http://0.0.0.0"));
484 assert!(!validate_url("http://[::1]"));
485 assert!(!validate_url("http://[::1]:8080"));
486 }
487
488 #[test]
489 fn validate_url_blocks_private_10() {
490 assert!(!validate_url("http://10.0.0.1"));
491 assert!(!validate_url("http://10.255.255.255"));
492 }
493
494 #[test]
495 fn validate_url_blocks_private_192_168() {
496 assert!(!validate_url("http://192.168.0.1"));
497 assert!(!validate_url("http://192.168.1.100:8080"));
498 }
499
500 #[test]
501 fn validate_url_blocks_private_172_16() {
502 assert!(!validate_url("http://172.16.0.1"));
503 assert!(!validate_url("http://172.31.255.255"));
504 // 172.15 and 172.32 are public
505 assert!(validate_url("http://172.15.0.1"));
506 assert!(validate_url("http://172.32.0.1"));
507 }
508
509 #[test]
510 fn validate_url_blocks_link_local() {
511 assert!(!validate_url("http://169.254.0.1"));
512 assert!(!validate_url("http://169.254.169.254")); // AWS metadata
513 }
514
515 #[test]
516 fn validate_url_blocks_ipv6_private() {
517 assert!(!validate_url("http://[fd00::1]"));
518 assert!(!validate_url("http://[fe80::1]"));
519 }
520
521 #[test]
522 fn validate_url_allows_public_ips() {
523 assert!(validate_url("http://8.8.8.8"));
524 assert!(validate_url("https://93.184.216.34"));
525 }
526
527 #[test]
528 fn validate_url_blocks_userinfo_literal_ip() {
529 // H1: a userinfo-prefixed literal IP must not slip past the literal-IP
530 // guard. reqwest connects straight to the IP (skipping SsrfSafeResolver),
531 // so validate_url is the only defense on this path.
532 assert!(!validate_url("http://@10.0.0.1/"));
533 assert!(!validate_url("http://@169.254.169.254/")); // cloud metadata
534 assert!(!validate_url("http://user:pass@127.0.0.1/"));
535 assert!(!validate_url("http://foo@192.168.1.1:80/path"));
536 assert!(!validate_url("http://a@b@10.0.0.1/")); // last '@' wins
537 assert!(!validate_url("http://@[::1]/"));
538 // A userinfo-prefixed *public* host is still allowed (host resolves and is
539 // re-checked at connect time by SsrfSafeResolver).
540 assert!(validate_url("http://user@example.com/"));
541 }
542
543 #[test]
544 fn validate_url_blocks_empty_host() {
545 // A truly empty authority is a parse error under the `url` crate → rejected.
546 assert!(!validate_url("http://@/"));
547 // `http:///path` is NOT an empty host under the `url` crate (nor under
548 // reqwest): it normalizes to host "path", a domain deferred to
549 // SsrfSafeResolver at connect time. Parsing with the same crate reqwest
550 // uses means we treat it exactly as reqwest will, which is the point.
551 assert!(validate_url("http:///path"));
552 }
553
554 #[test]
555 fn validate_url_blocks_numeric_ip_encodings() {
556 // The SSRF integer-IP bypass: these encodings all normalize to internal
557 // literals under the `url` crate (what reqwest connects through) but were
558 // rejected by the old `IpAddr::parse` pre-check, so they slipped past as
559 // "hostnames" and reqwest connected straight to the internal IP without
560 // ever consulting `SsrfSafeResolver`. validate_url must now block them.
561 assert!(!validate_url("http://2130706433/")); // decimal 127.0.0.1
562 assert!(!validate_url("http://0x7f000001/")); // hex 127.0.0.1
563 assert!(!validate_url("http://0177.0.0.1/")); // octal-leading 127.0.0.1
564 assert!(!validate_url("http://2852039166/")); // decimal 169.254.169.254 (cloud metadata)
565 assert!(!validate_url("http://0xa9fea9fe/")); // hex 169.254.169.254
566 assert!(!validate_url("http://017700000001/")); // full octal 127.0.0.1
567 // A public host in decimal form is still reachable (sanity: the guard is
568 // is_private_ip, not a blanket numeric-host reject).
569 assert!(validate_url("http://134744072/")); // 8.8.8.8
570 }
571
572 #[test]
573 fn validate_url_blocks_reserved_ranges() {
574 assert!(!validate_url("http://0.1.2.3")); // 0.0.0.0/8
575 assert!(!validate_url("http://192.0.0.1")); // 192.0.0.0/24
576 assert!(!validate_url("http://198.18.0.1")); // benchmarking 198.18/15
577 assert!(!validate_url("http://198.19.255.255"));
578 assert!(!validate_url("http://240.0.0.1")); // reserved 240/4
579 assert!(!validate_url("http://255.255.255.254"));
580 }
581
582 #[test]
583 fn is_private_ip_blocks_v6_embedded_v4() {
584 use std::net::{IpAddr, Ipv6Addr};
585 // v4-mapped private
586 assert!(is_private_ip(IpAddr::V6(
587 "::ffff:10.0.0.1".parse::<Ipv6Addr>().unwrap()
588 )));
589 // v4-mapped public
590 assert!(!is_private_ip(IpAddr::V6(
591 "::ffff:8.8.8.8".parse::<Ipv6Addr>().unwrap()
592 )));
593 // NAT64 wrapping link-local metadata
594 assert!(is_private_ip(IpAddr::V6(
595 "64:ff9b::169.254.169.254".parse::<Ipv6Addr>().unwrap()
596 )));
597 }
598
599 #[tokio::test]
600 async fn noop_fetcher_returns_none_without_network() {
601 let fetcher = LinkPreviewFetcher::Noop;
602 // Any URL, would be a public host in production but here we expect no I/O.
603 assert!(fetcher.fetch("https://example.com").await.is_none());
604 }
605 }
606