Skip to main content

mbr_core/
oembed.rs

1use crate::errors::OembedError;
2use crate::media::MediaEmbed;
3use futures::StreamExt;
4use regex::Regex;
5use reqwest::Client;
6use scraper::{ElementRef, Html, Selector};
7use std::error::Error;
8use std::net::IpAddr;
9use std::sync::LazyLock;
10use std::time::Duration;
11use url::Url;
12
13/// Maximum bytes of a remote page body to read when extracting metadata.
14/// All metadata we care about lives in `<head>`, which fits comfortably here.
15const MAX_OEMBED_BODY_BYTES: usize = 512 * 1024;
16
17/// Maximum number of HTTP redirects to follow when fetching page metadata.
18const MAX_REDIRECTS: usize = 5;
19
20/// Shared HTTP client for oembed fetches, built exactly once.
21///
22/// Constructing a client rebuilds the webpki root certificate store (expensive)
23/// and discards the connection pool, so we share a single instance across every
24/// fetch. Redirects are disabled so that every hop can be validated against
25/// `is_public_ip` (SSRF protection); `fetch_page_info` follows redirects
26/// manually. No timeout is baked in — it varies by mode (500ms server, disabled
27/// in build), so it is applied per request via `RequestBuilder::timeout`.
28static OEMBED_CLIENT: LazyLock<Client> = LazyLock::new(|| {
29    crate::http_client_builder()
30        .redirect(reqwest::redirect::Policy::none())
31        .build()
32        .expect("failed to build HTTP client")
33});
34
35/// Returns true if the IP address is publicly routable. Used to prevent SSRF:
36/// oembed must never fetch loopback, private, or link-local addresses (e.g.
37/// cloud metadata endpoints like 169.254.169.254).
38fn is_public_ip(ip: IpAddr) -> bool {
39    match ip {
40        IpAddr::V4(v4) => {
41            !(v4.is_loopback()
42                || v4.is_unspecified()
43                || v4.is_private()
44                || v4.is_link_local()
45                || v4.is_broadcast())
46        }
47        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
48            // IPv4-mapped addresses (e.g. ::ffff:127.0.0.1) inherit IPv4 rules
49            Some(mapped) => is_public_ip(IpAddr::V4(mapped)),
50            None => {
51                !(v6.is_loopback()
52                    || v6.is_unspecified()
53                    || v6.is_unique_local()
54                    || v6.is_unicast_link_local())
55            }
56        },
57    }
58}
59
60/// Validate that a URL's target passes the given IP check before fetching.
61/// IP-literal hosts are checked directly; domain hosts are resolved via DNS
62/// and EVERY resolved address must pass (conservative vs. split-horizon DNS).
63async fn check_url_target<F: Fn(IpAddr) -> bool>(url: &Url, check: &F) -> Result<(), OembedError> {
64    fn disallowed(detail: String) -> OembedError {
65        OembedError::DisallowedAddress { detail }
66    }
67    let host = url.host().ok_or_else(|| OembedError::NoHost {
68        url: url.to_string(),
69    })?;
70    match host {
71        url::Host::Ipv4(ip) => check(IpAddr::V4(ip))
72            .then_some(())
73            .ok_or_else(|| disallowed(ip.to_string())),
74        url::Host::Ipv6(ip) => check(IpAddr::V6(ip))
75            .then_some(())
76            .ok_or_else(|| disallowed(ip.to_string())),
77        url::Host::Domain(domain) => {
78            let port = url
79                .port_or_known_default()
80                .ok_or_else(|| OembedError::NoKnownPort {
81                    url: url.to_string(),
82                })?;
83            let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((domain, port))
84                .await
85                .map_err(|source| OembedError::DnsResolveFailed {
86                    domain: domain.to_string(),
87                    source,
88                })?
89                .collect();
90            if addrs.is_empty() {
91                return Err(OembedError::NoAddressesResolved {
92                    domain: domain.to_string(),
93                });
94            }
95            addrs
96                .iter()
97                .all(|addr| check(addr.ip()))
98                .then_some(())
99                .ok_or_else(|| disallowed(format!("resolved for host {domain}")))
100        }
101    }
102}
103
104/// Formats an error with its full source chain for detailed logging.
105/// Walks the `source()` chain to reveal nested errors (e.g., reqwest -> hyper -> io).
106fn format_error_chain(err: &dyn Error) -> String {
107    let mut chain = vec![err.to_string()];
108    let mut current = err.source();
109    while let Some(source) = current {
110        chain.push(source.to_string());
111        current = source.source();
112    }
113    chain.join(" -> ")
114}
115
116// Precompiled selectors for efficient HTML parsing
117// All metadata lives in <head>, so we scope our searches there
118static HEAD_SELECTOR: LazyLock<Selector> =
119    LazyLock::new(|| Selector::parse("head").expect("Invalid head selector"));
120static TITLE_SELECTOR: LazyLock<Selector> =
121    LazyLock::new(|| Selector::parse("title").expect("Invalid title selector"));
122
123// OpenGraph meta tags (priority sources)
124static OG_TITLE_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
125    Selector::parse("meta[property='og:title']").expect("Invalid og:title selector")
126});
127static OG_IMAGE_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
128    Selector::parse("meta[property='og:image']").expect("Invalid og:image selector")
129});
130static OG_DESCRIPTION_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
131    Selector::parse("meta[property='og:description']").expect("Invalid og:description selector")
132});
133
134// Fallback selectors
135static META_DESCRIPTION_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
136    Selector::parse("meta[name='description']").expect("Invalid meta description selector")
137});
138static FAVICON_SELECTOR: LazyLock<Selector> =
139    LazyLock::new(|| Selector::parse("link[rel='icon']").expect("Invalid favicon selector"));
140static ALT_FAVICON_SELECTOR: LazyLock<Selector> = LazyLock::new(|| {
141    Selector::parse("link[rel='alternate icon']").expect("Invalid alternate favicon selector")
142});
143
144/// Supported image types for favicons
145const SUPPORTED_FAVICON_TYPES: &[&str] = &[
146    "image/png",
147    "image/jpeg",
148    "image/jpg",
149    "image/svg+xml",
150    "image/x-icon",
151    "image/vnd.microsoft.icon",
152];
153
154/// Check if a favicon type is supported (or if no type is specified, assume supported)
155fn is_supported_favicon_type(type_attr: Option<&str>) -> bool {
156    type_attr.is_none_or(|t| SUPPORTED_FAVICON_TYPES.contains(&t))
157}
158
159/// Extract content attribute from first matching element, HTML-escaped
160fn extract_content(head: &ElementRef, selector: &Selector) -> Option<String> {
161    head.select(selector)
162        .next()
163        .and_then(|el| el.value().attr("content"))
164        .map(|s| html_escape::encode_text(s).to_string())
165}
166
167/// Extract content attribute from first matching element, RAW (unescaped).
168/// Used for values rendered into attribute contexts, where escaping happens
169/// at render time to avoid double-escaping.
170fn extract_content_raw(head: &ElementRef, selector: &Selector) -> Option<String> {
171    head.select(selector)
172        .next()
173        .and_then(|el| el.value().attr("content"))
174        .map(String::from)
175}
176
177/// Extract favicon href from first matching element with a supported image type
178fn extract_favicon(head: &ElementRef, selector: &Selector) -> Option<String> {
179    head.select(selector)
180        .find(|el| is_supported_favicon_type(el.value().attr("type")))
181        .and_then(|el| el.value().attr("href"))
182        .map(|s| s.to_string())
183}
184
185// Giphy regex patterns - compiled once for efficiency
186static GIPHY_MEDIA_RE: LazyLock<Regex> =
187    LazyLock::new(|| Regex::new(r"https?://(?:media\d*|i)\.giphy\.com/").unwrap());
188static GIPHY_PAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
189    Regex::new(r"https?://(?:www\.)?giphy\.com/gifs/(?:[^/]+-)?([a-zA-Z0-9]+)(?:\?.*)?$").unwrap()
190});
191
192// GIST regex patterns
193static GIST_RE: LazyLock<Regex> =
194    LazyLock::new(|| Regex::new(r"https?://gist\.github\.com/").unwrap());
195
196#[derive(Default, Clone)]
197pub struct PageInfo {
198    pub url: String,
199    /// Page title, stored text-escaped (safe for element text context only)
200    pub title: Option<String>,
201    /// Page description, stored text-escaped (safe for element text context only)
202    pub description: Option<String>,
203    /// Image/favicon URL, stored RAW; escaped at render time for attribute context
204    pub image: Option<String>,
205    pub embed_html: Option<String>, // For YouTube and other embeddable content
206}
207
208impl PageInfo {
209    /// Estimates the memory size of this PageInfo in bytes.
210    ///
211    /// Used by OembedCache for size-based eviction. This is an approximation
212    /// that accounts for the struct overhead plus the heap-allocated strings.
213    pub fn estimated_size(&self) -> usize {
214        std::mem::size_of::<Self>()
215            + self.url.len()
216            + self.title.as_ref().map_or(0, |s| s.len())
217            + self.description.as_ref().map_or(0, |s| s.len())
218            + self.image.as_ref().map_or(0, |s| s.len())
219            + self.embed_html.as_ref().map_or(0, |s| s.len())
220    }
221
222    /// Check if URL is a Giphy URL and create embed HTML
223    /// Matches patterns like:
224    /// - https://media.giphy.com/media/ID/giphy.gif
225    /// - https://media1.giphy.com/media/.../giphy.gif (or .webp)
226    /// - https://giphy.com/gifs/name-ID
227    /// - https://i.giphy.com/ID.gif
228    fn giphy_embed(url: &str) -> Option<String> {
229        // Pattern for direct media URLs (media.giphy.com, media1.giphy.com, i.giphy.com)
230        // These are direct image files - embed as img tags
231        if GIPHY_MEDIA_RE.is_match(url) {
232            return Some(format!(
233                r#"<figure class="giphy-embed">
234                    <img src="{}" alt="Giphy animation" loading="lazy" />
235                </figure>"#,
236                url
237            ));
238        }
239
240        // Pattern for giphy.com/gifs/... URLs - extract the ID and convert to media URL
241        // Format: https://giphy.com/gifs/description-ID or https://giphy.com/gifs/ID
242        if let Some(caps) = GIPHY_PAGE_RE.captures(url)
243            && let Some(id) = caps.get(1)
244        {
245            let gif_url = format!("https://media.giphy.com/media/{}/giphy.gif", id.as_str());
246            return Some(format!(
247                r#"<figure class="giphy-embed">
248                    <img src="{}" alt="Giphy animation" loading="lazy" />
249                </figure>"#,
250                gif_url
251            ));
252        }
253
254        None
255    }
256
257    /// Check if URL is a gist URL and create embed HTML
258    /// Matches patterns like:
259    /// - https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea
260    /// - https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea#file-order-ts
261    fn gist_embed(url: &str) -> Option<String> {
262        if GIST_RE.is_match(url) {
263            // Handle fragment - .js must come before #fragment
264            let script_url = if let Some(hash_pos) = url.find('#') {
265                let (path, fragment) = url.split_at(hash_pos);
266                format!("{}.js{}", path, fragment)
267            } else {
268                format!("{}.js", url)
269            };
270            return Some(format!(r#"<script src="{}"></script>"#, script_url));
271        }
272        None
273    }
274
275    /// Compute a no-network embed (Giphy, GitHub gist, or bare-URL media) for
276    /// `url`, if it matches one. Pure and synchronous — performs no I/O. Returns
277    /// `None` for URLs that would require a network (OpenGraph) fetch.
278    pub fn local_embed(url: &str) -> Option<PageInfo> {
279        // Check for Giphy - embed directly without fetching
280        if let Some(embed_html) = Self::giphy_embed(url) {
281            return Some(PageInfo {
282                url: url.to_string(),
283                embed_html: Some(embed_html),
284                ..Default::default()
285            });
286        }
287
288        // Check for GitHub gist - embed without fetching
289        if let Some(embed_html) = Self::gist_embed(url) {
290            return Some(PageInfo {
291                url: url.to_string(),
292                embed_html: Some(embed_html),
293                ..Default::default()
294            });
295        }
296
297        // Check for media extension (video/audio/PDF) - embed without fetching
298        // Parameters: server_mode=false (safe default for static builds),
299        // transcode_enabled=true, hls_enabled=false
300        if let Some(media) = MediaEmbed::from_bare_url(url) {
301            return Some(PageInfo {
302                url: url.to_string(),
303                embed_html: Some(media.to_html(false, true, false)),
304                ..Default::default()
305            });
306        }
307
308        None
309    }
310
311    pub async fn new_from_url(url: &str, timeout_ms: u64) -> Result<Self, OembedError> {
312        // No-network embeds (Giphy/gist/media) require no I/O and always work,
313        // even when network oembed is disabled (timeout 0).
314        if let Some(info) = Self::local_embed(url) {
315            return Ok(info);
316        }
317
318        // Network OpenGraph enrichment is gated by the timeout.
319        if timeout_ms == 0 {
320            // tracing::debug!("Oembed disabled, ignoring url {}", &url);
321            return Ok(PageInfo {
322                url: url.to_string(),
323                ..Default::default()
324            });
325        }
326
327        // For other URLs, fetch and parse OpenGraph metadata. The shared client
328        // carries no baked timeout; apply this request's timeout per hop.
329        // (timeout_ms is guaranteed > 0 here: the 0 case returned above.)
330        match Self::fetch_page_info(url, Duration::from_millis(timeout_ms)).await {
331            Ok(info) => Ok(info),
332            Err(e) => {
333                // Any error (timeout, network, etc.) - return a plain link
334                tracing::warn!(
335                    "Error fetching URL ({}) for enriched display: {}",
336                    &url,
337                    format_error_chain(&e)
338                );
339                Ok(PageInfo {
340                    url: url.to_string(),
341                    ..Default::default()
342                })
343            }
344        }
345    }
346
347    /// Fetch page metadata with SSRF protections: a public-IP check on every
348    /// hop, manual redirect following (max [`MAX_REDIRECTS`]), a content-type
349    /// check, and a body size cap of [`MAX_OEMBED_BODY_BYTES`].
350    async fn fetch_page_info(url: &str, timeout: Duration) -> Result<Self, OembedError> {
351        Self::fetch_page_info_inner(url, timeout, is_public_ip).await
352    }
353
354    /// Inner fetch with an injectable IP check so tests can exercise the
355    /// redirect and body handling against loopback mock servers.
356    ///
357    /// Uses the shared [`OEMBED_CLIENT`] and applies `timeout` per request, so
358    /// the timeout bounds each redirect hop individually (matching the previous
359    /// per-client timeout semantics).
360    async fn fetch_page_info_inner<F: Fn(IpAddr) -> bool>(
361        url: &str,
362        timeout: Duration,
363        check: F,
364    ) -> Result<Self, OembedError> {
365        let mut current_url = Url::parse(url)?;
366        let mut redirects = 0;
367        let response = loop {
368            if !matches!(current_url.scheme(), "http" | "https") {
369                return Err(OembedError::NonHttpScheme {
370                    url: current_url.to_string(),
371                });
372            }
373            check_url_target(&current_url, &check).await?;
374
375            // Security: reqwest re-resolves DNS when sending the request, so a
376            // malicious DNS server could return a public address to our check
377            // and a private one to reqwest (DNS-rebinding TOCTOU). We accept
378            // this residual risk: mbr is a local previewer, exploiting the gap
379            // requires an attacker-controlled rebinding DNS server, and closing
380            // it would require pinning resolved IPs via a custom connector.
381            let response = OEMBED_CLIENT
382                .get(current_url.clone())
383                .timeout(timeout)
384                .send()
385                .await?;
386
387            let status = response.status().as_u16();
388            if matches!(status, 301 | 302 | 303 | 307 | 308) {
389                if redirects >= MAX_REDIRECTS {
390                    return Err(OembedError::TooManyRedirects {
391                        max: MAX_REDIRECTS,
392                        url: url.to_string(),
393                    });
394                }
395                redirects += 1;
396                let location = response
397                    .headers()
398                    .get(reqwest::header::LOCATION)
399                    .ok_or_else(|| OembedError::RedirectWithoutLocation {
400                        url: current_url.to_string(),
401                    })?
402                    .to_str()
403                    .map_err(|_| OembedError::InvalidLocationHeader {
404                        url: current_url.to_string(),
405                    })?;
406                // Resolve relative redirects against the current URL
407                current_url = current_url.join(location)?;
408                continue;
409            }
410            break response;
411        };
412
413        // Check for Cloudflare challenge block
414        if response
415            .headers()
416            .get("cf-mitigated")
417            .is_some_and(|v| v == "challenge")
418        {
419            return Err(OembedError::CloudflareChallenge);
420        }
421
422        // Only parse HTML responses. A missing Content-Type is tolerated.
423        if let Some(content_type) = response.headers().get(reqwest::header::CONTENT_TYPE) {
424            let content_type = content_type.to_str().unwrap_or_default();
425            if !content_type.contains("text/html")
426                && !content_type.contains("application/xhtml+xml")
427            {
428                return Err(OembedError::NonHtmlContentType {
429                    content_type: content_type.to_string(),
430                    url: current_url.to_string(),
431                });
432            }
433        }
434
435        // Stream the body up to MAX_OEMBED_BODY_BYTES, then stop polling.
436        // Truncation (not an error): all metadata lives in <head>, which fits
437        // well within the cap, and Html::parse_document is lenient.
438        let mut buf: Vec<u8> = Vec::new();
439        let mut stream = response.bytes_stream();
440        while let Some(chunk) = stream.next().await {
441            let chunk = chunk?;
442            let remaining = MAX_OEMBED_BODY_BYTES - buf.len();
443            if chunk.len() >= remaining {
444                buf.extend_from_slice(&chunk[..remaining]);
445                break;
446            }
447            buf.extend_from_slice(&chunk);
448        }
449        // Lossy UTF-8 conversion: we give up charset detection for non-UTF-8
450        // pages, which may garble some metadata. That is acceptable graceful
451        // degradation for a best-effort link preview.
452        let body = String::from_utf8_lossy(&buf);
453        let document = Html::parse_document(&body);
454
455        // All metadata lives in <head> - extract with og:* priority, then fallbacks
456        let (title, description, image) = document
457            .select(&HEAD_SELECTOR)
458            .next()
459            .map(|head| {
460                // Priority: og:* tags first
461                let og_title = extract_content(&head, &OG_TITLE_SELECTOR);
462                let og_desc = extract_content(&head, &OG_DESCRIPTION_SELECTOR);
463                // Raw: image is only rendered in attribute context, escaped there
464                let og_image = extract_content_raw(&head, &OG_IMAGE_SELECTOR);
465
466                // Fallbacks only computed if og:* not found
467                let title = og_title.or_else(|| {
468                    head.select(&TITLE_SELECTOR)
469                        .next()
470                        .map(|el| el.text().collect::<String>())
471                        .filter(|s| !s.is_empty())
472                        .map(|s| html_escape::encode_text(&s).to_string())
473                });
474
475                let description =
476                    og_desc.or_else(|| extract_content(&head, &META_DESCRIPTION_SELECTOR));
477
478                let image = og_image
479                    .or_else(|| extract_favicon(&head, &FAVICON_SELECTOR))
480                    .or_else(|| extract_favicon(&head, &ALT_FAVICON_SELECTOR));
481
482                (title, description, image)
483            })
484            .unwrap_or((None, None, None));
485
486        Ok(PageInfo {
487            url: url.to_string(),
488            title,
489            description,
490            image,
491            embed_html: None,
492        })
493    }
494
495    pub fn text(&self) -> String {
496        format!(
497            "{}: {}",
498            self.title.clone().unwrap_or("no title".to_string()),
499            self.url
500        )
501    }
502
503    pub fn html(&self) -> String {
504        // If we have embed HTML (e.g., YouTube), use that
505        if let Some(embed) = &self.embed_html {
506            return embed.clone();
507        }
508
509        // Otherwise, create a link (TODO: make this a rich card with image/description)
510        // Attribute values (image src, href) are escaped for double-quoted
511        // attribute context here at render time; title/description are stored
512        // text-escaped at extraction time (element text context).
513        if let Some(title) = self.title.clone() {
514            let img_tag = self
515                .image
516                .as_ref()
517                .map(|src| {
518                    format!(
519                        "<img src=\"{}\"/>",
520                        html_escape::encode_double_quoted_attribute(src)
521                    )
522                })
523                .unwrap_or_default();
524            format!(
525                "<article class='mbr-social-link-box'>
526                    {}
527                    <a href=\"{}\" class='mbr-social-link'>
528                        <header>{}</header>
529                        <p>{}</p>
530                    </a>
531                </article>
532                ",
533                img_tag,
534                html_escape::encode_double_quoted_attribute(&self.url),
535                title,
536                self.description.as_deref().unwrap_or(""),
537            )
538        } else {
539            format!(
540                "<a href=\"{}\">{}</a>",
541                html_escape::encode_double_quoted_attribute(&self.url),
542                html_escape::encode_text(&self.url),
543            )
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use std::net::{Ipv4Addr, SocketAddr};
552    use std::sync::Arc;
553    use std::sync::atomic::{AtomicUsize, Ordering};
554    use tokio::io::{AsyncReadExt, AsyncWriteExt};
555    use tokio::net::TcpListener;
556
557    /// Spawn a minimal HTTP/1.1 mock server on 127.0.0.1:0. The response is
558    /// built from the bound address (so redirect targets can reference the
559    /// server itself) and served identically for every connection. Returns
560    /// the bound address and a counter of received requests.
561    async fn spawn_mock_server(
562        build_response: impl FnOnce(SocketAddr) -> String,
563    ) -> (SocketAddr, Arc<AtomicUsize>) {
564        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
565        let addr = listener.local_addr().unwrap();
566        let response = build_response(addr);
567        let hits = Arc::new(AtomicUsize::new(0));
568        let hits_counter = hits.clone();
569        tokio::spawn(async move {
570            loop {
571                let Ok((mut stream, _)) = listener.accept().await else {
572                    break;
573                };
574                hits_counter.fetch_add(1, Ordering::SeqCst);
575                let response = response.clone();
576                tokio::spawn(async move {
577                    let mut buf = [0u8; 4096];
578                    // A single read suffices for the small GET requests we send
579                    let _ = stream.read(&mut buf).await;
580                    let _ = stream.write_all(response.as_bytes()).await;
581                    let _ = stream.shutdown().await;
582                });
583            }
584        });
585        (addr, hits)
586    }
587
588    fn html_response(body: &str) -> String {
589        format!(
590            "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
591            body.len(),
592            body
593        )
594    }
595
596    #[test]
597    fn test_is_public_ip() {
598        for ip in [
599            "127.0.0.1",
600            "0.0.0.0",
601            "10.1.2.3",
602            "172.16.0.1",
603            "172.31.255.255",
604            "192.168.1.1",
605            "169.254.169.254",
606            "::1",
607            "::",
608            "fc00::1",
609            "fe80::1",
610            "::ffff:10.0.0.1",
611        ] {
612            assert!(
613                !is_public_ip(ip.parse().unwrap()),
614                "{ip} should be rejected"
615            );
616        }
617        for ip in ["93.184.216.34", "172.32.0.1", "2606:2800::1"] {
618            assert!(is_public_ip(ip.parse().unwrap()), "{ip} should be accepted");
619        }
620    }
621
622    #[tokio::test]
623    async fn test_fetch_rejects_loopback_literal() {
624        let (addr, hits) =
625            spawn_mock_server(|_| html_response("<html><head><title>x</title></head></html>"))
626                .await;
627        let url = format!("http://127.0.0.1:{}/", addr.port());
628        let result = PageInfo::fetch_page_info(&url, Duration::from_secs(2)).await;
629        assert!(result.is_err());
630        // The check runs before connecting, so the mock never sees a request
631        assert_eq!(hits.load(Ordering::SeqCst), 0);
632    }
633
634    #[tokio::test]
635    async fn test_fetch_rejects_private_literal_without_network() {
636        let result = PageInfo::fetch_page_info("http://192.168.0.1/", Duration::from_secs(2)).await;
637        assert!(result.is_err());
638    }
639
640    #[tokio::test]
641    async fn test_fetch_rejects_localhost_hostname() {
642        // Rejected via DNS resolution: localhost resolves to loopback addresses
643        let result = PageInfo::fetch_page_info("http://localhost:9/", Duration::from_secs(2)).await;
644        assert!(result.is_err());
645    }
646
647    #[tokio::test]
648    async fn test_fetch_follows_redirect_and_checks_every_hop() {
649        // Mock A redirects to a second loopback address (127.0.0.2). The
650        // checker allows only mock A's address, so the second hop is rejected.
651        let (addr, hits) = spawn_mock_server(|_| {
652            "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.2:9/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
653                .to_string()
654        })
655        .await;
656        let url = format!("http://127.0.0.1:{}/", addr.port());
657        let result = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |ip| {
658            ip == IpAddr::V4(Ipv4Addr::LOCALHOST)
659        })
660        .await;
661        assert!(result.is_err());
662        // The first hop was fetched; the redirect target was rejected pre-connect
663        assert_eq!(hits.load(Ordering::SeqCst), 1);
664    }
665
666    #[tokio::test]
667    async fn test_fetch_redirect_limit() {
668        // Mock redirects to itself forever; must fail after MAX_REDIRECTS hops
669        let (addr, _hits) = spawn_mock_server(|addr| {
670            format!(
671                "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
672                addr.port()
673            )
674        })
675        .await;
676        let url = format!("http://127.0.0.1:{}/", addr.port());
677        let result = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(5), |_| true).await;
678        let err = result.err().expect("must fail after redirect limit");
679        assert!(err.to_string().contains("redirect"));
680    }
681
682    #[tokio::test]
683    async fn test_fetch_truncates_large_body() {
684        // Body far exceeds MAX_OEMBED_BODY_BYTES; the <head> metadata still
685        // parses because we truncate rather than error
686        let body = format!(
687            "<html><head><title>Big</title></head><body>{}</body></html>",
688            "x".repeat(1_100_000)
689        );
690        let (addr, _hits) = spawn_mock_server(move |_| html_response(&body)).await;
691        let url = format!("http://127.0.0.1:{}/", addr.port());
692        let info = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(5), |_| true)
693            .await
694            .unwrap();
695        assert_eq!(info.title.as_deref(), Some("Big"));
696    }
697
698    #[tokio::test]
699    async fn test_fetch_rejects_non_html_content_type() {
700        let (addr, _hits) = spawn_mock_server(|_| {
701            "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 4\r\nConnection: close\r\n\r\nabcd"
702                .to_string()
703        })
704        .await;
705        let url = format!("http://127.0.0.1:{}/", addr.port());
706        let result = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |_| true).await;
707        let err = result.err().expect("must reject non-HTML content type");
708        assert!(err.to_string().contains("non-HTML"));
709    }
710
711    #[tokio::test]
712    async fn test_fetch_allows_missing_content_type() {
713        let body = "<html><head><title>NoCT</title></head><body></body></html>";
714        let (addr, _hits) = spawn_mock_server(|_| {
715            format!(
716                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
717                body.len(),
718                body
719            )
720        })
721        .await;
722        let url = format!("http://127.0.0.1:{}/", addr.port());
723        let info = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |_| true)
724            .await
725            .unwrap();
726        assert_eq!(info.title.as_deref(), Some("NoCT"));
727    }
728
729    #[tokio::test]
730    async fn test_shared_client_reused_across_fetches() {
731        // The oembed client must be a single shared instance so the webpki root
732        // store is built once and connections pool across fetches. Every deref
733        // of the LazyLock yields the same instance.
734        let first: *const Client = &*OEMBED_CLIENT;
735        let second: *const Client = &*OEMBED_CLIENT;
736        assert!(
737            std::ptr::eq(first, second),
738            "OEMBED_CLIENT must be a single shared instance"
739        );
740
741        // Two sequential fetches both succeed through the shared client, and the
742        // per-request timeout still applies (a non-trivial timeout is passed).
743        let (addr, hits) =
744            spawn_mock_server(|_| html_response("<html><head><title>Shared</title></head></html>"))
745                .await;
746        let url = format!("http://127.0.0.1:{}/", addr.port());
747        for _ in 0..2 {
748            let info = PageInfo::fetch_page_info_inner(&url, Duration::from_secs(2), |_| true)
749                .await
750                .unwrap();
751            assert_eq!(info.title.as_deref(), Some("Shared"));
752        }
753        assert_eq!(hits.load(Ordering::SeqCst), 2);
754    }
755
756    #[test]
757    fn test_giphy_media_url() {
758        let url = "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExaThmbzNhb3duNGM5NGVxemo4aHlnYTM1YXA4cGxmc2l2ejdjc2s4ZCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/CAxbo8KC2A0y4/giphy.gif";
759        let embed = PageInfo::giphy_embed(url);
760        assert!(embed.is_some());
761        let html = embed.unwrap();
762        assert!(html.contains("giphy-embed"));
763        assert!(html.contains(url));
764    }
765
766    #[test]
767    fn test_giphy_media_url_without_version() {
768        let url = "https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif";
769        let embed = PageInfo::giphy_embed(url);
770        assert!(embed.is_some());
771        let html = embed.unwrap();
772        assert!(html.contains("giphy-embed"));
773        assert!(html.contains(url));
774    }
775
776    #[test]
777    fn test_giphy_i_url() {
778        let url = "https://i.giphy.com/CAxbo8KC2A0y4.gif";
779        let embed = PageInfo::giphy_embed(url);
780        assert!(embed.is_some());
781        let html = embed.unwrap();
782        assert!(html.contains("giphy-embed"));
783        assert!(html.contains(url));
784    }
785
786    #[test]
787    fn test_giphy_page_url() {
788        let url = "https://giphy.com/gifs/cat-funny-CAxbo8KC2A0y4";
789        let embed = PageInfo::giphy_embed(url);
790        assert!(embed.is_some());
791        let html = embed.unwrap();
792        assert!(html.contains("giphy-embed"));
793        assert!(html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
794    }
795
796    #[test]
797    fn test_giphy_page_url_simple() {
798        let url = "https://giphy.com/gifs/CAxbo8KC2A0y4";
799        let embed = PageInfo::giphy_embed(url);
800        assert!(embed.is_some());
801        let html = embed.unwrap();
802        assert!(html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
803    }
804
805    #[test]
806    fn test_non_giphy_url() {
807        let url = "https://example.com/image.gif";
808        let embed = PageInfo::giphy_embed(url);
809        assert!(embed.is_none());
810    }
811
812    #[test]
813    fn test_youtube_not_giphy() {
814        let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
815        let embed = PageInfo::giphy_embed(url);
816        assert!(embed.is_none());
817    }
818
819    #[tokio::test]
820    async fn test_zero_timeout_returns_plain_link() {
821        // With timeout=0, should return plain link without network call
822        let url = "https://example.com/some-page";
823        let result = PageInfo::new_from_url(url, 0).await;
824        assert!(result.is_ok());
825        let info = result.unwrap();
826        assert_eq!(info.url, url);
827        assert!(info.title.is_none()); // No network call, so no title fetched
828        assert!(info.embed_html.is_none());
829        // html() should return a plain link
830        assert!(info.html().contains("<a href="));
831        assert!(info.html().contains(url));
832    }
833
834    #[tokio::test]
835    async fn test_zero_timeout_still_allows_no_network_youtube_embed() {
836        // YouTube is a no-network embed (via MediaEmbed), so it must STILL embed
837        // even when network oembed is disabled (timeout 0). Only OpenGraph network
838        // fetches are gated by the timeout.
839        let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
840        let result = PageInfo::new_from_url(url, 0).await;
841        assert!(result.is_ok());
842        let info = result.unwrap();
843        assert!(info.embed_html.is_some());
844        assert!(info.html().contains("youtube-embed"));
845    }
846
847    #[tokio::test]
848    async fn test_zero_timeout_still_allows_no_network_giphy_embed() {
849        // No-network embeds (Giphy) require no I/O, so they must STILL embed even
850        // when network oembed is disabled (timeout 0). Only OpenGraph network
851        // fetches are gated by the timeout.
852        let url = "https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif";
853        let result = PageInfo::new_from_url(url, 0).await;
854        assert!(result.is_ok());
855        let info = result.unwrap();
856        assert!(info.embed_html.is_some());
857        assert!(info.html().contains("giphy-embed"));
858
859        // A URL that WOULD require a network fetch still falls back to a plain
860        // link at timeout 0 (proving the fetch itself is skipped).
861        let network_info = PageInfo::new_from_url("https://example.com/some-page", 0)
862            .await
863            .unwrap();
864        assert!(network_info.embed_html.is_none());
865        assert!(network_info.html().contains("<a href="));
866    }
867
868    #[test]
869    fn test_local_embed_giphy_and_non_matching() {
870        // Giphy page URL yields a giphy-embed figure with the derived media URL
871        let page = PageInfo::local_embed("https://giphy.com/gifs/cat-funny-CAxbo8KC2A0y4")
872            .expect("giphy page URL should produce a local embed");
873        let page_html = page.embed_html.expect("embed html present");
874        assert!(page_html.contains("giphy-embed"));
875        assert!(page_html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
876
877        // Giphy media URL yields a giphy-embed figure pointing at the media URL
878        let media_url = "https://media1.giphy.com/media/CAxbo8KC2A0y4/giphy.gif";
879        let media =
880            PageInfo::local_embed(media_url).expect("giphy media URL should produce a local embed");
881        let media_html = media.embed_html.expect("embed html present");
882        assert!(media_html.contains("giphy-embed"));
883        assert!(media_html.contains(media_url));
884
885        // A plain article URL requires a network fetch, so there is no local embed
886        assert!(PageInfo::local_embed("https://example.com/article").is_none());
887    }
888
889    #[test]
890    fn test_estimated_size_empty() {
891        let info = PageInfo::default();
892        // Should at least include the struct size
893        assert!(info.estimated_size() >= std::mem::size_of::<PageInfo>());
894    }
895
896    #[test]
897    fn test_estimated_size_with_fields() {
898        let info = PageInfo {
899            url: "https://example.com".to_string(),
900            title: Some("Test Title".to_string()),
901            description: Some("A longer description text".to_string()),
902            image: Some("https://example.com/image.png".to_string()),
903            embed_html: None,
904        };
905        let size = info.estimated_size();
906        // Size should include all the string lengths
907        let expected_min = std::mem::size_of::<PageInfo>()
908            + info.url.len()
909            + info.title.as_ref().unwrap().len()
910            + info.description.as_ref().unwrap().len()
911            + info.image.as_ref().unwrap().len();
912        assert_eq!(size, expected_min);
913    }
914
915    #[test]
916    fn test_clone() {
917        let info = PageInfo {
918            url: "https://example.com".to_string(),
919            title: Some("Test".to_string()),
920            description: None,
921            image: None,
922            embed_html: Some("<div></div>".to_string()),
923        };
924        let cloned = info.clone();
925        assert_eq!(cloned.url, info.url);
926        assert_eq!(cloned.title, info.title);
927        assert_eq!(cloned.embed_html, info.embed_html);
928    }
929
930    #[test]
931    fn test_gist_embed_basic() {
932        let url = "https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea";
933        let embed = PageInfo::gist_embed(url);
934        assert!(embed.is_some());
935        let html = embed.unwrap();
936        assert!(html.contains(
937            r#"<script src="https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea.js"></script>"#
938        ));
939    }
940
941    #[test]
942    fn test_gist_embed_with_fragment() {
943        let url = "https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea#file-order-ts";
944        let embed = PageInfo::gist_embed(url);
945        assert!(embed.is_some());
946        let html = embed.unwrap();
947        // .js should come BEFORE the fragment
948        assert!(html.contains(
949            r#"<script src="https://gist.github.com/rpinna/b97f8505940f255e8ebbd9a17c76f3ea.js#file-order-ts"></script>"#
950        ));
951    }
952
953    #[test]
954    fn test_gist_embed_http() {
955        let url = "http://gist.github.com/user/abc123";
956        let embed = PageInfo::gist_embed(url);
957        assert!(embed.is_some());
958    }
959
960    #[test]
961    fn test_gist_non_gist_url() {
962        let url = "https://github.com/user/repo";
963        let embed = PageInfo::gist_embed(url);
964        assert!(embed.is_none());
965    }
966
967    #[test]
968    fn test_gist_not_youtube() {
969        let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
970        let embed = PageInfo::gist_embed(url);
971        assert!(embed.is_none());
972    }
973
974    // Bare media URL tests
975    #[tokio::test]
976    async fn test_bare_mp4_url_returns_video_embed() {
977        let url = "https://example.com/videos/demo.mp4";
978        let result = PageInfo::new_from_url(url, 500).await;
979        assert!(result.is_ok());
980        let info = result.unwrap();
981        assert!(info.embed_html.is_some());
982        let html = info.embed_html.unwrap();
983        assert!(html.contains("<video"));
984        assert!(html.contains(url));
985    }
986
987    #[tokio::test]
988    async fn test_bare_mp3_url_returns_audio_embed() {
989        let url = "https://example.com/audio/podcast.mp3";
990        let result = PageInfo::new_from_url(url, 500).await;
991        assert!(result.is_ok());
992        let info = result.unwrap();
993        assert!(info.embed_html.is_some());
994        let html = info.embed_html.unwrap();
995        assert!(html.contains("<audio"));
996        assert!(html.contains(url));
997    }
998
999    #[tokio::test]
1000    async fn test_bare_pdf_url_returns_pdf_embed() {
1001        let url = "https://example.com/docs/report.pdf";
1002        let result = PageInfo::new_from_url(url, 500).await;
1003        assert!(result.is_ok());
1004        let info = result.unwrap();
1005        assert!(info.embed_html.is_some());
1006        let html = info.embed_html.unwrap();
1007        assert!(html.contains("pdf-embed"));
1008        assert!(html.contains(r#"type="application/pdf""#));
1009        assert!(html.contains(url));
1010    }
1011
1012    #[tokio::test]
1013    async fn test_mp4_in_path_but_not_extension_goes_to_opengraph() {
1014        // URL has .mp4 in the path but not as the file extension
1015        // This should NOT match media detection and should proceed to OpenGraph
1016        let url = "https://example.com/videos/mp4-format/info";
1017        let result = PageInfo::new_from_url(url, 500).await;
1018        assert!(result.is_ok());
1019        let info = result.unwrap();
1020        // Should NOT have embed_html (or if it times out, it's a plain link)
1021        // The key is that it shouldn't detect as video
1022        if let Some(html) = &info.embed_html {
1023            assert!(!html.contains("<video"));
1024        }
1025    }
1026
1027    #[tokio::test]
1028    async fn test_bare_m4v_url_returns_video_embed() {
1029        // .m4v is a supported video extension (unlike .mov which Vid doesn't detect)
1030        let url = "https://example.com/videos/clip.m4v";
1031        let result = PageInfo::new_from_url(url, 500).await;
1032        assert!(result.is_ok());
1033        let info = result.unwrap();
1034        assert!(info.embed_html.is_some());
1035        let html = info.embed_html.unwrap();
1036        assert!(html.contains("<video"));
1037    }
1038
1039    #[tokio::test]
1040    async fn test_bare_wav_url_returns_audio_embed() {
1041        let url = "https://example.com/sounds/effect.wav";
1042        let result = PageInfo::new_from_url(url, 500).await;
1043        assert!(result.is_ok());
1044        let info = result.unwrap();
1045        assert!(info.embed_html.is_some());
1046        let html = info.embed_html.unwrap();
1047        assert!(html.contains("<audio"));
1048    }
1049
1050    // Tests for format_error_chain
1051    #[test]
1052    fn test_format_error_chain_single() {
1053        let err = std::io::Error::other("simple error");
1054        let chain = format_error_chain(&err);
1055        assert_eq!(chain, "simple error");
1056    }
1057
1058    #[test]
1059    fn test_format_error_chain_nested() {
1060        // Create a nested error chain using Box<dyn Error>
1061        #[derive(Debug)]
1062        struct OuterError {
1063            source: Box<dyn Error + Send + Sync>,
1064        }
1065        impl std::fmt::Display for OuterError {
1066            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1067                write!(f, "outer error")
1068            }
1069        }
1070        impl Error for OuterError {
1071            fn source(&self) -> Option<&(dyn Error + 'static)> {
1072                Some(self.source.as_ref())
1073            }
1074        }
1075
1076        let inner = std::io::Error::other("inner error");
1077        let outer = OuterError {
1078            source: Box::new(inner),
1079        };
1080        let chain = format_error_chain(&outer);
1081        assert!(chain.contains("outer error"));
1082        assert!(chain.contains("inner error"));
1083        assert!(chain.contains(" -> "));
1084    }
1085
1086    // Tests for is_supported_favicon_type
1087    #[test]
1088    fn test_supported_favicon_type_none() {
1089        // No type attribute means assume supported
1090        assert!(is_supported_favicon_type(None));
1091    }
1092
1093    #[test]
1094    fn test_supported_favicon_type_png() {
1095        assert!(is_supported_favicon_type(Some("image/png")));
1096    }
1097
1098    #[test]
1099    fn test_supported_favicon_type_svg() {
1100        assert!(is_supported_favicon_type(Some("image/svg+xml")));
1101    }
1102
1103    #[test]
1104    fn test_supported_favicon_type_ico() {
1105        assert!(is_supported_favicon_type(Some("image/x-icon")));
1106        assert!(is_supported_favicon_type(Some("image/vnd.microsoft.icon")));
1107    }
1108
1109    #[test]
1110    fn test_unsupported_favicon_type() {
1111        assert!(!is_supported_favicon_type(Some("image/webp")));
1112        assert!(!is_supported_favicon_type(Some("text/html")));
1113    }
1114
1115    // Tests for PageInfo::text()
1116    #[test]
1117    fn test_text_with_title() {
1118        let info = PageInfo {
1119            url: "https://example.com".to_string(),
1120            title: Some("My Title".to_string()),
1121            ..Default::default()
1122        };
1123        let text = info.text();
1124        assert_eq!(text, "My Title: https://example.com");
1125    }
1126
1127    #[test]
1128    fn test_text_without_title() {
1129        let info = PageInfo {
1130            url: "https://example.com".to_string(),
1131            ..Default::default()
1132        };
1133        let text = info.text();
1134        assert_eq!(text, "no title: https://example.com");
1135    }
1136
1137    // Tests for PageInfo::html()
1138    #[test]
1139    fn test_html_with_embed() {
1140        let info = PageInfo {
1141            url: "https://example.com".to_string(),
1142            embed_html: Some("<div>embedded</div>".to_string()),
1143            ..Default::default()
1144        };
1145        let html = info.html();
1146        assert_eq!(html, "<div>embedded</div>");
1147    }
1148
1149    #[test]
1150    fn test_html_with_title_and_image() {
1151        let info = PageInfo {
1152            url: "https://example.com".to_string(),
1153            title: Some("My Title".to_string()),
1154            description: Some("My description".to_string()),
1155            image: Some("https://example.com/image.png".to_string()),
1156            embed_html: None,
1157        };
1158        let html = info.html();
1159        assert!(html.contains("mbr-social-link-box"));
1160        assert!(html.contains("My Title"));
1161        assert!(html.contains("My description"));
1162        assert!(html.contains(r#"<img src="https://example.com/image.png"/>"#));
1163    }
1164
1165    #[test]
1166    fn test_html_with_title_no_image() {
1167        let info = PageInfo {
1168            url: "https://example.com".to_string(),
1169            title: Some("Just Title".to_string()),
1170            description: None,
1171            image: None,
1172            embed_html: None,
1173        };
1174        let html = info.html();
1175        assert!(html.contains("mbr-social-link-box"));
1176        assert!(html.contains("Just Title"));
1177        assert!(!html.contains("<img"));
1178    }
1179
1180    #[test]
1181    fn test_html_no_title_plain_link() {
1182        let info = PageInfo {
1183            url: "https://example.com/page".to_string(),
1184            title: None,
1185            description: Some("ignored without title".to_string()),
1186            image: None,
1187            embed_html: None,
1188        };
1189        let html = info.html();
1190        // Should be a plain link
1191        assert!(html.contains(r#"<a href="https://example.com/page">"#));
1192        assert!(html.contains("https://example.com/page</a>"));
1193        assert!(!html.contains("mbr-social-link-box"));
1194    }
1195
1196    #[test]
1197    fn test_html_image_with_quotes_escaped() {
1198        // Single-quote breakout attempt: harmless inside double-quoted attribute
1199        let info = PageInfo {
1200            url: "https://example.com".to_string(),
1201            title: Some("Title".to_string()),
1202            image: Some("x' onerror='alert(1)".to_string()),
1203            ..Default::default()
1204        };
1205        let html = info.html();
1206        // The whole payload stays inside the double-quoted src value
1207        assert!(html.contains(r#"<img src="x' onerror='alert(1)"/>"#));
1208
1209        // Double-quote breakout attempt: quote must be escaped to &quot;
1210        let info = PageInfo {
1211            url: "https://example.com".to_string(),
1212            title: Some("Title".to_string()),
1213            image: Some(r#"x" onerror="alert(1)"#.to_string()),
1214            ..Default::default()
1215        };
1216        let html = info.html();
1217        assert!(html.contains("&quot;"));
1218        // No parseable onerror attribute: the closing quote never appears raw
1219        assert!(!html.contains(r#"" onerror=""#));
1220    }
1221
1222    #[test]
1223    fn test_html_url_with_quote_escaped() {
1224        let evil_url = r#"https://example.com/"><script>alert(1)</script>"#;
1225
1226        // Titled-card branch
1227        let info = PageInfo {
1228            url: evil_url.to_string(),
1229            title: Some("Title".to_string()),
1230            ..Default::default()
1231        };
1232        let html = info.html();
1233        assert!(!html.contains(r#""><script>"#));
1234        assert!(!html.contains("<script>"));
1235
1236        // Plain-link branch
1237        let info = PageInfo {
1238            url: evil_url.to_string(),
1239            ..Default::default()
1240        };
1241        let html = info.html();
1242        assert!(!html.contains(r#""><script>"#));
1243        // Visible link text is text-escaped too
1244        assert!(!html.contains("<script>"));
1245        assert!(html.contains("&lt;script&gt;"));
1246    }
1247
1248    #[test]
1249    fn test_html_favicon_quote_breakout_escaped() {
1250        let info = PageInfo {
1251            url: "https://example.com".to_string(),
1252            title: Some("Title".to_string()),
1253            image: Some(r#"x.png"><script>alert(1)</script>"#.to_string()),
1254            ..Default::default()
1255        };
1256        let html = info.html();
1257        assert!(!html.contains("<script>"));
1258        assert!(html.contains("&quot;&gt;&lt;script&gt;"));
1259    }
1260
1261    #[test]
1262    fn test_extract_og_image_is_raw() {
1263        // og:image containing & must be stored raw and escaped exactly once at render
1264        let doc = Html::parse_document(
1265            r#"<html><head>
1266                <meta property="og:title" content="T"/>
1267                <meta property="og:image" content="https://example.com/img?a=1&b=2"/>
1268            </head><body></body></html>"#,
1269        );
1270        let head = doc.select(&HEAD_SELECTOR).next().unwrap();
1271        let image = extract_content_raw(&head, &OG_IMAGE_SELECTOR).unwrap();
1272        // Stored raw: scraper decodes the attribute, no entity-encoding applied
1273        assert_eq!(image, "https://example.com/img?a=1&b=2");
1274
1275        let info = PageInfo {
1276            url: "https://example.com".to_string(),
1277            title: Some("T".to_string()),
1278            image: Some(image),
1279            ..Default::default()
1280        };
1281        let html = info.html();
1282        // Escaped exactly once: &amp; not &amp;amp;
1283        assert!(html.contains(r#"<img src="https://example.com/img?a=1&amp;b=2"/>"#));
1284        assert!(!html.contains("&amp;amp;"));
1285    }
1286
1287    // Tests for Giphy URL with query params
1288    #[test]
1289    fn test_giphy_page_url_with_query() {
1290        let url = "https://giphy.com/gifs/cat-funny-CAxbo8KC2A0y4?utm_source=test";
1291        let embed = PageInfo::giphy_embed(url);
1292        assert!(embed.is_some());
1293        let html = embed.unwrap();
1294        assert!(html.contains("https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.gif"));
1295    }
1296
1297    #[test]
1298    fn test_giphy_webp_extension() {
1299        let url = "https://media.giphy.com/media/CAxbo8KC2A0y4/giphy.webp";
1300        let embed = PageInfo::giphy_embed(url);
1301        assert!(embed.is_some());
1302        let html = embed.unwrap();
1303        assert!(html.contains(url));
1304    }
1305}