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}