From b7ab12898e5cd18f559d132022d7e67253c1b1c5 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:38:52 +0200 Subject: [PATCH] feat(http): add UrlKind enum and probe_url_kind HEAD probe --- crates/archivr-core/src/downloader/http.rs | 81 ++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/crates/archivr-core/src/downloader/http.rs b/crates/archivr-core/src/downloader/http.rs index 7ac57c5..773789e 100644 --- a/crates/archivr-core/src/downloader/http.rs +++ b/crates/archivr-core/src/downloader/http.rs @@ -3,6 +3,69 @@ use std::path::Path; use crate::hash::hash_file; +/// Whether a URL resolves to an HTML document or a downloadable file. +#[derive(Debug, PartialEq, Eq)] +pub enum UrlKind { + Html, + File, +} + +/// Probes `url` with a HEAD request and inspects the `Content-Type` header. +/// Falls back to a GET request (body not read) if the server returns 405. +/// +/// Returns `Err` if the probe fails (network error, non-2xx/405 status). +/// Redirects (3xx) are followed automatically by reqwest; only the final +/// response status is checked. +pub fn probe_url_kind(url: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::limited(10)) + .user_agent("archivr/0.1") + .build() + .context("failed to build HTTP client")?; + + // Prefer HEAD: no body transfer. + let head = client + .head(url) + .send() + .with_context(|| format!("failed to probe {url}"))?; + + if head.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { + // Server rejected HEAD — do a GET but only inspect headers. + let get = client + .get(url) + .send() + .with_context(|| format!("failed to probe {url}"))?; + if !get.status().is_success() { + bail!("HTTP {} probing {url}", get.status()); + } + let ct = get + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + return Ok(if content_type_is_html(ct) { + UrlKind::Html + } else { + UrlKind::File + }); + } + + if !head.status().is_success() { + bail!("HTTP {} probing {url}", head.status()); + } + + let ct = head + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + Ok(if content_type_is_html(ct) { + UrlKind::Html + } else { + UrlKind::File + }) +} + /// Returns `(sha256_hex, extension_with_leading_dot, title_hint)` on success. /// `title_hint` is derived from the `Content-Disposition` filename header, or the /// last path segment of the final URL after redirects. @@ -319,4 +382,22 @@ mod tests { fn percent_decode_plus_is_space() { assert_eq!(percent_decode("hello+world"), "hello world"); } + + #[test] + fn url_kind_html_variants() { + // content_type_is_html is already tested; verify UrlKind is wired correctly + // by checking the enum values exist and are distinct. + assert_ne!(UrlKind::Html, UrlKind::File); + } + + #[test] + fn probe_url_kind_fails_on_unreachable_host() { + // 127.0.0.1:1 is guaranteed to refuse connections. + let err = probe_url_kind("http://127.0.0.1:1/").unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("probe") || msg.contains("connect") || msg.contains("refused"), + "unexpected error message: {msg}" + ); + } }