identity_kit/methods/web/mod.rs
1//! `did:web`: a DID document served over HTTPS by a web origin.
2//!
3//! The [did:web method] maps a DID to the URL of its document: `did:web:example.com` is served
4//! at `https://example.com/.well-known/did.json`, and `did:web:example.com%3A8443:user:alice`
5//! at `https://example.com:8443/user/alice/did.json` (a port is percent-encoded as `%3A`, path
6//! segments are separated by `:`). [`document_url`] implements that mapping and
7//! [`did_from_url`] its inverse. The document is fetched through an [`HttpClient`] the caller
8//! injects; [`ReqwestClient`] (feature `reqwest`) is a hardened default that speaks HTTPS only,
9//! follows no redirect, refuses non-public addresses and caps the response size.
10//!
11//! The method is updated by republishing the document and deactivated by removing it; neither
12//! has an API, so [`DidWeb`] as a [`DidProducer`] builds the document to publish and nothing
13//! more.
14//!
15//! [did:web method]: https://w3c-ccg.github.io/did-method-web/
16
17use core::fmt;
18use core::fmt::Write as _;
19use std::error::Error;
20use std::sync::Arc;
21
22use url::{Host, Url};
23
24use crate::did::{
25 Context, Did, DidDocument, MethodType, ResolutionError, ResolutionErrorKind, ResolutionOptions,
26 ResolutionResult, VerificationMaterial, VerificationMethod, VerificationRelationship,
27};
28use crate::jose::Jwk;
29use crate::resolver::{DidProducer, DidResolver, Hosting, MethodCapabilities, ProduceError};
30
31use super::check_accept;
32
33#[cfg(feature = "reqwest")]
34mod client;
35
36#[cfg(feature = "reqwest")]
37#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
38pub use client::ReqwestClient;
39#[cfg(feature = "local-network")]
40#[cfg_attr(docsrs, doc(cfg(feature = "local-network")))]
41pub use client::{LocalNetwork, ReqwestClientBuilder};
42
43/// The method name, `web`.
44pub const METHOD: &str = "web";
45
46/// The `@context` of a document [`DidWeb`] produces.
47pub const CONTEXT: [&str; 2] = [
48 Context::DID_V1,
49 "https://w3id.org/security/suites/jws-2020/v1",
50];
51
52// ---------------------------------------------------------------------------------------------
53// HTTP
54// ---------------------------------------------------------------------------------------------
55
56/// What [`DidWeb`] needs from HTTP: a `GET` of a URL.
57///
58/// Implement it over the HTTP client of the application so `did:web` resolution shares its
59/// connection pool, proxy and TLS settings. The implementation decides the security policy:
60/// [`ReqwestClient`] is the one this crate ships.
61///
62/// Every URL a client is given comes from untrusted input (a DID, a credential's status list
63/// reference), so an implementation must refuse what [`ReqwestClient`] refuses, or weigh the
64/// consequence: plain `http`, redirects, hosts that resolve to loopback, private, link-local or
65/// other non-public addresses, unbounded bodies and unbounded waits.
66///
67/// A client follows no redirect itself: it returns the 3xx response, with its `Location` in
68/// [`HttpResponse::location`]. A caller that follows redirects, such as the status list check
69/// of the `sd-jwt` feature, fetches each target with a new call, so that every hop goes
70/// through the client's rules.
71#[async_trait::async_trait]
72pub trait HttpClient: Send + Sync {
73 /// Fetches `url`, returning the status and the body of the response.
74 ///
75 /// # Errors
76 ///
77 /// Any failure to get a response: a refused URL, a network or TLS error, a timeout, a body
78 /// over the client's limit. An HTTP error status is a response, not an error.
79 async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError>;
80
81 /// Fetches `url` asking for a body of `media_type`, sent as the `Accept` header.
82 ///
83 /// The default calls [`get`](Self::get) and sends no `Accept` header; [`ReqwestClient`]
84 /// sends it. Implement it when a server negotiates the content type.
85 ///
86 /// # Errors
87 ///
88 /// As [`get`](Self::get).
89 async fn get_accepting(&self, url: &Url, media_type: &str) -> Result<HttpResponse, HttpError> {
90 let _ = media_type;
91 self.get(url).await
92 }
93}
94
95macro_rules! forward_http_client {
96 ($($t:ty),* $(,)?) => {$(
97 #[async_trait::async_trait]
98 impl<T: HttpClient + ?Sized> HttpClient for $t {
99 async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
100 (**self).get(url).await
101 }
102
103 async fn get_accepting(
104 &self,
105 url: &Url,
106 media_type: &str,
107 ) -> Result<HttpResponse, HttpError> {
108 (**self).get_accepting(url, media_type).await
109 }
110 }
111 )*};
112}
113
114forward_http_client!(&T, Box<T>, Arc<T>);
115
116/// An HTTP response: the status code, the body and, for a redirect, its target.
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct HttpResponse {
120 /// The status code, `200` for a served document.
121 pub status: u16,
122 /// The body, which a `did:web` resolver parses as JSON.
123 pub body: Vec<u8>,
124 /// The `Location` header of a 3xx response, as sent: possibly relative to the requested
125 /// URL. `None` for any other response.
126 pub location: Option<String>,
127}
128
129impl HttpResponse {
130 /// A response with `status` and `body`, and no `location`.
131 pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
132 Self {
133 status,
134 body: body.into(),
135 location: None,
136 }
137 }
138
139 /// This response with the `Location` header `location`.
140 #[must_use]
141 pub fn with_location(mut self, location: impl Into<String>) -> Self {
142 self.location = Some(location.into());
143 self
144 }
145}
146
147/// Why an [`HttpClient`] could not get a response.
148#[derive(Debug)]
149pub struct HttpError(Box<dyn Error + Send + Sync + 'static>);
150
151impl HttpError {
152 /// Wraps `error`.
153 pub fn new<E: Error + Send + Sync + 'static>(error: E) -> Self {
154 Self(Box::new(error))
155 }
156
157 /// An error carrying `message`.
158 pub fn msg<M: fmt::Display>(message: M) -> Self {
159 Self(message.to_string().into())
160 }
161
162 /// The wrapped error.
163 pub fn inner(&self) -> &(dyn Error + Send + Sync + 'static) {
164 self.0.as_ref()
165 }
166}
167
168impl fmt::Display for HttpError {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 fmt::Display::fmt(&self.0, f)
171 }
172}
173
174impl Error for HttpError {
175 fn source(&self) -> Option<&(dyn Error + 'static)> {
176 self.0.source()
177 }
178}
179
180impl From<Box<dyn Error + Send + Sync + 'static>> for HttpError {
181 fn from(error: Box<dyn Error + Send + Sync + 'static>) -> Self {
182 Self(error)
183 }
184}
185
186// ---------------------------------------------------------------------------------------------
187// DID <-> URL
188// ---------------------------------------------------------------------------------------------
189
190/// Why a URL has no `did:web`, or a `did:web` no document URL.
191#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
192#[non_exhaustive]
193pub enum WebError {
194 /// The URL is not an HTTPS location a `did:web` can name.
195 #[error("`{url}` cannot be a did:web: {reason}")]
196 InvalidUrl {
197 /// The URL.
198 url: Url,
199 /// What is wrong with it.
200 reason: &'static str,
201 },
202 /// The DID is not a well-formed `did:web`.
203 #[error("`{did}` is not a valid did:web: {reason}")]
204 InvalidDid {
205 /// The DID.
206 did: Did,
207 /// What is wrong with it.
208 reason: &'static str,
209 },
210}
211
212/// The directory of the document of a DID without path segments.
213const WELL_KNOWN: &str = ".well-known";
214
215/// The file name of the document.
216const DOCUMENT_FILE: &str = "did.json";
217
218/// The `did:web` whose document lives under `url`: `https://example.com` gives
219/// `did:web:example.com`, `https://example.com:8443/user/alice` gives
220/// `did:web:example.com%3A8443:user:alice`.
221///
222/// The URL must be `https`, name a domain (not an IP address) and carry no credentials, query
223/// or fragment. A trailing slash is ignored, as is the document file name that
224/// [`document_url`] appends (`/.well-known/did.json` or `/did.json`), so a document URL maps
225/// back to its DID; other path segments become `:`-separated id segments, percent-encoded where
226/// the DID syntax requires it.
227///
228/// # Errors
229///
230/// [`WebError::InvalidUrl`] when the URL breaks one of those rules.
231///
232/// # Examples
233///
234/// ```
235/// use identity_kit::methods::web::did_from_url;
236/// use url::Url;
237///
238/// let did = did_from_url(&Url::parse("https://example.com:3000/user/alice/")?)?;
239/// assert_eq!(did.as_str(), "did:web:example.com%3A3000:user:alice");
240/// let did = did_from_url(&Url::parse("https://example.com/.well-known/did.json")?)?;
241/// assert_eq!(did.as_str(), "did:web:example.com");
242/// assert!(did_from_url(&Url::parse("https://192.0.2.1/")?).is_err());
243/// # Ok::<(), Box<dyn std::error::Error>>(())
244/// ```
245pub fn did_from_url(url: &Url) -> Result<Did, WebError> {
246 let invalid = |reason| WebError::InvalidUrl {
247 url: url.clone(),
248 reason,
249 };
250 if url.scheme() != "https" {
251 return Err(invalid("the scheme must be `https`"));
252 }
253 let domain = match url.host() {
254 Some(Host::Domain(domain)) => domain,
255 Some(_) => return Err(invalid("the host must be a domain name, not an IP address")),
256 None => return Err(invalid("the URL has no host")),
257 };
258 if !domain
259 .bytes()
260 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
261 {
262 return Err(invalid("the host has characters a DID cannot carry"));
263 }
264 if !url.username().is_empty() || url.password().is_some() {
265 return Err(invalid("the URL must not carry credentials"));
266 }
267 if url.query().is_some() || url.fragment().is_some() {
268 return Err(invalid("the URL must not have a query or a fragment"));
269 }
270 let mut id = domain.to_owned();
271 if let Some(port) = url.port() {
272 let _ = write!(id, "%3A{port}");
273 }
274 let mut segments: Vec<&str> = url
275 .path_segments()
276 .map(Iterator::collect)
277 .unwrap_or_default();
278 if segments.last() == Some(&"") {
279 segments.pop();
280 }
281 if segments.last() == Some(&DOCUMENT_FILE) {
282 segments.pop();
283 if segments.as_slice() == [WELL_KNOWN] {
284 segments.clear();
285 }
286 }
287 for segment in segments {
288 if segment.is_empty() {
289 return Err(invalid("the path has an empty segment"));
290 }
291 id.push(':');
292 id.push_str(&encode_segment(segment));
293 }
294 Did::parse(&format!("did:web:{id}")).map_err(|_| invalid("the URL does not map to a DID"))
295}
296
297/// The `did:web` of the origin of `url`: as [`did_from_url`], but the URL must have no path.
298///
299/// # Errors
300///
301/// [`WebError::InvalidUrl`] when the URL has a path, or breaks a rule of [`did_from_url`].
302pub fn did_from_origin(url: &Url) -> Result<Did, WebError> {
303 if !matches!(url.path(), "" | "/") {
304 return Err(WebError::InvalidUrl {
305 url: url.clone(),
306 reason: "an origin has no path",
307 });
308 }
309 did_from_url(url)
310}
311
312/// `segment` with every byte outside the DID `idchar` set percent-encoded.
313fn encode_segment(segment: &str) -> String {
314 let bytes = segment.as_bytes();
315 let mut out = String::with_capacity(segment.len());
316 let mut i = 0;
317 while i < bytes.len() {
318 let b = bytes[i];
319 if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_') {
320 out.push(char::from(b));
321 } else if b == b'%'
322 && bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit)
323 && bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit)
324 {
325 out.push_str(&segment[i..i + 3]);
326 i += 3;
327 continue;
328 } else {
329 let _ = write!(out, "%{b:02X}");
330 }
331 i += 1;
332 }
333 out
334}
335
336/// The URL of the document of `did` (the Read operation's URL construction):
337/// `https://host[:port]/.well-known/did.json` for a bare domain, else
338/// `https://host[:port]/<segments>/did.json`.
339///
340/// # Errors
341///
342/// [`WebError::InvalidDid`] when `did` is not a `did:web`, names an IP address, has a
343/// malformed port, or has an empty, `.` or `..` path segment, percent-encoded or not.
344///
345/// # Examples
346///
347/// ```
348/// use identity_kit::did::Did;
349/// use identity_kit::methods::web::document_url;
350///
351/// let url = document_url(&Did::parse("did:web:example.com")?)?;
352/// assert_eq!(url.as_str(), "https://example.com/.well-known/did.json");
353///
354/// let url = document_url(&Did::parse("did:web:example.com%3A3000:user:alice")?)?;
355/// assert_eq!(url.as_str(), "https://example.com:3000/user/alice/did.json");
356/// # Ok::<(), Box<dyn std::error::Error>>(())
357/// ```
358pub fn document_url(did: &Did) -> Result<Url, WebError> {
359 let invalid = |reason| WebError::InvalidDid {
360 did: did.clone(),
361 reason,
362 };
363 if did.method() != METHOD {
364 return Err(invalid("the method is not `web`"));
365 }
366 let mut segments = did.method_specific_id().split(':');
367 let authority = percent_decode(segments.next().unwrap_or_default());
368 let (host, port) = match authority.rsplit_once(':') {
369 Some((host, port)) => (host, Some(port)),
370 None => (authority.as_str(), None),
371 };
372 if host.is_empty()
373 || !host
374 .bytes()
375 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
376 {
377 return Err(invalid("the host is not a domain name"));
378 }
379 if let Some(port) = port
380 && (port.is_empty() || port.len() > 5 || !port.bytes().all(|b| b.is_ascii_digit()))
381 {
382 return Err(invalid("the port is not a number"));
383 }
384 let mut path = String::new();
385 let mut has_segments = false;
386 for segment in segments {
387 if is_dot_segment(segment) {
388 return Err(invalid("a path segment is empty, `.` or `..`"));
389 }
390 has_segments = true;
391 path.push('/');
392 path.push_str(segment);
393 }
394 if !has_segments {
395 path.push('/');
396 path.push_str(WELL_KNOWN);
397 }
398 let port = port.map(|port| format!(":{port}")).unwrap_or_default();
399 let url = Url::parse(&format!("https://{host}{port}{path}/{DOCUMENT_FILE}"))
400 .map_err(|_| invalid("the DID does not map to a URL"))?;
401 match url.host() {
402 Some(Host::Domain(_)) => Ok(url),
403 _ => Err(invalid("the host is an IP address")),
404 }
405}
406
407/// Whether `segment` is empty, or is `.` or `..` as the URL parser reads it: a segment of `.` and
408/// `%2E` in any combination and case is a dot segment, which the parser collapses into the path
409/// before it, so `did:web:example.com:a:%2E%2E:b` would name `https://example.com/b/did.json`.
410fn is_dot_segment(segment: &str) -> bool {
411 let mut dots = 0;
412 let mut i = 0;
413 let bytes = segment.as_bytes();
414 while i < bytes.len() {
415 if bytes[i] == b'.' {
416 i += 1;
417 } else if bytes[i..]
418 .get(..3)
419 .is_some_and(|b| b.eq_ignore_ascii_case(b"%2e"))
420 {
421 i += 3;
422 } else {
423 return false;
424 }
425 dots += 1;
426 }
427 dots <= 2
428}
429
430/// Decodes the percent-encoded octets of a DID segment, which the DID syntax has checked to be
431/// complete; non-UTF-8 octets become replacement characters and fail the host check.
432#[expect(clippy::cast_possible_truncation, reason = "a hex digit is below 16")]
433fn percent_decode(s: &str) -> String {
434 let bytes = s.as_bytes();
435 let mut out = Vec::with_capacity(bytes.len());
436 let mut i = 0;
437 while i < bytes.len() {
438 match (bytes[i], bytes.get(i + 1), bytes.get(i + 2)) {
439 (b'%', Some(hi), Some(lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() => {
440 let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8;
441 out.push(hex(*hi) << 4 | hex(*lo));
442 i += 3;
443 }
444 (b, _, _) => {
445 out.push(b);
446 i += 1;
447 }
448 }
449 }
450 String::from_utf8_lossy(&out).into_owned()
451}
452
453// ---------------------------------------------------------------------------------------------
454// The method
455// ---------------------------------------------------------------------------------------------
456
457/// The `did:web` method: a [`DidResolver`] fetching documents through an [`HttpClient`], and a
458/// [`DidProducer`] building the document to publish.
459///
460/// # Examples
461///
462/// ```
463/// use identity_kit::did::Did;
464/// use identity_kit::methods::web::{DidWeb, HttpClient, HttpError, HttpResponse};
465/// use identity_kit::resolver::Resolver;
466/// use url::Url;
467///
468/// /// Serves one document from memory.
469/// struct Static(String);
470///
471/// #[async_trait::async_trait]
472/// impl HttpClient for Static {
473/// async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
474/// Ok(if url.as_str() == "https://example.com/.well-known/did.json" {
475/// HttpResponse::new(200, self.0.as_bytes())
476/// } else {
477/// HttpResponse::new(404, Vec::new())
478/// })
479/// }
480/// }
481///
482/// # tokio::runtime::Builder::new_current_thread().build()?.block_on(async {
483/// let document = r#"{"id": "did:web:example.com"}"#.to_owned();
484/// let resolver = Resolver::builder().with(DidWeb::new(Static(document))).build();
485/// let did = Did::parse("did:web:example.com")?;
486/// assert_eq!(resolver.resolve_document(&did).await?.id, did);
487/// # Ok::<(), Box<dyn std::error::Error>>(())
488/// # })?;
489/// # Ok::<(), Box<dyn std::error::Error>>(())
490/// ```
491#[derive(Debug, Clone)]
492pub struct DidWeb<C> {
493 client: C,
494}
495
496impl<C: HttpClient> DidWeb<C> {
497 /// The method fetching documents with `client`.
498 pub const fn new(client: C) -> Self {
499 Self { client }
500 }
501
502 /// The HTTP client.
503 pub const fn client(&self) -> &C {
504 &self.client
505 }
506}
507
508#[async_trait::async_trait]
509impl<C: HttpClient> DidResolver for DidWeb<C> {
510 fn method(&self) -> &'static str {
511 METHOD
512 }
513
514 /// Fetches the document of `did` from [`document_url`] and checks that its `id` is `did`.
515 ///
516 /// # Errors
517 ///
518 /// A [`ResolutionError`] whose kind is:
519 ///
520 /// - [`InvalidDid`](ResolutionErrorKind::InvalidDid) when `did` has no document URL, caused
521 /// by the [`WebError`].
522 /// - [`NotFound`](ResolutionErrorKind::NotFound) on a `404` or `410` response.
523 /// - [`InternalError`](ResolutionErrorKind::InternalError) when the client fails, caused by
524 /// its [`HttpError`], or when it returns any other non-success status, which the detail
525 /// names.
526 /// - [`InvalidDidDocument`](ResolutionErrorKind::InvalidDidDocument) when the body is not a
527 /// DID document, caused by the [`serde_json::Error`], or is the document of another DID,
528 /// which the detail names.
529 /// - [`RepresentationNotSupported`](ResolutionErrorKind::RepresentationNotSupported) for an
530 /// `accept` that is not JSON.
531 async fn resolve(
532 &self,
533 did: &Did,
534 options: &ResolutionOptions,
535 ) -> Result<ResolutionResult, ResolutionError> {
536 check_accept(options)?;
537 let url = document_url(did).map_err(|error| {
538 ResolutionError::new(ResolutionErrorKind::InvalidDid).with_source(error)
539 })?;
540 let response = self.client.get(&url).await.map_err(|error| {
541 ResolutionError::new(ResolutionErrorKind::InternalError)
542 .with_detail(format!("fetching `{url}` failed"))
543 .with_source(error)
544 })?;
545 let kind = match response.status {
546 200..=299 => None,
547 404 | 410 => Some(ResolutionErrorKind::NotFound),
548 _ => Some(ResolutionErrorKind::InternalError),
549 };
550 if let Some(kind) = kind {
551 return Err(ResolutionError::new(kind).with_detail(format!(
552 "`{url}` responded with HTTP status {}",
553 response.status
554 )));
555 }
556 let document: DidDocument = serde_json::from_slice(&response.body).map_err(|error| {
557 ResolutionError::new(ResolutionErrorKind::InvalidDidDocument)
558 .with_detail(format!("the body of `{url}` is not a DID document"))
559 .with_source(error)
560 })?;
561 if document.id != *did {
562 return Err(
563 ResolutionError::new(ResolutionErrorKind::InvalidDidDocument).with_detail(format!(
564 "`{url}` holds the document of `{}`, not `{did}`",
565 document.id
566 )),
567 );
568 }
569 Ok(ResolutionResult::new(document))
570 }
571}
572
573#[async_trait::async_trait]
574impl<C: HttpClient> DidProducer for DidWeb<C> {
575 /// The HTTPS URL the document will be served under, as [`did_from_url`] reads it.
576 type Params = Url;
577
578 fn method(&self) -> &'static str {
579 METHOD
580 }
581
582 /// Hosted on the web, any number of keys, updated by republishing; fragments come from the
583 /// keys.
584 fn capabilities(&self) -> MethodCapabilities {
585 MethodCapabilities::new(Hosting::Web).with_updatable(true)
586 }
587
588 /// The document to publish at `url` for `keys`.
589 ///
590 /// Each key becomes a `JsonWebKey2020` verification method whose fragment is the key's
591 /// `kid`, or `key-<index>` when it has none. A key with `use` `enc`, or an X25519 key, is
592 /// referenced from `keyAgreement`; every other key from `authentication`,
593 /// `assertionMethod`, `capabilityInvocation` and `capabilityDelegation`. The document's
594 /// `@context` is [`CONTEXT`].
595 ///
596 /// # Errors
597 ///
598 /// - [`ProduceError::NoKeys`] without a key.
599 /// - [`ProduceError::InvalidParams`] when `url` has no `did:web` (see [`did_from_url`]).
600 /// - [`ProduceError::UnsupportedKey`] for a `kid` that is not a valid fragment.
601 /// - [`ProduceError::Document`] for two keys with the same `kid`.
602 async fn produce(&self, keys: &[Jwk], url: Url) -> Result<DidDocument, ProduceError> {
603 if keys.is_empty() {
604 return Err(ProduceError::NoKeys);
605 }
606 let did = did_from_url(&url).map_err(|error| ProduceError::InvalidParams {
607 reason: "the URL is not a `did:web` location".to_owned(),
608 source: Some(Box::new(error)),
609 })?;
610 let mut builder = DidDocument::builder(did.clone()).context(Context::uris(CONTEXT));
611 for (index, key) in keys.iter().enumerate() {
612 let id = match key.kid() {
613 Some(kid) => {
614 did.join(&format!("#{kid}"))
615 .map_err(|error| ProduceError::UnsupportedKey {
616 index,
617 reason: format!("`kid` {kid:?} is not a valid DID URL fragment"),
618 source: Some(Box::new(error)),
619 })?
620 }
621 None => did.to_url().with_fragment(&format!("key-{index}")),
622 };
623 let relationships: &[VerificationRelationship] =
624 if key.key_use() == Some("enc") || key.crv() == Some("X25519") {
625 &[VerificationRelationship::KeyAgreement]
626 } else {
627 &[
628 VerificationRelationship::Authentication,
629 VerificationRelationship::AssertionMethod,
630 VerificationRelationship::CapabilityInvocation,
631 VerificationRelationship::CapabilityDelegation,
632 ]
633 };
634 let method = VerificationMethod::new(
635 id,
636 MethodType::JSON_WEB_KEY_2020,
637 did.clone(),
638 VerificationMaterial::jwk(key),
639 );
640 builder = builder.verification_method(method, relationships.iter().copied());
641 }
642 Ok(builder.build()?)
643 }
644}