ndn_security/did/
url.rs

1//! DID URL parsing and dereferencing.
2//!
3//! A DID URL is a DID plus optional path, query, and/or fragment components,
4//! per W3C DID Core §3.2:
5//!
6//! ```text
7//! did-url     = did path-abempty [ "?" query ] [ "#" fragment ]
8//! path-abempty = *( "/" segment )
9//! ```
10//!
11//! Examples:
12//! - `did:ndn:BwkI...#key-0` — fragment reference to a verification method
13//! - `did:ndn:BwMg...#key-agreement-1` — zone DID key agreement VM
14//! - `did:ndn:BwkI.../service?type=ndn#endpoint` — service lookup
15//!
16//! (The method-specific identifier is base64url — no colons.)
17//!
18//! # Dereferencing
19//!
20//! [`deref_did_url`] takes a parsed DID URL and a resolved [`DidDocument`]
21//! and returns the specific resource the URL identifies — a verification
22//! method, a service, or the full document (when no fragment).
23
24use super::{
25    document::{DidDocument, Service, VerificationMethod},
26    resolver::DidError,
27};
28
29// ── DID URL ───────────────────────────────────────────────────────────────────
30
31/// A parsed DID URL (DID + optional path, query, fragment).
32///
33/// Per W3C DID Core §3.2.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DidUrl {
36    /// The DID part (`did:<method>:<method-specific-id>`).
37    pub did: String,
38    /// Path segments after the method-specific identifier (may be empty).
39    pub path: Option<String>,
40    /// Query string (without the leading `?`).
41    pub query: Option<String>,
42    /// Fragment (without the leading `#`). Used to reference VMs and services.
43    pub fragment: Option<String>,
44}
45
46impl DidUrl {
47    /// Parse a DID URL string.
48    ///
49    /// # Errors
50    ///
51    /// Returns `DidError::InvalidDid` if `url` is not a valid DID URL.
52    pub fn parse(url: &str) -> Result<Self, DidError> {
53        if !url.starts_with("did:") {
54            return Err(DidError::InvalidDid(format!("not a DID URL: {url}")));
55        }
56
57        // Split off fragment first (fragment can contain '#' in theory but in
58        // practice we split on the first '#').
59        let (before_frag, fragment) = match url.find('#') {
60            Some(pos) => (&url[..pos], Some(url[pos + 1..].to_string())),
61            None => (url, None),
62        };
63
64        // Split query string.
65        let (before_query, query) = match before_frag.find('?') {
66            Some(pos) => (
67                &before_frag[..pos],
68                Some(before_frag[pos + 1..].to_string()),
69            ),
70            None => (before_frag, None),
71        };
72
73        // `did:` + method + `:` + method-specific-id [path]
74        // The method-specific-id ends at the first `/` that is NOT part of
75        // the method-specific-id itself. Per DID Core, `did:ndn:com:acme:alice`
76        // has no path; `did:ndn:com:acme:alice/some/path` has path `/some/path`.
77        //
78        // Strategy: find the 3rd `:` (after `did:method:`), then look for `/`.
79        let colon2 = before_query
80            .find(':')
81            .and_then(|i| before_query[i + 1..].find(':').map(|j| i + 1 + j));
82
83        let (did, path) = match colon2 {
84            None => {
85                return Err(DidError::InvalidDid(format!(
86                    "DID URL missing method-specific-id: {url}"
87                )));
88            }
89            Some(method_colon) => {
90                // method_colon is the position of the second ':' in the string
91                let rest = &before_query[method_colon + 1..];
92                match rest.find('/') {
93                    None => (before_query.to_string(), None),
94                    Some(slash) => {
95                        let split_pos = method_colon + 1 + slash;
96                        (
97                            before_query[..split_pos].to_string(),
98                            Some(before_query[split_pos..].to_string()),
99                        )
100                    }
101                }
102            }
103        };
104
105        Ok(Self {
106            did,
107            path,
108            query,
109            fragment,
110        })
111    }
112
113    /// Whether this URL is a plain DID with no path, query, or fragment.
114    pub fn is_bare_did(&self) -> bool {
115        self.path.is_none() && self.query.is_none() && self.fragment.is_none()
116    }
117}
118
119impl std::fmt::Display for DidUrl {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.write_str(&self.did)?;
122        if let Some(ref path) = self.path {
123            f.write_str(path)?;
124        }
125        if let Some(ref query) = self.query {
126            write!(f, "?{query}")?;
127        }
128        if let Some(ref fragment) = self.fragment {
129            write!(f, "#{fragment}")?;
130        }
131        Ok(())
132    }
133}
134
135// ── Dereferenced resource ─────────────────────────────────────────────────────
136
137/// The resource identified by a dereferenced DID URL.
138///
139/// Per W3C DID Core §7.2, a DID URL deferences to one of these.
140#[derive(Debug, Clone)]
141pub enum DereferencedResource<'a> {
142    /// The full DID Document (when the URL has no fragment).
143    Document(&'a DidDocument),
144    /// A specific verification method (fragment matched a VM `id`).
145    VerificationMethod(&'a VerificationMethod),
146    /// A specific service (fragment matched a service `id`).
147    Service(&'a Service),
148}
149
150// ── Dereferencing ─────────────────────────────────────────────────────────────
151
152/// Dereference a DID URL against an already-resolved DID Document.
153///
154/// This is the "secondary" dereference step from W3C DID Core §7.2 —
155/// the document has already been resolved; this function extracts the
156/// specific resource identified by the URL's fragment.
157///
158/// # Rules
159///
160/// - No fragment → returns the full document.
161/// - Fragment matches a `verificationMethod[].id` → returns that VM.
162/// - Fragment matches a `service[].id` → returns that service.
163/// - Fragment matches nothing → returns `None`.
164///
165/// Fragment comparison strips any leading `#` and is exact-string.
166/// Per DID Core, fragment comparison is case-sensitive.
167///
168/// # Example
169///
170/// ```rust,no_run
171/// # use ndn_security::did::{DidUrl, deref_did_url};
172/// # use ndn_security::did::document::DidDocument;
173/// # fn example(doc: &DidDocument) {
174/// let url = DidUrl::parse("did:ndn:com:acme:alice#key-0").unwrap();
175/// match deref_did_url(&url, doc) {
176///     Some(ndn_security::did::url::DereferencedResource::VerificationMethod(vm)) => {
177///         println!("Found key: {}", vm.id);
178///     }
179///     _ => {}
180/// }
181/// # }
182/// ```
183pub fn deref_did_url<'a>(url: &DidUrl, doc: &'a DidDocument) -> Option<DereferencedResource<'a>> {
184    let fragment = url.fragment.as_deref()?;
185
186    // Try verification methods first: match by full `id` or by `#fragment`.
187    // The VM `id` is typically `<did>#<fragment>`, so we match both ways.
188    for vm in &doc.verification_methods {
189        if vm.id == fragment
190            || vm
191                .id
192                .split_once('#')
193                .map(|(_, f)| f == fragment)
194                .unwrap_or(false)
195            || vm.id == format!("{}#{fragment}", url.did)
196        {
197            return Some(DereferencedResource::VerificationMethod(vm));
198        }
199    }
200
201    // Try services.
202    for svc in &doc.service {
203        if svc.id == fragment
204            || svc
205                .id
206                .split_once('#')
207                .map(|(_, f)| f == fragment)
208                .unwrap_or(false)
209            || svc.id == format!("{}#{fragment}", url.did)
210        {
211            return Some(DereferencedResource::Service(svc));
212        }
213    }
214
215    None
216}
217
218/// Convenience: dereference without a fragment, returning the document.
219///
220/// If `url` has no fragment, returns a reference to the document itself.
221/// If it has a fragment, delegates to [`deref_did_url`].
222pub fn deref_did_url_or_document<'a>(
223    url: &DidUrl,
224    doc: &'a DidDocument,
225) -> Option<DereferencedResource<'a>> {
226    if url.fragment.is_none() {
227        Some(DereferencedResource::Document(doc))
228    } else {
229        deref_did_url(url, doc)
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn parse_bare_did() {
239        let url = DidUrl::parse("did:ndn:com:acme:alice").unwrap();
240        assert_eq!(url.did, "did:ndn:com:acme:alice");
241        assert!(url.path.is_none());
242        assert!(url.query.is_none());
243        assert!(url.fragment.is_none());
244        assert!(url.is_bare_did());
245    }
246
247    #[test]
248    fn parse_did_with_fragment() {
249        let url = DidUrl::parse("did:ndn:com:acme:alice#key-0").unwrap();
250        assert_eq!(url.did, "did:ndn:com:acme:alice");
251        assert_eq!(url.fragment.as_deref(), Some("key-0"));
252    }
253
254    #[test]
255    fn parse_did_with_path() {
256        let url = DidUrl::parse("did:ndn:com:acme:alice/some/path").unwrap();
257        assert_eq!(url.did, "did:ndn:com:acme:alice");
258        assert_eq!(url.path.as_deref(), Some("/some/path"));
259    }
260
261    #[test]
262    fn parse_did_with_query_and_fragment() {
263        let url = DidUrl::parse("did:ndn:com:acme:alice?service=files#key-0").unwrap();
264        assert_eq!(url.did, "did:ndn:com:acme:alice");
265        assert_eq!(url.query.as_deref(), Some("service=files"));
266        assert_eq!(url.fragment.as_deref(), Some("key-0"));
267    }
268
269    #[test]
270    fn parse_zone_did_with_fragment() {
271        // Current binary form: base64url with no colons.
272        let url = DidUrl::parse("did:ndn:BwMgabc123abc123abc#key-agreement-1").unwrap();
273        assert_eq!(url.did, "did:ndn:BwMgabc123abc123abc");
274        assert_eq!(url.fragment.as_deref(), Some("key-agreement-1"));
275    }
276
277    #[test]
278    fn roundtrip_display() {
279        let original = "did:ndn:com:acme:alice#key-0";
280        let url = DidUrl::parse(original).unwrap();
281        assert_eq!(url.to_string(), original);
282    }
283
284    #[test]
285    fn not_a_did_url_returns_error() {
286        assert!(DidUrl::parse("https://example.com").is_err());
287        assert!(DidUrl::parse("did:ndn").is_err());
288    }
289}