use std::time::Duration;
use reqwest::redirect::Policy;
use reqwest::{Client, ClientBuilder, header};
use tracing::debug;
use url::Url;
use crate::{Account, Error, Jrd};
pub const DEFAULT_MAX_BODY_BYTES: u64 = 64 * 1024;
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
pub fn recommended_client() -> Result<Client, Error> {
Ok(ClientBuilder::new()
.timeout(DEFAULT_REQUEST_TIMEOUT)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.redirect(Policy::custom(|attempt| {
const MAX_REDIRECTS: usize = 2;
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("too many redirects");
}
let origin = attempt.previous().first().unwrap_or_else(|| attempt.url());
if origin.host_str() == attempt.url().host_str()
&& origin.scheme() == attempt.url().scheme()
{
attempt.follow()
} else {
attempt.error("cross-origin redirect forbidden for WebFinger")
}
}))
.build()?)
}
pub async fn resolve(account: &Account, client: &Client) -> Result<Jrd, Error> {
let url = account.webfinger_url()?;
let expected = account.to_resource();
fetch_at(&url, Some(&expected), client).await
}
pub async fn fetch_at(
url: &Url,
expected_subject: Option<&str>,
client: &Client,
) -> Result<Jrd, Error> {
fetch_at_with_limit(url, expected_subject, client, DEFAULT_MAX_BODY_BYTES).await
}
pub async fn fetch_at_with_limit(
url: &Url,
expected_subject: Option<&str>,
client: &Client,
max_body_bytes: u64,
) -> Result<Jrd, Error> {
debug!(%url, max_body_bytes, "fetching WebFinger JRD");
let response = client
.get(url.clone())
.header(
header::ACCEPT,
format!("{jrd}, application/json", jrd = crate::MEDIA_TYPE),
)
.send()
.await?;
let status = response.status();
if !status.is_success() {
return Err(Error::BadStatus(status.as_u16()));
}
let body = read_capped(response, max_body_bytes).await?;
let jrd: Jrd = serde_json::from_slice(&body)?;
if jrd.subject.is_empty() {
return Err(Error::MissingSubject);
}
if let Some(expected) = expected_subject
&& jrd.subject != expected
&& !jrd.aliases.iter().any(|a| a == expected)
{
return Err(Error::SubjectMismatch {
requested: expected.to_owned(),
returned: jrd.subject,
});
}
Ok(jrd)
}
async fn read_capped(
mut response: reqwest::Response,
max_body_bytes: u64,
) -> Result<Vec<u8>, Error> {
let mut acc: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await? {
if max_body_bytes > 0 && (acc.len() as u64 + chunk.len() as u64) > max_body_bytes {
return Err(Error::ResponseTooLarge(max_body_bytes));
}
acc.extend_from_slice(&chunk);
}
Ok(acc)
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::rels;
fn mock_url(server: &MockServer, resource: &str) -> Url {
format!(
"{base}/.well-known/webfinger?resource={resource}",
base = server.uri(),
)
.parse()
.expect("mock URL must parse")
}
#[tokio::test]
async fn fetch_at_returns_parsed_jrd_when_subject_matches() {
let server = MockServer::start().await;
let subject = "acct:alice@example.com";
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": subject,
"links": [{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/users/alice"
}]
})))
.mount(&server)
.await;
let jrd = fetch_at(&mock_url(&server, subject), Some(subject), &Client::new())
.await
.expect("fetch should succeed");
assert_eq!(jrd.subject, subject);
assert_eq!(
jrd.activitypub_actor()
.expect("actor link must be present")
.rel,
rels::ACTIVITYPUB_ACTOR,
);
}
#[tokio::test]
async fn fetch_at_accepts_expected_subject_in_aliases() {
let server = MockServer::start().await;
let canonical = "acct:Alice@example.com";
let queried = "acct:alice@EXAMPLE.COM";
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": canonical,
"aliases": [queried],
})))
.mount(&server)
.await;
let jrd = fetch_at(&mock_url(&server, queried), Some(queried), &Client::new())
.await
.expect("alias match should satisfy the subject check");
assert_eq!(jrd.subject, canonical);
}
#[tokio::test]
async fn fetch_at_rejects_mismatched_subject() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": "acct:attacker@evil.example",
})))
.mount(&server)
.await;
let err = fetch_at(
&mock_url(&server, "acct:alice@example.com"),
Some("acct:alice@example.com"),
&Client::new(),
)
.await
.expect_err("mismatched subject must produce an error");
assert!(
matches!(err, Error::SubjectMismatch { .. }),
"expected SubjectMismatch, got {err:?}",
);
}
#[tokio::test]
async fn fetch_at_rejects_empty_subject() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": "",
})))
.mount(&server)
.await;
let err = fetch_at(
&mock_url(&server, "acct:alice@example.com"),
None,
&Client::new(),
)
.await
.expect_err("empty subject must produce an error");
assert!(
matches!(err, Error::MissingSubject),
"expected MissingSubject, got {err:?}",
);
}
#[tokio::test]
async fn fetch_at_reports_bad_status_on_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let err = fetch_at(
&mock_url(&server, "acct:alice@example.com"),
None,
&Client::new(),
)
.await
.expect_err("404 response must propagate as BadStatus");
assert!(
matches!(err, Error::BadStatus(404)),
"expected BadStatus(404), got {err:?}",
);
}
#[tokio::test]
async fn fetch_at_skips_subject_check_when_expected_is_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": "acct:anyone@any.example",
})))
.mount(&server)
.await;
let jrd = fetch_at(
&mock_url(&server, "acct:anyone@any.example"),
None,
&Client::new(),
)
.await
.expect("None-expected must skip subject verification");
assert_eq!(jrd.subject, "acct:anyone@any.example");
}
#[test]
fn resolve_future_is_send() {
fn assert_send<F: Send>(_: F) {}
let client = Client::new();
let account = Account::parse("acct:a@b.example").expect("valid acct");
assert_send(resolve(&account, &client));
let url: Url = "https://example.com/.well-known/webfinger"
.parse()
.expect("valid URL");
assert_send(fetch_at(&url, None, &client));
}
#[tokio::test]
async fn fetch_at_rejects_body_exceeding_size_cap() {
let server = MockServer::start().await;
let big = "x".repeat(65_536);
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
format!(r#"{{"subject":"acct:a@b.example","padding":"{big}"}}"#).into_bytes(),
"application/jrd+json",
))
.mount(&server)
.await;
let err = fetch_at_with_limit(
&mock_url(&server, "acct:a@b.example"),
None,
&Client::new(),
1024, )
.await
.expect_err("oversize body must be rejected");
assert!(
matches!(err, Error::ResponseTooLarge(1024)),
"expected ResponseTooLarge(1024), got {err:?}",
);
}
#[tokio::test]
async fn fetch_at_accepts_body_under_the_default_cap() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": "acct:a@b.example",
"links": [{"rel": "self", "type": "application/activity+json", "href": "https://b.example/u/a"}],
})))
.mount(&server)
.await;
let jrd = fetch_at(
&mock_url(&server, "acct:a@b.example"),
Some("acct:a@b.example"),
&Client::new(),
)
.await
.expect("well-sized response must succeed");
assert_eq!(jrd.subject, "acct:a@b.example");
}
#[test]
fn recommended_client_builds_without_panicking() {
let _ = recommended_client().expect("TLS stack must initialise");
}
#[tokio::test]
async fn recommended_client_times_out_on_slow_responder() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "subject": "acct:a@b.example" }))
.set_delay(Duration::from_secs(30)),
)
.mount(&server)
.await;
let client = recommended_client().expect("recommended client must build");
let started = std::time::Instant::now();
let result = fetch_at(&mock_url(&server, "acct:a@b.example"), None, &client).await;
let elapsed = started.elapsed();
assert!(
result.is_err(),
"slow responder must surface as an error, not a delayed Ok",
);
assert!(
elapsed < DEFAULT_REQUEST_TIMEOUT + Duration::from_secs(5),
"fetch_at took {elapsed:?}, expected timeout near {DEFAULT_REQUEST_TIMEOUT:?}",
);
}
}