Skip to main content

solid_pod_rs/
interop.rs

1//! Interop / discovery helpers.
2//!
3//! This module rounds out the crate's public Solid surface with small,
4//! framework-agnostic helpers for ecosystem discovery flows:
5//!
6//! - **`.well-known/solid`** — Solid Protocol §4.1.2 discovery document.
7//! - **WebFinger** — RFC 7033, used to map acct: URIs to WebIDs.
8//! - **NIP-05 verification** — Nostr pubkey ↔ DNS name binding.
9//! - **Dev-mode session bypass** — consumer-crate helper for tests.
10//!
11//! None of these helpers perform network I/O on their own; they return
12//! response bodies and signal objects that the consumer crate wires
13//! into its HTTP server.
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::PodError;
18
19// ---------------------------------------------------------------------------
20// .well-known/solid discovery document
21// ---------------------------------------------------------------------------
22
23/// Solid Protocol `.well-known/solid` discovery document. The doc
24/// advertises the OIDC issuer, the pod URL, and the Notifications
25/// endpoint. JSS parity: includes `api.accounts` URLs.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SolidWellKnown {
28    #[serde(rename = "@context")]
29    pub context: serde_json::Value,
30
31    pub solid_oidc_issuer: String,
32
33    pub notification_gateway: String,
34
35    pub storage: String,
36
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub webfinger: Option<String>,
39
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub api: Option<SolidWellKnownApi>,
42}
43
44/// JSS-compatible account management API pointers.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SolidWellKnownApi {
47    pub accounts: SolidWellKnownAccounts,
48}
49
50/// JSS-compatible account endpoint URLs.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SolidWellKnownAccounts {
53    pub new: String,
54    pub recover: String,
55    pub signin: String,
56    pub signout: String,
57}
58
59/// Build the discovery document for a pod root.
60pub fn well_known_solid(pod_base: &str, oidc_issuer: &str) -> SolidWellKnown {
61    let base = pod_base.trim_end_matches('/');
62    SolidWellKnown {
63        context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
64        solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
65        notification_gateway: format!("{base}/.notifications"),
66        storage: format!("{base}/"),
67        webfinger: Some(format!("{base}/.well-known/webfinger")),
68        api: Some(SolidWellKnownApi {
69            accounts: SolidWellKnownAccounts {
70                new: format!("{base}/api/accounts/new"),
71                recover: format!("{base}/api/accounts/recover"),
72                signin: format!("{base}/login"),
73                signout: format!("{base}/logout"),
74            },
75        }),
76    }
77}
78
79// ---------------------------------------------------------------------------
80// WebFinger (RFC 7033)
81// ---------------------------------------------------------------------------
82
83/// WebFinger JRD (JSON Resource Descriptor) response.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WebFingerJrd {
86    pub subject: String,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub aliases: Vec<String>,
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub links: Vec<WebFingerLink>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct WebFingerLink {
95    pub rel: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub href: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
99    pub content_type: Option<String>,
100}
101
102/// Produce a WebFinger JRD response pointing `acct:user@host` at the
103/// user's WebID. Returns `None` if the resource is not recognised.
104pub fn webfinger_response(resource: &str, pod_base: &str, webid: &str) -> Option<WebFingerJrd> {
105    if !resource.starts_with("acct:") && !resource.starts_with("https://") {
106        return None;
107    }
108    let base = pod_base.trim_end_matches('/');
109    Some(WebFingerJrd {
110        subject: resource.to_string(),
111        aliases: vec![webid.to_string()],
112        links: vec![
113            WebFingerLink {
114                rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
115                href: Some(format!("{base}/")),
116                content_type: None,
117            },
118            WebFingerLink {
119                rel: "http://www.w3.org/ns/solid#webid".to_string(),
120                href: Some(webid.to_string()),
121                content_type: None,
122            },
123            WebFingerLink {
124                rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
125                href: Some(format!("{base}/")),
126                content_type: None,
127            },
128        ],
129    })
130}
131
132// ---------------------------------------------------------------------------
133// NIP-05 verification
134// ---------------------------------------------------------------------------
135
136/// NIP-05 response document (the JSON served at
137/// `.well-known/nostr.json?name=<local>`).
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Nip05Document {
140    pub names: std::collections::HashMap<String, String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
143}
144
145/// Verify a NIP-05 identifier (`local@example.com`) against a fetched
146/// NIP-05 document. Returns the resolved hex pubkey on success.
147pub fn verify_nip05(identifier: &str, document: &Nip05Document) -> Result<String, PodError> {
148    let (local, _domain) = identifier
149        .split_once('@')
150        .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
151    let lookup = if local.is_empty() { "_" } else { local };
152    let pubkey = document
153        .names
154        .get(lookup)
155        .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
156    if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
157        return Err(PodError::Nip98(format!(
158            "NIP-05 pubkey malformed for {identifier}"
159        )));
160    }
161    Ok(pubkey.clone())
162}
163
164/// Build the NIP-05 document structure for a pod's own hosted names.
165pub fn nip05_document(names: impl IntoIterator<Item = (String, String)>) -> Nip05Document {
166    Nip05Document {
167        names: names.into_iter().collect(),
168        relays: None,
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Dev-mode session bypass
174// ---------------------------------------------------------------------------
175
176/// Dev-mode session — ergonomic handle a consumer crate can plug into
177/// its request-processing pipeline in place of NIP-98/OIDC verification
178/// during tests or local development. The bypass is only constructable
179/// via explicit allow, never through a header the client supplies.
180#[derive(Debug, Clone)]
181pub struct DevSession {
182    pub webid: String,
183    pub pubkey: Option<String>,
184    pub is_admin: bool,
185}
186
187/// Build a dev-session bypass. Callers are expected to gate this on a
188/// top-level `ENABLE_DEV_SESSION=1` or similar environment check —
189/// the helper itself will not read env to avoid accidental activation.
190pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
191    DevSession {
192        webid: webid.into(),
193        pubkey: None,
194        is_admin,
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Tests
200// ---------------------------------------------------------------------------
201
202// These unit tests sit next to the interop functions they cover; the
203// did:nostr resolver and NodeInfo surfaces below were appended later. When
204// `did-nostr` is cfg'd out (e.g. the oidc/s3 CI feature cells) this becomes a
205// test module followed by items, which is intentional here.
206#[allow(clippy::items_after_test_module)]
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn well_known_solid_advertises_oidc_and_storage() {
213        let d = well_known_solid("https://pod.example/", "https://op.example/");
214        assert_eq!(d.solid_oidc_issuer, "https://op.example");
215        assert!(d.notification_gateway.ends_with(".notifications"));
216        assert!(d.storage.ends_with('/'));
217    }
218
219    #[test]
220    fn webfinger_returns_links_for_acct() {
221        let j = webfinger_response(
222            "acct:alice@pod.example",
223            "https://pod.example",
224            "https://pod.example/profile/card#me",
225        )
226        .unwrap();
227        assert_eq!(j.subject, "acct:alice@pod.example");
228        assert!(j
229            .links
230            .iter()
231            .any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
232    }
233
234    #[test]
235    fn webfinger_rejects_unknown_scheme() {
236        assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
237    }
238
239    #[test]
240    fn nip05_verify_returns_pubkey() {
241        let mut names = std::collections::HashMap::new();
242        names.insert("alice".to_string(), "a".repeat(64));
243        let doc = nip05_document(names);
244        let pk = verify_nip05("alice@pod.example", &doc).unwrap();
245        assert_eq!(pk, "a".repeat(64));
246    }
247
248    #[test]
249    fn nip05_verify_rejects_malformed_pubkey() {
250        let mut names = std::collections::HashMap::new();
251        names.insert("alice".to_string(), "shortkey".to_string());
252        let doc = nip05_document(names);
253        assert!(verify_nip05("alice@p", &doc).is_err());
254    }
255
256    #[test]
257    fn nip05_root_name_resolves_via_underscore() {
258        let mut names = std::collections::HashMap::new();
259        names.insert("_".to_string(), "b".repeat(64));
260        let doc = nip05_document(names);
261        assert!(verify_nip05("@pod.example", &doc).is_ok());
262    }
263
264    #[test]
265    fn dev_session_stores_admin_flag() {
266        let s = dev_session("https://me/profile#me", true);
267        assert!(s.is_admin);
268        assert_eq!(s.webid, "https://me/profile#me");
269    }
270}
271
272// ---------------------------------------------------------------------------
273// did:nostr resolver (Sprint 6 D)
274// ---------------------------------------------------------------------------
275
276/// did:nostr resolver — DID-Doc publication + bidirectional
277/// `alsoKnownAs`/`owl:sameAs` verification.
278///
279/// Mirrors `JavaScriptSolidServer/src/auth/did-nostr.js`: given
280/// `did:nostr:<pubkey>` hosted on an origin, fetch
281/// `https://<origin>/.well-known/did/nostr/<pubkey>.json`, iterate the
282/// `alsoKnownAs` entries, fetch each candidate WebID profile, and
283/// verify it carries an `owl:sameAs` / `schema:sameAs` back-link to
284/// `did:nostr:<pubkey>`. Only a verified WebID is returned.
285///
286/// Defence-in-depth: every outbound request (DID Doc + each WebID
287/// candidate) runs through the configured [`SsrfPolicy`](crate::security::ssrf::SsrfPolicy) before
288/// network I/O. A small in-memory TTL cache covers both success and
289/// negative results so a dark origin does not hammer the downstream.
290#[cfg(feature = "did-nostr")]
291pub mod did_nostr {
292    use std::collections::HashMap;
293    use std::sync::{Arc, RwLock};
294    use std::time::{Duration, Instant};
295
296    use reqwest::Client;
297    use serde::{Deserialize, Serialize};
298    use url::Url;
299
300    use crate::security::ssrf::SsrfPolicy;
301
302    /// Compose the well-known DID Doc location for a Nostr pubkey
303    /// hosted on a given origin. Mirrors JSS `did-nostr.js:79` where
304    /// the resolver URL is `<base>/<pubkey>.json`.
305    pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
306        format!(
307            "{}/.well-known/did/nostr/{}.json",
308            origin.trim_end_matches('/'),
309            pubkey
310        )
311    }
312
313    /// Build the canonical `did:nostr` DID Doc for publication at the
314    /// well-known URL (ADR-125 — supersedes ADR-074 §D2/§D3/§D4/§D13).
315    ///
316    /// The single published form: `@context`
317    /// `["https://www.w3.org/ns/did/v1", "https://www.w3.org/ns/cid/v1",
318    /// "https://w3id.org/nostr/context"]` (did:nostr CG 0.1.1 — DID Core
319    /// context first, as DID Core requires),
320    /// top-level `type: "DIDNostr"`, a single `Multikey` verification method
321    /// with `publicKeyMultibase: "fe70102<hex>"`, fragment `#key1`. Optional
322    /// members are omitted when empty (no `service: []`), per the did:nostr CG
323    /// spec (<https://nostrcg.github.io/did-nostr/>). The 2019 suite +
324    /// `publicKeyHex` + `z`-base58 multibase are dropped. No dual-publish.
325    ///
326    /// Built from the hex identifier alone, so the parity byte is `02` (the
327    /// even-y lift every resolver computes). A controller publishing from its
328    /// full key may carry `03` instead — see
329    /// `did_nostr_types::render_did_document_published` (nostrcg/did-nostr#145).
330    ///
331    /// `also_known_as` is surfaced as a top-level `alsoKnownAs` link when
332    /// non-empty — the spec's canonical location for cross-platform identity
333    /// (WebID / ActivityPub / AT-proto).
334    ///
335    /// NOTE: The canonical DID:nostr types (including `NostrPubkey`,
336    /// `render_did_document`, `render_did_document_tier3`) live in
337    /// [`crate::did_nostr_types`] (feature `did-nostr-types`). This
338    /// convenience wrapper accepts a raw hex string and merges
339    /// `also_known_as` into the canonical skeleton. New code should prefer
340    /// the canonical types directly.
341    pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
342        // Delegate to the canonical renderer when the pubkey parses cleanly;
343        // fall back to inline JSON for malformed input (preserves
344        // backward-compat — callers never saw an error here).
345        #[cfg(feature = "did-nostr-types")]
346        if let Ok(pk) = crate::did_nostr_types::NostrPubkey::from_hex(pubkey) {
347            let mut doc = crate::did_nostr_types::render_did_document(&pk);
348            if !also_known_as.is_empty() {
349                doc["alsoKnownAs"] = serde_json::json!(also_known_as);
350            }
351            return doc;
352        }
353
354        // Fallback for malformed hex (D-1 fix, ADR-124 §7 / I2).
355        //
356        // A malformed pubkey cannot produce the `fe70102` framing, so we must
357        // NEVER emit a `Multikey` verification method without its
358        // `publicKeyMultibase` — a keyless `Multikey` is an I2 violation (the
359        // VM would advertise a key type it cannot back). The signature is
360        // infallible by contract (callers never saw an error here), so instead
361        // of erroring we emit the canonical envelope with an EMPTY
362        // `verificationMethod` (and, in lockstep, empty `authentication` /
363        // `assertionMethod`, since `#key1` no longer resolves). The
364        // `did:nostr:<pubkey>` body and the canonical context/type are
365        // preserved verbatim. Well-formed input always takes the delegating
366        // branch above and carries the full `Multikey` VM.
367        let did = format!("did:nostr:{pubkey}");
368        let mut doc = serde_json::json!({
369            "@context": [
370                "https://www.w3.org/ns/did/v1",
371                "https://www.w3.org/ns/cid/v1",
372                "https://w3id.org/nostr/context"
373            ],
374            "id": did,
375            "type": "DIDNostr",
376            "verificationMethod": [],
377            "authentication": [],
378            "assertionMethod": []
379        });
380        if !also_known_as.is_empty() {
381            doc["alsoKnownAs"] = serde_json::json!(also_known_as);
382        }
383        doc
384    }
385
386    /// Parsed DID Doc. Only the subset of fields relevant to WebID
387    /// resolution is typed; unknown fields are ignored.
388    #[derive(Debug, Clone, Deserialize, Serialize)]
389    pub struct DidNostrDoc {
390        pub id: String,
391        #[serde(default, rename = "alsoKnownAs")]
392        pub also_known_as: Vec<String>,
393    }
394
395    /// TTL-cached `did:nostr:<pubkey>` → WebID resolver with per-hop
396    /// SSRF enforcement.
397    pub struct DidNostrResolver {
398        ssrf: Arc<SsrfPolicy>,
399        client: Client,
400        cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
401        success_ttl: Duration,
402        failure_ttl: Duration,
403    }
404
405    struct CachedEntry {
406        fetched: Instant,
407        web_id: Option<String>,
408    }
409
410    impl DidNostrResolver {
411        /// Construct a resolver with the default HTTP client (10 s
412        /// timeout) and TTLs matching JSS (5 min success, 1 min
413        /// failure).
414        pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
415            // Redirects are refused: the SSRF check runs on the URL we
416            // were given, and following a 30x would reach a host it never
417            // vetted (JSS re-checks each hop and refuses cross-origin
418            // redirects, `fetchWithRedirectGuard`).
419            let client = Client::builder()
420                .timeout(Duration::from_secs(10))
421                .redirect(reqwest::redirect::Policy::none())
422                .build()
423                .unwrap_or_else(|_| Client::new());
424            Self {
425                ssrf,
426                client,
427                cache: Arc::new(RwLock::new(HashMap::new())),
428                success_ttl: Duration::from_secs(300),
429                failure_ttl: Duration::from_secs(60),
430            }
431        }
432
433        /// Override the default success / failure cache TTLs.
434        pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
435            self.success_ttl = success;
436            self.failure_ttl = failure;
437            self
438        }
439
440        /// Resolve `did:nostr:<pubkey>` against `origin` to a verified
441        /// WebID. Returns `None` if:
442        ///
443        /// - SSRF policy denies the origin or any WebID candidate.
444        /// - DID Doc fetch fails or the doc's `id` does not match
445        ///   `did:nostr:<pubkey>`.
446        /// - `alsoKnownAs` is empty.
447        /// - No candidate WebID carries a back-link (`owl:sameAs` or
448        ///   `schema:sameAs`) to the same `did:nostr:<pubkey>`.
449        ///
450        /// Both success and failure are cached; subsequent calls
451        /// within the matching TTL are served from memory without
452        /// network I/O.
453        pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
454            // The pubkey comes off a NIP-98 event and is interpolated into a
455            // URL path and the cache key: enforce 64 hex characters (JSS
456            // `resolveDidNostrToWebId` does the same) and lowercase it.
457            if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
458                return None;
459            }
460            let pubkey = pubkey.to_ascii_lowercase();
461            let pubkey = pubkey.as_str();
462            let cache_key = format!("{origin}|{pubkey}");
463
464            // Cache lookup (read lock).
465            if let Ok(guard) = self.cache.read() {
466                if let Some(entry) = guard.get(&cache_key) {
467                    let ttl = if entry.web_id.is_some() {
468                        self.success_ttl
469                    } else {
470                        self.failure_ttl
471                    };
472                    if entry.fetched.elapsed() < ttl {
473                        return entry.web_id.clone();
474                    }
475                }
476            }
477
478            let result =
479                tokio::time::timeout(RESOLVE_DEADLINE, self.resolve_uncached(origin, pubkey))
480                    .await
481                    .unwrap_or(None);
482
483            if let Ok(mut guard) = self.cache.write() {
484                // Bounded like JSS (10k entries): pubkeys are attacker
485                // supplied, so evict the oldest entry rather than grow.
486                if guard.len() >= MAX_CACHE_ENTRIES {
487                    if let Some(oldest) = guard
488                        .iter()
489                        .min_by_key(|(_, e)| e.fetched)
490                        .map(|(k, _)| k.clone())
491                    {
492                        guard.remove(&oldest);
493                    }
494                }
495                guard.insert(
496                    cache_key,
497                    CachedEntry {
498                        fetched: Instant::now(),
499                        web_id: result.clone(),
500                    },
501                );
502            }
503
504            result
505        }
506
507        /// A client that connects `url`'s host to `ip`, the address the SSRF
508        /// policy approved, instead of resolving the name again (closes the
509        /// DNS-rebinding window between check and connect). IP-literal hosts
510        /// need no pin. Redirects stay off and the timeout is kept.
511        fn pinned_client(&self, url: &Url, ip: std::net::IpAddr) -> Client {
512            let Some(url::Host::Domain(host)) = url.host() else {
513                return self.client.clone();
514            };
515            let port = url.port_or_known_default().unwrap_or(80);
516            Client::builder()
517                .timeout(Duration::from_secs(10))
518                .redirect(reqwest::redirect::Policy::none())
519                .resolve(host, std::net::SocketAddr::new(ip, port))
520                .build()
521                .unwrap_or_else(|_| self.client.clone())
522        }
523
524        async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
525            // 1. SSRF check on origin.
526            let origin_url = Url::parse(origin).ok()?;
527            let origin_ip = self.ssrf.resolve_and_check(&origin_url).await.ok()?;
528
529            // 2. Fetch DID Doc, connecting to the address the SSRF check
530            // vetted (a second DNS lookup could answer differently).
531            let url = did_nostr_well_known_url(origin, pubkey);
532            let resp = self
533                .pinned_client(&origin_url, origin_ip)
534                .get(&url)
535                .header("accept", "application/did+json, application/json")
536                .send()
537                .await
538                .ok()?
539                .error_for_status()
540                .ok()?;
541            let doc: DidNostrDoc = serde_json::from_slice(&read_capped(resp).await?).ok()?;
542
543            if doc.id != format!("did:nostr:{pubkey}") {
544                return None;
545            }
546
547            // 3. Iterate candidates; return the first verified WebID.
548            let did_iri = format!("did:nostr:{pubkey}");
549            // The list comes from a remote document: bound the fetches.
550            for candidate in doc.also_known_as.iter().take(MAX_CANDIDATES) {
551                if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
552                    return Some(web_id);
553                }
554            }
555            None
556        }
557
558        async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
559            let url = Url::parse(candidate).ok()?;
560            let ip = self.ssrf.resolve_and_check(&url).await.ok()?;
561            let resp = self
562                .pinned_client(&url, ip)
563                .get(url.as_str())
564                .header("accept", "text/turtle, application/ld+json")
565                .send()
566                .await
567                .ok()?
568                .error_for_status()
569                .ok()?;
570            let body = read_capped(resp).await?;
571
572            // Back-link check: the profile must itself assert, as a real
573            // statement about the candidate WebID, an `owl:sameAs` /
574            // `schema:sameAs` to this DID. A document that merely mentions
575            // the DID somewhere does not count (one-way links are never
576            // accepted).
577            if backlink_present(&body, candidate, did_iri) {
578                Some(candidate.to_string())
579            } else {
580                None
581            }
582        }
583    }
584
585    /// Most `alsoKnownAs` entries one resolution will fetch.
586    pub const MAX_CANDIDATES: usize = 4;
587    /// Wall-clock budget for one whole resolution (document plus candidates).
588    const RESOLVE_DEADLINE: Duration = Duration::from_secs(15);
589    /// Largest DID document / WebID profile body the resolver reads.
590    const MAX_DOC_BYTES: usize = 512 * 1024;
591    /// Resolver cache bound (JSS `CACHE_MAX_ENTRIES`).
592    const MAX_CACHE_ENTRIES: usize = 10_000;
593
594    const SAME_AS_IRIS: [&str; 4] = [
595        "http://www.w3.org/2002/07/owl#sameAs",
596        "https://schema.org/sameAs",
597        "http://schema.org/sameAs",
598        "https://schema.org/sameAs/",
599    ];
600    const SAME_AS_KEYS: [&str; 7] = [
601        "owl:sameAs",
602        "schema:sameAs",
603        "sameAs",
604        "http://www.w3.org/2002/07/owl#sameAs",
605        "https://schema.org/sameAs",
606        "http://schema.org/sameAs",
607        "https://schema.org/sameAs/",
608    ];
609
610    async fn read_capped(mut resp: reqwest::Response) -> Option<Vec<u8>> {
611        if resp
612            .content_length()
613            .is_some_and(|n| n as usize > MAX_DOC_BYTES)
614        {
615            return None;
616        }
617        let mut out = Vec::new();
618        while let Some(chunk) = resp.chunk().await.ok()? {
619            if out.len() + chunk.len() > MAX_DOC_BYTES {
620                return None;
621            }
622            out.extend_from_slice(&chunk);
623        }
624        Some(out)
625    }
626
627    /// True when `body` (JSON-LD or Turtle) asserts
628    /// `<candidate> owl:sameAs|schema:sameAs <did_iri>`.
629    ///
630    /// Exposed for tests; the resolver calls it with the fetched profile.
631    pub fn backlink_present(body: &[u8], candidate: &str, did_iri: &str) -> bool {
632        if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) {
633            return json_backlink(&v, candidate, did_iri);
634        }
635        let doc_base = candidate.split('#').next().unwrap_or(candidate);
636        match crate::rdf_input::parse_turtle(body, doc_base) {
637            Ok(g) => g.triples().any(|t| {
638                matches!(&t.subject, crate::ldp::Term::Iri(s) if s == candidate)
639                    && matches!(&t.predicate, crate::ldp::Term::Iri(p) if SAME_AS_IRIS.contains(&p.as_str()))
640                    && matches!(&t.object, crate::ldp::Term::Iri(o) if o == did_iri)
641            }),
642            Err(_) => false,
643        }
644    }
645
646    fn json_backlink(v: &serde_json::Value, candidate: &str, did_iri: &str) -> bool {
647        use serde_json::Value;
648        let base = Url::parse(candidate).ok();
649        let resolve = |id: &str| -> String {
650            base.as_ref()
651                .and_then(|b| b.join(id).ok())
652                .map(|u| u.to_string())
653                .unwrap_or_else(|| id.to_string())
654        };
655        let is_did = |x: &Value| match x {
656            Value::String(s) => s == did_iri,
657            Value::Object(m) => m.get("@id").and_then(Value::as_str) == Some(did_iri),
658            _ => false,
659        };
660        fn walk(
661            v: &Value,
662            depth: usize,
663            visit: &mut dyn FnMut(&serde_json::Map<String, Value>) -> bool,
664        ) -> bool {
665            if depth > 16 {
666                return false;
667            }
668            match v {
669                Value::Object(m) => visit(m) || m.values().any(|c| walk(c, depth + 1, visit)),
670                Value::Array(a) => a.iter().any(|c| walk(c, depth + 1, visit)),
671                _ => false,
672            }
673        }
674        walk(v, 0, &mut |m| {
675            let id = m.get("@id").or_else(|| m.get("id")).and_then(Value::as_str);
676            let Some(id) = id else { return false };
677            if resolve(id) != candidate {
678                return false;
679            }
680            SAME_AS_KEYS.iter().any(|k| match m.get(*k) {
681                Some(Value::Array(a)) => a.iter().any(is_did),
682                Some(x) => is_did(x),
683                None => false,
684            })
685        })
686    }
687
688    #[cfg(test)]
689    mod pin_tests {
690        use super::*;
691        use wiremock::matchers::method;
692        use wiremock::{Mock, MockServer, ResponseTemplate};
693
694        /// A hostname that cannot resolve is reachable only through the pin:
695        /// proves the client connects to the vetted IP, not a fresh lookup.
696        #[tokio::test]
697        async fn pinned_client_connects_to_the_vetted_address() {
698            let server = MockServer::start().await;
699            Mock::given(method("GET"))
700                .respond_with(ResponseTemplate::new(200))
701                .mount(&server)
702                .await;
703            let port = server.address().port();
704            let url = Url::parse(&format!("http://rebind.invalid:{port}/x")).unwrap();
705            let r = DidNostrResolver::new(Arc::new(SsrfPolicy::new()));
706            let unpinned = r.client.get(url.as_str()).send().await;
707            assert!(unpinned.is_err(), "the name must not resolve by itself");
708            let ok = r
709                .pinned_client(&url, "127.0.0.1".parse().unwrap())
710                .get(url.as_str())
711                .send()
712                .await
713                .expect("pinned request reaches the vetted address");
714            assert_eq!(ok.status(), 200);
715        }
716    }
717}
718
719// ---------------------------------------------------------------------------
720// NodeInfo 2.1 (Sprint 7 C)
721// ---------------------------------------------------------------------------
722
723/// `/.well-known/nodeinfo` discovery document (JSON), per
724/// nodeinfo.diaspora.software §6. Points clients at one or more
725/// versioned NodeInfo docs.
726pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
727    serde_json::json!({
728        "links": [
729            {
730                "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
731                "href": format!(
732                    "{}/.well-known/nodeinfo/2.1",
733                    base_url.trim_end_matches('/')
734                )
735            }
736        ]
737    })
738}
739
740/// `/.well-known/nodeinfo/2.1` content document, per
741/// nodeinfo.diaspora.software §3 (schema 2.1).
742pub fn nodeinfo_2_1(
743    software_name: &str,
744    software_version: &str,
745    open_registrations: bool,
746    total_users: u64,
747) -> serde_json::Value {
748    serde_json::json!({
749        "version": "2.1",
750        "software": {
751            "name": software_name,
752            "version": software_version,
753            "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
754            "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
755        },
756        "protocols": ["solid", "activitypub"],
757        "services": {
758            "inbound": [],
759            "outbound": []
760        },
761        "openRegistrations": open_registrations,
762        "usage": {
763            "users": {
764                "total": total_users
765            }
766        },
767        "metadata": {}
768    })
769}