//! DID-to-handle resolution for the `did:web:` method. //! //! A `did:web:` DID names a host that publishes its DID document at a //! well-known URL. The document in turn encodes the current handle as //! the first `alsoKnownAs` AT URI (`at://`). The PLC directory //! has no idea about these DIDs, so without this module the AppView's //! handle-sync worker would leave every `did:web:` post stuck on //! `@…` forever. //! //! URL shape (per the did:web spec, https://w3c-ccg.github.io/did-method-web): //! did:web:example.com -> https://example.com/.well-known/did.json //! did:web:example.com:user:alice -> https://example.com/user/alice/did.json //! //! Anything else — non-2xx, garbage body, no `alsoKnownAs` — collapses //! to `Ok(None)` so a misconfigured remote can't fail the worker. use anyhow::Result; use async_trait::async_trait; use reqwest::Client; use serde_json::Value; use crate::handle::DidHandleResolver; #[derive(Clone)] pub struct WebResolver { pub client: Client, /// URL scheme for the resolved well-known document. Production /// uses `"https"`; tests can flip this to `"http"` so a plain /// mock TCP listener can stand in for a real PDS. pub scheme: String, } impl WebResolver { pub fn new() -> Self { Self { client: Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .unwrap(), scheme: "https".to_string(), } } /// Build the did:web URL for a given DID. Returns `None` when the /// DID is empty, contains a traversal segment, or otherwise looks /// like a URL-injection attempt. pub(crate) fn did_to_url(&self, did: &str) -> Option { let rest = did.strip_prefix("did:web:")?; if rest.is_empty() { return None; } // Per spec, `:` inside the method-specific identifier separates // path components. Convert them to `/`. We also reject path // traversal (`..`) defensively. if rest.split(':').any(|seg| seg.is_empty() || seg == "..") { return None; } let host_path = rest.replace(':', "/"); Some(format!( "{}://{}/.well-known/did.json", self.scheme, host_path )) } } impl Default for WebResolver { fn default() -> Self { Self::new() } } #[async_trait] impl DidHandleResolver for WebResolver { async fn resolve_handle(&self, did: &str) -> Result> { // Anything that isn't `did:web:` is out of scope; let the next // resolver (PLC) take a swing instead of returning Err. if !did.starts_with("did:web:") { return Ok(None); } let url = match self.did_to_url(did) { Some(u) => u, None => return Ok(None), }; self.resolve_handle_at_url(&url).await } } impl WebResolver { /// Fetch `url` and parse out the first `at://` handle from the /// `alsoKnownAs` array. Public so integration tests can drive /// it directly against a mock HTTP listener bound to /// `127.0.0.1:PORT` (which can't be expressed as a did:web DID /// because the URL builder splits `:` into path segments). pub async fn resolve_handle_at_url( &self, url: &str, ) -> Result> { let resp = match self.client.get(url).send().await { Ok(r) => r, // Network-level failures are reported as Err so the worker // can distinguish "try again later" from "no answer". Err(e) => return Err(e.into()), }; let status = resp.status(); if status.as_u16() == 404 { // DID is syntactically valid but the host doesn't serve a // document — same semantics as a missing PLC entry. return Ok(None); } if !status.is_success() { // 5xx / weird codes — treat as "no answer". We don't want // a broken remote to spam the worker's `failed` counter. return Ok(None); } // Body might be invalid JSON; treat as Ok(None) instead of // bubbling an Err — the worker has no useful retry semantics // for malformed bodies. let v: Value = match resp.json().await { Ok(v) => v, Err(_) => return Ok(None), }; Ok(Self::extract_handle(&v)) } /// Pull the first `at://` URI out of a DID document's /// `alsoKnownAs` array. Returns `None` if the array is missing, /// empty, or only contains non-`at://` entries. pub(crate) fn extract_handle(v: &Value) -> Option { let aka = v.get("alsoKnownAs").and_then(|x| x.as_array())?; for entry in aka { if let Some(s) = entry.as_str() { if let Some(handle) = s.strip_prefix("at://") { if !handle.is_empty() { return Some(handle.to_string()); } } } } None } } #[cfg(test)] mod tests { use super::*; use std::time::Duration; fn http_resolver() -> WebResolver { WebResolver { client: Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap(), scheme: "http".to_string(), } } /// `did:web:pds.maarcadetweet.local` must URL-encode the host /// correctly: no path mangling, dots preserved. #[tokio::test] async fn did_to_url_preserves_dotted_host() { let r = WebResolver::new(); let url = r.did_to_url("did:web:pds.maarcadetweet.local").unwrap(); assert_eq!(url, "https://pds.maarcadetweet.local/.well-known/did.json"); } /// Multi-segment DIDs (`did:web:host:user:alice`) map to a nested /// path per the spec. #[tokio::test] async fn did_to_url_handles_path_segments() { let r = WebResolver::new(); let url = r.did_to_url("did:web:example.com:user:alice").unwrap(); assert_eq!(url, "https://example.com/user/alice/.well-known/did.json"); } /// Garbage DIDs must short-circuit with `None` and never try to /// build a URL we could be tricked into requesting. #[tokio::test] async fn did_to_url_rejects_garbage() { let r = WebResolver::new(); assert!(r.did_to_url("did:web:").is_none()); assert!(r.did_to_url("did:web:..").is_none()); assert!(r.did_to_url("did:web:example.com:..").is_none()); assert!(r.did_to_url("did:web::empty").is_none()); } /// Non-`did:web:` DIDs are out of scope; must return `Ok(None)` /// without touching the network. #[tokio::test] async fn resolve_skips_non_web_dids() { let resolver = http_resolver(); let r = tokio::time::timeout( Duration::from_millis(200), resolver.resolve_handle("did:plc:abc"), ) .await .expect("non-did:web must not block on the network") .unwrap(); assert!(r.is_none()); } }