use anyhow::{Context as _, Result};
use hickory_resolver::TokioResolver;
use reqwest::Client;
use super::{fetch, identity};
use crate::net;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedAccount {
pub did: String,
pub pds_url: String,
pub handle: Option<String>,
}
pub fn resolver() -> Result<TokioResolver> {
let builder = TokioResolver::builder_tokio().context("reading the system DNS configuration")?;
builder.build().context("building the DNS resolver")
}
fn is_no_records(err: &hickory_resolver::net::NetError) -> bool {
matches!(
err,
hickory_resolver::net::NetError::Dns(hickory_resolver::net::DnsError::NoRecordsFound(_))
)
}
fn txt_query_name(handle: &str) -> String {
format!("_atproto.{}.", handle.trim_end_matches('.'))
}
pub async fn did_from_dns(resolver: &TokioResolver, handle: &str) -> Result<Option<String>> {
let name = txt_query_name(handle);
let lookup = match resolver.txt_lookup(&name).await {
Ok(lookup) => lookup,
Err(err) if is_no_records(&err) => return Ok(None),
Err(err) => {
return Err(anyhow::Error::new(err))
.with_context(|| format!("resolving the {name} TXT record"))
}
};
let records: Vec<String> = lookup
.answers()
.iter()
.filter_map(|record| match &record.data {
hickory_resolver::proto::rr::RData::TXT(txt) => Some(txt),
_ => None,
})
.map(|txt| {
let chunks: Vec<&[u8]> = txt.txt_data.iter().map(|c| c.as_ref()).collect();
identity::join_txt_chunks(&chunks)
})
.collect();
identity::did_from_txt_records(&records)
.with_context(|| format!("reading the {name} TXT record"))
}
async fn did_from_well_known(http: &Client, handle: &str) -> Result<String> {
let url = format!("https://{handle}/.well-known/atproto-did");
let response = net::guarded_get_no_privacy(http, &url, &[])
.await
.with_context(|| format!("fetching {url}"))?;
let status = response.status().as_u16();
if status != 200 {
anyhow::bail!("{url} returned status {status}");
}
let body = net::read_capped(response).await?;
identity::did_from_well_known(&String::from_utf8_lossy(&body))
.with_context(|| format!("reading {url}"))
}
pub async fn did_for_handle(
resolver: &TokioResolver,
http: &Client,
handle: &str,
) -> Result<String> {
let dns = did_from_dns(resolver, handle).await?;
prefer_dns(dns, || did_from_well_known(http, handle)).await
}
pub async fn prefer_dns<F, Fut>(dns: Option<String>, well_known: F) -> Result<String>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<String>>,
{
match dns {
Some(did) => Ok(did),
None => well_known().await,
}
}
pub async fn did_document(
http: &Client,
did: &str,
plc_directory: &str,
) -> Result<serde_json::Value> {
let url = identity::did_document_url(did, plc_directory)?;
let document = fetch::get_json(http, &url, fetch::DID_JSON).await?;
identity::validate_did_document(&document, did)?;
Ok(document)
}
pub async fn resolve(
resolver: &TokioResolver,
http: &Client,
subject: &str,
plc_directory: &str,
) -> Result<ResolvedAccount> {
if identity::is_atproto_did(subject) {
let document = did_document(http, subject, plc_directory).await?;
let reverse = match identity::declared_handle(&document) {
Some(handle) => did_for_handle(resolver, http, &handle).await.ok(),
None => None,
};
return account_from_did(&document, subject, reverse.as_deref());
}
let handle = identity::normalize_handle(subject)?;
let did = did_for_handle(resolver, http, &handle).await?;
let document = did_document(http, &did, plc_directory).await?;
account_from_handle(&document, &handle, &did)
}
pub fn account_from_handle(
document: &serde_json::Value,
handle: &str,
did: &str,
) -> Result<ResolvedAccount> {
identity::verify_handle_claim(document, handle)?;
Ok(ResolvedAccount {
pds_url: identity::pds_endpoint(document, did)?,
did: did.to_string(),
handle: Some(handle.to_string()),
})
}
pub fn account_from_did(
document: &serde_json::Value,
did: &str,
reverse: Option<&str>,
) -> Result<ResolvedAccount> {
let claimed = identity::declared_handle(document);
let handle = match (&claimed, reverse) {
(Some(_), Some(back)) if back == did => claimed.clone(),
_ => None,
};
Ok(ResolvedAccount {
pds_url: identity::pds_endpoint(document, did)?,
did: did.to_string(),
handle,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_txt_query_name_is_fully_qualified() {
let name = txt_query_name("alice.example.com");
assert!(
name.ends_with('.'),
"not an FQDN, so the DNS search list applies: {name}"
);
assert_eq!(name, "_atproto.alice.example.com.");
assert!(!name.contains(".."), "double dot in {name}");
}
#[tokio::test]
async fn a_missing_txt_record_is_absent_rather_than_an_error() {
let resolver = resolver().unwrap();
let result = did_from_dns(&resolver, "nonexistent-handle.invalid").await;
assert!(matches!(result, Ok(None)), "got {result:?}");
}
#[tokio::test]
async fn a_resolver_failure_is_an_error_rather_than_absence() {
let label = "a".repeat(60);
let handle = format!("{label}.{label}.{label}.{label}.com");
assert!(handle.len() > 240 && handle.len() <= 253);
assert!(
identity::normalize_handle(&handle).is_ok(),
"the handle itself must be valid, or the test proves nothing"
);
let resolver = resolver().unwrap();
let result = did_from_dns(&resolver, &handle).await;
assert!(
result.is_err(),
"a name-construction failure was reported as 'no record': {result:?}"
);
}
#[tokio::test]
async fn the_well_known_fallback_fails_closed_on_an_internal_host() {
let err = did_from_well_known(&Client::new(), "127.0.0.1")
.await
.expect_err("must refuse a loopback handle host");
let rendered = format!("{err:#}");
assert!(
rendered.contains("forbidden (internal) address"),
"failed for the wrong reason: {rendered}"
);
}
#[tokio::test]
async fn a_reserved_tld_handle_is_refused_before_any_lookup() {
let resolver = resolver().unwrap();
let err = resolve(
&resolver,
&Client::new(),
"alice.internal",
"https://plc.directory",
)
.await
.expect_err("must refuse a reserved TLD");
assert!(format!("{err:#}").contains("reserved TLD"));
}
const SUBJECT_DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
fn doc_claiming(handle: &str) -> serde_json::Value {
serde_json::json!({
"id": SUBJECT_DID,
"alsoKnownAs": [format!("at://{handle}")],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}]
})
}
#[test]
fn a_handle_the_document_does_not_claim_is_refused() {
let document = doc_claiming("someone-else.com");
let err = account_from_handle(&document, "victim.com", SUBJECT_DID)
.expect_err("a document that claims a different handle must be refused");
assert!(
format!("{err:#}").contains("victim.com"),
"failed for the wrong reason: {err:#}"
);
let ok = account_from_handle(&doc_claiming("alice.com"), "alice.com", SUBJECT_DID)
.expect("a matching claim must resolve");
assert_eq!(ok.handle.as_deref(), Some("alice.com"));
assert_eq!(ok.pds_url, "https://pds.example.com");
}
#[test]
fn a_did_first_handle_must_round_trip_to_the_same_did() {
let document = doc_claiming("alice.com");
let ok = account_from_did(&document, SUBJECT_DID, Some(SUBJECT_DID)).unwrap();
assert_eq!(ok.handle.as_deref(), Some("alice.com"));
let other = account_from_did(
&document,
SUBJECT_DID,
Some("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"),
)
.unwrap();
assert_eq!(
other.handle, None,
"a handle that resolves to a DIFFERENT did was reported as verified"
);
let none = account_from_did(&document, SUBJECT_DID, None).unwrap();
assert_eq!(none.handle, None);
assert_eq!(none.pds_url, "https://pds.example.com");
}
#[tokio::test]
async fn dns_wins_and_the_well_known_lookup_is_never_called() {
let called = std::sync::atomic::AtomicUsize::new(0);
let did = prefer_dns(Some(SUBJECT_DID.to_string()), || async {
called.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".to_string())
})
.await
.unwrap();
assert_eq!(did, SUBJECT_DID, "the DNS answer must win");
assert_eq!(
called.load(std::sync::atomic::Ordering::SeqCst),
0,
"the well-known lookup ran even though DNS had answered"
);
}
#[tokio::test]
async fn the_well_known_lookup_runs_when_dns_has_no_record() {
let did = prefer_dns(None, || async { Ok(SUBJECT_DID.to_string()) })
.await
.unwrap();
assert_eq!(did, SUBJECT_DID);
}
}