maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "at-identity"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "DID, PLC, handle resolution"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
at-crypto = { workspace = true }
|
||||
at-shared = { workspace = true }
|
||||
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use at_shared::did::Did;
|
||||
|
||||
/// Resolve a human-readable handle (`alice.bsky.social`) to its [`Did`].
|
||||
///
|
||||
/// Distinct from [`DidHandleResolver`], which is the inverse — it resolves
|
||||
/// a DID back to its current handle. Both live in the same module so the
|
||||
/// AppView's handle-sync worker can plug in a stub for tests.
|
||||
#[async_trait]
|
||||
pub trait HandleResolver: Send + Sync {
|
||||
async fn resolve(&self, handle: &str) -> Result<Option<Did>>;
|
||||
}
|
||||
|
||||
/// Resolve a DID (e.g. `did:plc:...`) to its current handle, if known.
|
||||
///
|
||||
/// Returns `Ok(None)` — never `Err` — when the handle can't be determined
|
||||
/// for legitimate reasons (e.g. unknown DID or unsupported method such as
|
||||
/// `did:web:`). `Err(_)` is reserved for genuine network / protocol
|
||||
/// failures so the worker can distinguish "nothing to do" from "try again
|
||||
/// next pass".
|
||||
#[async_trait]
|
||||
pub trait DidHandleResolver: Send + Sync {
|
||||
async fn resolve_handle(&self, did: &str) -> Result<Option<String>>;
|
||||
}
|
||||
|
||||
pub struct WellKnownResolver {
|
||||
pub client: reqwest::Client,
|
||||
pub dns_zone: String,
|
||||
}
|
||||
|
||||
impl WellKnownResolver {
|
||||
pub fn new(dns_zone: String) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap(),
|
||||
dns_zone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HandleResolver for WellKnownResolver {
|
||||
async fn resolve(&self, handle: &str) -> Result<Option<Did>> {
|
||||
if let Some(zone) = handle.strip_prefix('@') {
|
||||
if zone.ends_with(&self.dns_zone.trim_start_matches('.')) {
|
||||
let user = handle.trim_start_matches('@').trim_end_matches(&self.dns_zone);
|
||||
if let Some(did) = self.lookup_local(user).await? {
|
||||
return Ok(Some(did));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(resp) = self
|
||||
.client
|
||||
.get(format!("https://{}/.well-known/atproto-did", handle))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if resp.status().is_success() {
|
||||
let body = resp.text().await?;
|
||||
let did: Did = body.trim().parse()?;
|
||||
return Ok(Some(did));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl WellKnownResolver {
|
||||
async fn lookup_local(&self, user: &str) -> Result<Option<Did>> {
|
||||
let _ = user;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_handle(handle: &str, resolver: &dyn HandleResolver) -> Result<Option<Did>> {
|
||||
resolver.resolve(handle).await
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod handle;
|
||||
pub mod plc;
|
||||
pub mod web;
|
||||
|
||||
pub use handle::{resolve_handle, DidHandleResolver, HandleResolver};
|
||||
pub use plc::{submit_op, PlcClient};
|
||||
pub use web::WebResolver;
|
||||
@@ -0,0 +1,244 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use at_crypto::plc_op::PlcOperation;
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::handle::DidHandleResolver;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlcClient {
|
||||
pub base_url: String,
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl PlcClient {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit(&self, did: &str, op: &PlcOperation) -> Result<String> {
|
||||
let url = format!("{}/{}", self.base_url, did);
|
||||
let body = serde_json::to_value(op)?;
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("plc submit failed: {} {}", status, text);
|
||||
}
|
||||
let v: Value = resp.json().await?;
|
||||
Ok(v.get("cid")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Resolve `did:plc:<id>` to its current handle by reading
|
||||
/// `<base_url>/<did>/data` and pulling out the `handle` field.
|
||||
///
|
||||
/// Only `did:plc:` is currently supported; `did:web:` and other
|
||||
/// methods return `Ok(None)` (the AppView's handle-sync worker treats
|
||||
/// `None` as "skip, try again later", not as an error).
|
||||
pub async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
|
||||
DidHandleResolver::resolve_handle(self, did).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DidHandleResolver for PlcClient {
|
||||
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
|
||||
// We only know how to look up PLC DIDs. Anything else (did:web:,
|
||||
// did:key:, etc.) is reported as "no handle available" rather
|
||||
// than an error.
|
||||
let rest = match did.strip_prefix("did:plc:") {
|
||||
Some(r) => r,
|
||||
None => return Ok(None),
|
||||
};
|
||||
// Sanity-check the suffix so we don't construct weird URLs.
|
||||
if rest.is_empty() || rest.contains('/') {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let url = format!("{}/{}/data", self.base_url, did);
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 404 {
|
||||
// DID exists syntactically but isn't registered. Not an error.
|
||||
return Ok(None);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("plc lookup failed: {} {}", status, text);
|
||||
}
|
||||
let v: Value = resp.json().await?;
|
||||
// Modern PLC DID documents don't carry a top-level `handle` field
|
||||
// (deprecated in 2024); the handle is encoded as the first
|
||||
// `alsoKnownAs` AT URI: `at://<handle>`. We try both, preferring
|
||||
// `alsoKnownAs` so we handle current docs, then falling back to
|
||||
// the legacy `handle` field for older ones.
|
||||
if let Some(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 Ok(Some(handle.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(v.get("handle")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_op(client: &PlcClient, did: &str, op: &PlcOperation) -> Result<String> {
|
||||
client.submit(did, op).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A 404 from plc.directory (e.g. unknown DID) must come back as
|
||||
/// `Ok(None)` — never `Err(_)` — so the worker doesn't log it as a
|
||||
/// transient failure every pass.
|
||||
#[tokio::test]
|
||||
async fn resolve_handle_returns_none_on_404() {
|
||||
// Spin up a tiny mock server that always returns 404.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
// Read the request line + headers (don't care about body).
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
|
||||
let _ = sock.write_all(resp).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let client = PlcClient::new(format!("http://{addr}"));
|
||||
let r = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client.resolve_handle("did:plc:nobody"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(r.is_none(), "404 must map to Ok(None), got {r:?}");
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// A 2xx response with the expected `handle` field should round-trip.
|
||||
#[tokio::test]
|
||||
async fn resolve_handle_parses_handle_field() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let body = br#"{"id":"did:plc:abc","handle":"alice.bsky.social"}"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.write_all(body).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let client = PlcClient::new(format!("http://{addr}"));
|
||||
let r = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client.resolve_handle("did:plc:abc"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(r.as_deref(), Some("alice.bsky.social"));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// Modern PLC DID documents encode the handle in `alsoKnownAs[0]` as
|
||||
/// `at://<handle>` instead of a top-level field. Real-world docs
|
||||
/// (e.g. Bluesky's) look like this — must be parsed correctly.
|
||||
#[tokio::test]
|
||||
async fn resolve_handle_parses_alsoKnownAs() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let body = br#"{"did":"did:plc:abc","alsoKnownAs":["at://alice.bsky.social"],"services":{}}"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.write_all(body).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let client = PlcClient::new(format!("http://{addr}"));
|
||||
let r = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client.resolve_handle("did:plc:abc"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(r.as_deref(), Some("alice.bsky.social"));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// `did:web:` is explicitly out of scope for now. Make sure we
|
||||
/// short-circuit with `Ok(None)` and never touch the network.
|
||||
#[tokio::test]
|
||||
async fn resolve_handle_skips_did_web() {
|
||||
// Construct a client pointed at an unreachable address — if our
|
||||
// implementation actually tried to hit it, this would time out.
|
||||
let client = PlcClient::new("http://127.0.0.1:1");
|
||||
let r = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
client.resolve_handle("did:web:example.com"),
|
||||
)
|
||||
.await
|
||||
.expect("did:web must not block on the network")
|
||||
.unwrap();
|
||||
assert!(r.is_none());
|
||||
}
|
||||
|
||||
/// Garbage DIDs (empty suffix, embedded slash) must be rejected
|
||||
/// without a network round-trip.
|
||||
#[tokio::test]
|
||||
async fn resolve_handle_rejects_garbage_did() {
|
||||
let client = PlcClient::new("http://127.0.0.1:1");
|
||||
for bad in ["did:plc:", "did:plc:/etc/passwd"] {
|
||||
let r = client.resolve_handle(bad).await.unwrap();
|
||||
assert!(r.is_none(), "{bad} must yield None, got {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 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://<handle>`). 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
|
||||
//! `@<did-prefix>…` 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<String> {
|
||||
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<Option<String>> {
|
||||
// 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<Option<String>> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Integration tests for [`WebResolver`].
|
||||
//!
|
||||
//! These spin up a tiny mock HTTP server on `127.0.0.1:0` (just like
|
||||
//! the PLC tests do). Because the DID-to-URL builder splits on `:`
|
||||
//! to turn path components into URL segments, encoding `127.0.0.1:PORT`
|
||||
//! in a DID doesn't produce a URL the mock can answer — so the tests
|
||||
//! drive the resolver through its crate-internal `resolve_handle_at_url`
|
||||
//! seam, which takes a URL directly. Production code never calls it;
|
||||
//! tests do, so we can exercise the full HTTP round-trip without TLS.
|
||||
//!
|
||||
//! Each test runs the resolver inside a 2-second timeout so a hung
|
||||
//! connection can't freeze the suite.
|
||||
|
||||
use at_identity::WebResolver;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Spawn a single-shot mock HTTP server that always responds with
|
||||
/// `status` + `body`, regardless of path. Returns the base URL.
|
||||
async fn mock_server(status: u16, body: &'static [u8]) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
404 => "Not Found",
|
||||
500 => "Internal Server Error",
|
||||
_ => "Status",
|
||||
};
|
||||
let header = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(header.as_bytes()).await;
|
||||
if !body.is_empty() {
|
||||
let _ = sock.write_all(body).await;
|
||||
}
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn resolver() -> WebResolver {
|
||||
WebResolver {
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap(),
|
||||
scheme: "https".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve(r: &WebResolver, base: &str, path: &str) -> anyhow::Result<Option<String>> {
|
||||
let url = format!("{base}{path}");
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
r.resolve_handle_at_url(&url),
|
||||
)
|
||||
.await
|
||||
.expect("timed out talking to mock server")
|
||||
}
|
||||
|
||||
/// Happy path: a DID document with
|
||||
/// `alsoKnownAs: ["at://alice.example.com"]` must yield
|
||||
/// `Some("alice.example.com")`.
|
||||
#[tokio::test]
|
||||
async fn resolve_web_handle_returns_handle_from_alsoKnownAs() {
|
||||
let body = br#"{"id":"did:web:example.com","alsoKnownAs":["at://alice.example.com"],"verificationMethod":[]}"#;
|
||||
let base = mock_server(200, body).await;
|
||||
let r = resolver();
|
||||
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
||||
assert_eq!(got.as_deref(), Some("alice.example.com"));
|
||||
}
|
||||
|
||||
/// A 404 from the remote must collapse to `Ok(None)`, never `Err`.
|
||||
#[tokio::test]
|
||||
async fn resolve_web_handle_handles_404() {
|
||||
let base = mock_server(404, b"").await;
|
||||
let r = resolver();
|
||||
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
||||
assert!(got.is_none(), "404 must yield None, got {got:?}");
|
||||
}
|
||||
|
||||
/// A 2xx with a non-JSON body (think: an HTML error page served by
|
||||
/// a misconfigured reverse proxy) must also collapse to `Ok(None)`
|
||||
/// so the worker doesn't see it as a transient failure.
|
||||
#[tokio::test]
|
||||
async fn resolve_web_handle_handles_invalid_json() {
|
||||
let base = mock_server(200, b"<html>not json</html>").await;
|
||||
let r = resolver();
|
||||
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
||||
assert!(got.is_none(), "invalid JSON must yield None, got {got:?}");
|
||||
}
|
||||
|
||||
/// A 2xx with valid JSON but no `alsoKnownAs` field must yield
|
||||
/// `Ok(None)` (the document doesn't advertise a handle).
|
||||
#[tokio::test]
|
||||
async fn resolve_web_handle_handles_missing_alsoKnownAs() {
|
||||
let body = br#"{"id":"did:web:example.com","verificationMethod":[]}"#;
|
||||
let base = mock_server(200, body).await;
|
||||
let r = resolver();
|
||||
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
||||
assert!(got.is_none(), "missing alsoKnownAs must yield None");
|
||||
}
|
||||
|
||||
/// A `alsoKnownAs` array that doesn't contain any `at://` URI must
|
||||
/// yield `Ok(None)`.
|
||||
#[tokio::test]
|
||||
async fn resolve_web_handle_ignores_non_at_uris() {
|
||||
let body = br#"{"id":"did:web:example.com","alsoKnownAs":["https://example.com","mailto:foo"]}"#;
|
||||
let base = mock_server(200, body).await;
|
||||
let r = resolver();
|
||||
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
||||
assert!(
|
||||
got.is_none(),
|
||||
"non-at:// entries must not be treated as handles, got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multiple `alsoKnownAs` entries: the **first** `at://` wins. This
|
||||
/// matches the PLC client's behavior and the way real-world PDSes
|
||||
/// list their primary handle first.
|
||||
#[tokio::test]
|
||||
async fn resolve_web_handle_picks_first_at_uri() {
|
||||
let body = br#"{"id":"did:web:example.com","alsoKnownAs":["at://first.example.com","at://second.example.com"]}"#;
|
||||
let base = mock_server(200, body).await;
|
||||
let r = resolver();
|
||||
let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap();
|
||||
assert_eq!(got.as_deref(), Some("first.example.com"));
|
||||
}
|
||||
Reference in New Issue
Block a user