1use crate::http::{HttpClient, HttpClientBuilder};
13use crate::{
14 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
15 TLSARecord, TlsaCertUsage, TlsaMatching, TlsaSelector, crypto, utils::strip_origin_from_name,
16};
17use reqwest::Method;
18use serde::{Deserialize, Serialize};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21#[derive(Clone)]
22pub struct OvhProvider {
23 application_key: String,
24 application_secret: String,
25 consumer_key: String,
26 pub(crate) endpoint: String,
27 client: HttpClient,
28}
29
30#[derive(Serialize, Debug)]
31pub struct CreateDnsRecordParams {
32 #[serde(rename = "fieldType")]
33 pub field_type: String,
34 #[serde(rename = "subDomain")]
35 pub sub_domain: String,
36 pub target: String,
37 pub ttl: u32,
38}
39
40#[derive(Serialize, Debug)]
41pub struct UpdateDnsRecordParams {
42 pub target: String,
43 pub ttl: u32,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct OvhRecordFormat {
48 pub field_type: String,
49 pub target: String,
50}
51
52#[derive(Deserialize, Debug)]
53struct OvhRecordBody {
54 id: u64,
55 #[serde(rename = "fieldType")]
56 field_type: String,
57 target: String,
58}
59
60#[derive(Debug)]
61pub enum OvhEndpoint {
62 OvhEu,
63 OvhCa,
64 KimsufiEu,
65 KimsufiCa,
66 SoyoustartEu,
67 SoyoustartCa,
68}
69
70impl OvhEndpoint {
71 fn api_url(&self) -> &'static str {
72 match self {
73 OvhEndpoint::OvhEu => "https://eu.api.ovh.com/1.0",
74 OvhEndpoint::OvhCa => "https://ca.api.ovh.com/1.0",
75 OvhEndpoint::KimsufiEu => "https://eu.api.kimsufi.com/1.0",
76 OvhEndpoint::KimsufiCa => "https://ca.api.kimsufi.com/1.0",
77 OvhEndpoint::SoyoustartEu => "https://eu.api.soyoustart.com/1.0",
78 OvhEndpoint::SoyoustartCa => "https://ca.api.soyoustart.com/1.0",
79 }
80 }
81}
82
83impl std::str::FromStr for OvhEndpoint {
84 type Err = Error;
85
86 fn from_str(s: &str) -> Result<Self, Self::Err> {
87 match s {
88 "ovh-eu" => Ok(OvhEndpoint::OvhEu),
89 "ovh-ca" => Ok(OvhEndpoint::OvhCa),
90 "kimsufi-eu" => Ok(OvhEndpoint::KimsufiEu),
91 "kimsufi-ca" => Ok(OvhEndpoint::KimsufiCa),
92 "soyoustart-eu" => Ok(OvhEndpoint::SoyoustartEu),
93 "soyoustart-ca" => Ok(OvhEndpoint::SoyoustartCa),
94 _ => Err(Error::Parse(format!("Invalid OVH endpoint: {}", s))),
95 }
96 }
97}
98
99impl From<&DnsRecord> for OvhRecordFormat {
100 fn from(record: &DnsRecord) -> Self {
101 match record {
102 DnsRecord::A(content) => OvhRecordFormat {
103 field_type: "A".to_string(),
104 target: content.to_string(),
105 },
106 DnsRecord::AAAA(content) => OvhRecordFormat {
107 field_type: "AAAA".to_string(),
108 target: content.to_string(),
109 },
110 DnsRecord::CNAME(content) => OvhRecordFormat {
111 field_type: "CNAME".to_string(),
112 target: format!("{}.", content.trim_end_matches('.')),
113 },
114 DnsRecord::NS(content) => OvhRecordFormat {
115 field_type: "NS".to_string(),
116 target: format!("{}.", content.trim_end_matches('.')),
117 },
118 DnsRecord::MX(mx) => OvhRecordFormat {
119 field_type: "MX".to_string(),
120 target: format!("{} {}.", mx.priority, mx.exchange.trim_end_matches('.')),
121 },
122 DnsRecord::TXT(content) => OvhRecordFormat {
123 field_type: "TXT".to_string(),
124 target: content.clone(),
125 },
126 DnsRecord::SRV(srv) => OvhRecordFormat {
127 field_type: "SRV".to_string(),
128 target: format!(
129 "{} {} {} {}.",
130 srv.priority,
131 srv.weight,
132 srv.port,
133 srv.target.trim_end_matches('.')
134 ),
135 },
136 DnsRecord::TLSA(tlsa) => OvhRecordFormat {
137 field_type: "TLSA".to_string(),
138 target: tlsa.to_string(),
139 },
140 DnsRecord::CAA(caa) => OvhRecordFormat {
141 field_type: "CAA".to_string(),
142 target: caa.to_string(),
143 },
144 }
145 }
146}
147
148impl OvhProvider {
149 pub(crate) fn new(
150 application_key: impl AsRef<str>,
151 application_secret: impl AsRef<str>,
152 consumer_key: impl AsRef<str>,
153 endpoint: OvhEndpoint,
154 timeout: Option<Duration>,
155 ) -> crate::Result<Self> {
156 let client = HttpClientBuilder::default()
157 .with_timeout(timeout.or(Some(Duration::from_secs(30))))
158 .build();
159 Ok(Self {
160 application_key: application_key.as_ref().to_string(),
161 application_secret: application_secret.as_ref().to_string(),
162 consumer_key: consumer_key.as_ref().to_string(),
163 endpoint: endpoint.api_url().to_string(),
164 client,
165 })
166 }
167
168 fn generate_signature(&self, method: &str, url: &str, body: &str, timestamp: u64) -> String {
169 let data = format!(
170 "{}+{}+{}+{}+{}+{}",
171 self.application_secret, self.consumer_key, method, url, body, timestamp
172 );
173
174 let hash = crypto::sha1_digest(data.as_bytes());
175 let hex_string = hash
176 .iter()
177 .map(|b| format!("{:02x}", b))
178 .collect::<String>();
179 format!("$1${}", hex_string)
180 }
181
182 async fn send_authenticated_request(
183 &self,
184 method: Method,
185 url: &str,
186 body: &str,
187 ) -> crate::Result<String> {
188 let timestamp = SystemTime::now()
189 .duration_since(UNIX_EPOCH)
190 .map_err(|e| Error::Client(format!("Failed to get timestamp: {}", e)))?
191 .as_secs();
192
193 let signature = self.generate_signature(method.as_str(), url, body, timestamp);
194
195 let mut request = match method {
196 Method::GET => self.client.get(url),
197 Method::POST => self.client.post(url),
198 Method::PUT => self.client.put(url),
199 Method::DELETE => self.client.delete(url),
200 Method::PATCH => self.client.patch(url),
201 other => {
202 return Err(Error::Unsupported(format!(
203 "OVH unsupported method: {other}"
204 )));
205 }
206 };
207 request = request
208 .with_header("X-Ovh-Application", &self.application_key)
209 .with_header("X-Ovh-Consumer", &self.consumer_key)
210 .with_header("X-Ovh-Signature", signature)
211 .with_header("X-Ovh-Timestamp", timestamp.to_string());
212
213 if !body.is_empty() {
214 request = request.with_raw_body(body.to_string());
215 }
216
217 request.send_raw().await
218 }
219
220 async fn get_zone_name(&self, origin: impl IntoFqdn<'_>) -> crate::Result<String> {
221 let domain = origin.into_name();
222 let domain_name = domain.trim_end_matches('.');
223
224 let url = format!("{}/domain/zone/{}", self.endpoint, domain_name);
225 self.send_authenticated_request(Method::GET, &url, "")
226 .await
227 .map(|_| domain_name.to_string())
228 .map_err(|_| Error::Api(format!("Zone {} not found or not accessible", domain_name)))
229 }
230
231 async fn list_record_ids(
232 &self,
233 zone: &str,
234 subdomain: &str,
235 record_type: DnsRecordType,
236 ) -> crate::Result<Vec<u64>> {
237 let url = format!(
238 "{}/domain/zone/{}/record?fieldType={}&subDomain={}",
239 self.endpoint,
240 zone,
241 record_type.as_str(),
242 subdomain
243 );
244 let body = self
245 .send_authenticated_request(Method::GET, &url, "")
246 .await?;
247 serde_json::from_str(&body)
248 .map_err(|e| Error::Api(format!("Failed to parse record list: {}", e)))
249 }
250
251 async fn fetch_record(&self, zone: &str, id: u64) -> crate::Result<OvhRecordBody> {
252 let url = format!("{}/domain/zone/{}/record/{}", self.endpoint, zone, id);
253 let body = self
254 .send_authenticated_request(Method::GET, &url, "")
255 .await?;
256 serde_json::from_str(&body)
257 .map_err(|e| Error::Api(format!("Failed to parse record {}: {}", id, e)))
258 }
259
260 async fn list_at(
261 &self,
262 zone: &str,
263 subdomain: &str,
264 record_type: DnsRecordType,
265 ) -> crate::Result<Vec<OvhRecordBody>> {
266 let ids = self.list_record_ids(zone, subdomain, record_type).await?;
267 let mut out = Vec::with_capacity(ids.len());
268 for id in ids {
269 let body = self.fetch_record(zone, id).await?;
270 if body.field_type == record_type.as_str() {
271 out.push(body);
272 }
273 }
274 Ok(out)
275 }
276
277 async fn refresh_zone(&self, zone: &str) -> crate::Result<()> {
278 let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone);
279 self.send_authenticated_request(Method::POST, &url, "")
280 .await
281 .map(|_| ())
282 }
283
284 async fn post_record(
285 &self,
286 zone: &str,
287 subdomain: &str,
288 ttl: u32,
289 wire: &OvhRecordFormat,
290 ) -> crate::Result<()> {
291 let params = CreateDnsRecordParams {
292 field_type: wire.field_type.clone(),
293 sub_domain: subdomain.to_string(),
294 target: wire.target.clone(),
295 ttl,
296 };
297 let body = serde_json::to_string(¶ms)
298 .map_err(|e| Error::Serialize(format!("Failed to serialize record: {}", e)))?;
299
300 let url = format!("{}/domain/zone/{}/record", self.endpoint, zone);
301 self.send_authenticated_request(Method::POST, &url, &body)
302 .await
303 .map(|_| ())
304 }
305
306 async fn delete_record_id(&self, zone: &str, id: u64) -> crate::Result<()> {
307 let url = format!("{}/domain/zone/{}/record/{}", self.endpoint, zone, id);
308 self.send_authenticated_request(Method::DELETE, &url, "")
309 .await
310 .map(|_| ())
311 }
312
313 fn subdomain_for<'a>(&self, zone: &str, name: impl IntoFqdn<'a>) -> String {
314 let name = name.into_name();
315 let subdomain = strip_origin_from_name(&name, zone, Some(""));
316 if subdomain == "@" {
317 String::new()
318 } else {
319 subdomain
320 }
321 }
322
323 pub(crate) async fn set_rrset(
324 &self,
325 name: impl IntoFqdn<'_>,
326 record_type: DnsRecordType,
327 ttl: u32,
328 records: Vec<DnsRecord>,
329 origin: impl IntoFqdn<'_>,
330 ) -> crate::Result<()> {
331 let desired = build_wire(record_type, &records)?;
332 let zone = self.get_zone_name(origin).await?;
333 let subdomain = self.subdomain_for(&zone, name);
334
335 let existing = self.list_at(&zone, &subdomain, record_type).await?;
336
337 let mut to_add: Vec<OvhRecordFormat> = Vec::new();
338 let mut leftovers: Vec<&OvhRecordBody> = existing.iter().collect();
339
340 for wanted in &desired {
341 if let Some(pos) = leftovers.iter().position(|e| target_equivalent(e, wanted)) {
342 leftovers.swap_remove(pos);
343 } else {
344 to_add.push(wanted.clone());
345 }
346 }
347
348 let mut mutated = false;
349 for stale in leftovers {
350 self.delete_record_id(&zone, stale.id).await?;
351 mutated = true;
352 }
353 for wire in to_add {
354 self.post_record(&zone, &subdomain, ttl, &wire).await?;
355 mutated = true;
356 }
357
358 if mutated {
359 self.refresh_zone(&zone).await?;
360 }
361 Ok(())
362 }
363
364 pub(crate) async fn add_to_rrset(
365 &self,
366 name: impl IntoFqdn<'_>,
367 record_type: DnsRecordType,
368 ttl: u32,
369 records: Vec<DnsRecord>,
370 origin: impl IntoFqdn<'_>,
371 ) -> crate::Result<()> {
372 if records.is_empty() {
373 return Ok(());
374 }
375 let desired = build_wire(record_type, &records)?;
376 let zone = self.get_zone_name(origin).await?;
377 let subdomain = self.subdomain_for(&zone, name);
378
379 let existing = self.list_at(&zone, &subdomain, record_type).await?;
380
381 let mut mutated = false;
382 for wire in desired {
383 if existing.iter().any(|e| target_equivalent(e, &wire)) {
384 continue;
385 }
386 self.post_record(&zone, &subdomain, ttl, &wire).await?;
387 mutated = true;
388 }
389
390 if mutated {
391 self.refresh_zone(&zone).await?;
392 }
393 Ok(())
394 }
395
396 pub(crate) async fn remove_from_rrset(
397 &self,
398 name: impl IntoFqdn<'_>,
399 record_type: DnsRecordType,
400 records: Vec<DnsRecord>,
401 origin: impl IntoFqdn<'_>,
402 ) -> crate::Result<()> {
403 if records.is_empty() {
404 return Ok(());
405 }
406 let to_remove = build_wire(record_type, &records)?;
407 let zone = self.get_zone_name(origin).await?;
408 let subdomain = self.subdomain_for(&zone, name);
409
410 let existing = self.list_at(&zone, &subdomain, record_type).await?;
411
412 let mut mutated = false;
413 for wire in to_remove {
414 if let Some(entry) = existing.iter().find(|e| target_equivalent(e, &wire)) {
415 self.delete_record_id(&zone, entry.id).await?;
416 mutated = true;
417 }
418 }
419
420 if mutated {
421 self.refresh_zone(&zone).await?;
422 }
423 Ok(())
424 }
425
426 pub(crate) async fn list_rrset(
427 &self,
428 name: impl IntoFqdn<'_>,
429 record_type: DnsRecordType,
430 origin: impl IntoFqdn<'_>,
431 ) -> crate::Result<Vec<DnsRecord>> {
432 let zone = self.get_zone_name(origin).await?;
433 let subdomain = self.subdomain_for(&zone, name);
434 let existing = self.list_at(&zone, &subdomain, record_type).await?;
435 existing
436 .into_iter()
437 .map(|e| parse_ovh_target(record_type, &e.target))
438 .collect()
439 }
440}
441
442fn build_wire(
443 expected_type: DnsRecordType,
444 records: &[DnsRecord],
445) -> crate::Result<Vec<OvhRecordFormat>> {
446 let mut out = Vec::with_capacity(records.len());
447 for record in records {
448 if record.as_type() != expected_type {
449 return Err(Error::Api(format!(
450 "RRSet record type mismatch: expected {}, got {}",
451 expected_type.as_str(),
452 record.as_type().as_str(),
453 )));
454 }
455 out.push(record.into());
456 }
457 Ok(out)
458}
459
460fn target_equivalent(existing: &OvhRecordBody, wanted: &OvhRecordFormat) -> bool {
461 if existing.field_type != wanted.field_type {
462 return false;
463 }
464 if existing.target == wanted.target {
465 return true;
466 }
467 match wanted.field_type.as_str() {
468 "CNAME" | "NS" => existing
469 .target
470 .trim_end_matches('.')
471 .eq_ignore_ascii_case(wanted.target.trim_end_matches('.')),
472 "MX" | "SRV" => {
473 normalize_priority_target(&existing.target) == normalize_priority_target(&wanted.target)
474 }
475 "TLSA" => existing.target.eq_ignore_ascii_case(&wanted.target),
476 _ => false,
477 }
478}
479
480fn normalize_priority_target(value: &str) -> String {
481 let trimmed = value.trim();
482 let last_space = trimmed.rfind(char::is_whitespace);
483 match last_space {
484 Some(idx) => {
485 let (prefix, tail) = trimmed.split_at(idx);
486 let tail_trimmed = tail.trim().trim_end_matches('.').to_ascii_lowercase();
487 format!("{} {}", prefix.trim(), tail_trimmed)
488 }
489 None => trimmed.trim_end_matches('.').to_ascii_lowercase(),
490 }
491}
492
493fn parse_ovh_target(record_type: DnsRecordType, target: &str) -> crate::Result<DnsRecord> {
494 match record_type {
495 DnsRecordType::A => target
496 .parse()
497 .map(DnsRecord::A)
498 .map_err(|e| Error::Parse(format!("invalid A target {}: {}", target, e))),
499 DnsRecordType::AAAA => target
500 .parse()
501 .map(DnsRecord::AAAA)
502 .map_err(|e| Error::Parse(format!("invalid AAAA target {}: {}", target, e))),
503 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(target.to_string())),
504 DnsRecordType::NS => Ok(DnsRecord::NS(target.to_string())),
505 DnsRecordType::TXT => Ok(DnsRecord::TXT(target.to_string())),
506 DnsRecordType::MX => {
507 let mut parts = target.splitn(2, char::is_whitespace);
508 let priority = parts
509 .next()
510 .ok_or_else(|| Error::Parse(format!("invalid MX target: {}", target)))?
511 .parse::<u16>()
512 .map_err(|e| Error::Parse(format!("invalid MX priority in {}: {}", target, e)))?;
513 let exchange = parts
514 .next()
515 .ok_or_else(|| Error::Parse(format!("MX target missing exchange: {}", target)))?
516 .trim()
517 .to_string();
518 Ok(DnsRecord::MX(MXRecord { exchange, priority }))
519 }
520 DnsRecordType::SRV => {
521 let mut parts = target.split_whitespace();
522 let priority = parts
523 .next()
524 .ok_or_else(|| Error::Parse(format!("invalid SRV target: {}", target)))?
525 .parse::<u16>()
526 .map_err(|e| Error::Parse(format!("invalid SRV priority in {}: {}", target, e)))?;
527 let weight = parts
528 .next()
529 .ok_or_else(|| Error::Parse(format!("invalid SRV target: {}", target)))?
530 .parse::<u16>()
531 .map_err(|e| Error::Parse(format!("invalid SRV weight in {}: {}", target, e)))?;
532 let port = parts
533 .next()
534 .ok_or_else(|| Error::Parse(format!("invalid SRV target: {}", target)))?
535 .parse::<u16>()
536 .map_err(|e| Error::Parse(format!("invalid SRV port in {}: {}", target, e)))?;
537 let srv_target = parts
538 .next()
539 .ok_or_else(|| Error::Parse(format!("SRV target missing host: {}", target)))?
540 .to_string();
541 Ok(DnsRecord::SRV(SRVRecord {
542 priority,
543 weight,
544 port,
545 target: srv_target,
546 }))
547 }
548 DnsRecordType::TLSA => {
549 let mut parts = target.split_whitespace();
550 let usage = parts
551 .next()
552 .ok_or_else(|| Error::Parse(format!("invalid TLSA target: {}", target)))?
553 .parse::<u8>()
554 .map_err(|e| Error::Parse(format!("invalid TLSA usage in {}: {}", target, e)))?;
555 let selector = parts
556 .next()
557 .ok_or_else(|| Error::Parse(format!("invalid TLSA target: {}", target)))?
558 .parse::<u8>()
559 .map_err(|e| Error::Parse(format!("invalid TLSA selector in {}: {}", target, e)))?;
560 let matching = parts
561 .next()
562 .ok_or_else(|| Error::Parse(format!("invalid TLSA target: {}", target)))?
563 .parse::<u8>()
564 .map_err(|e| Error::Parse(format!("invalid TLSA matching in {}: {}", target, e)))?;
565 let cert_hex = parts
566 .next()
567 .ok_or_else(|| Error::Parse(format!("TLSA target missing data: {}", target)))?;
568 Ok(DnsRecord::TLSA(TLSARecord {
569 cert_usage: tlsa_cert_usage_from_u8(usage)?,
570 selector: tlsa_selector_from_u8(selector)?,
571 matching: tlsa_matching_from_u8(matching)?,
572 cert_data: decode_hex(cert_hex)?,
573 }))
574 }
575 DnsRecordType::CAA => parse_caa(target),
576 }
577}
578
579fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
580 Ok(match value {
581 0 => TlsaCertUsage::PkixTa,
582 1 => TlsaCertUsage::PkixEe,
583 2 => TlsaCertUsage::DaneTa,
584 3 => TlsaCertUsage::DaneEe,
585 255 => TlsaCertUsage::Private,
586 _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {}", value))),
587 })
588}
589
590fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
591 Ok(match value {
592 0 => TlsaSelector::Full,
593 1 => TlsaSelector::Spki,
594 255 => TlsaSelector::Private,
595 _ => return Err(Error::Parse(format!("unknown TLSA selector: {}", value))),
596 })
597}
598
599fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
600 Ok(match value {
601 0 => TlsaMatching::Raw,
602 1 => TlsaMatching::Sha256,
603 2 => TlsaMatching::Sha512,
604 255 => TlsaMatching::Private,
605 _ => return Err(Error::Parse(format!("unknown TLSA matching: {}", value))),
606 })
607}
608
609fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
610 if !hex.len().is_multiple_of(2) {
611 return Err(Error::Parse(format!("invalid hex string: {}", hex)));
612 }
613 (0..hex.len())
614 .step_by(2)
615 .map(|i| {
616 u8::from_str_radix(&hex[i..i + 2], 16)
617 .map_err(|e| Error::Parse(format!("invalid hex byte: {}", e)))
618 })
619 .collect()
620}
621
622fn parse_caa(target: &str) -> crate::Result<DnsRecord> {
623 let mut parts = target.splitn(3, char::is_whitespace);
624 let flags = parts
625 .next()
626 .ok_or_else(|| Error::Parse(format!("invalid CAA target: {}", target)))?
627 .parse::<u8>()
628 .map_err(|e| Error::Parse(format!("invalid CAA flags in {}: {}", target, e)))?;
629 let tag = parts
630 .next()
631 .ok_or_else(|| Error::Parse(format!("CAA target missing tag: {}", target)))?
632 .to_string();
633 let raw_value = parts
634 .next()
635 .ok_or_else(|| Error::Parse(format!("CAA target missing value: {}", target)))?
636 .trim();
637 let unquoted_full = strip_caa_quotes(raw_value);
638
639 let issuer_critical = flags & 0x80 != 0;
640 match tag.as_str() {
641 "issue" => {
642 let (name, options) = parse_caa_value(&unquoted_full);
643 Ok(DnsRecord::CAA(CAARecord::Issue {
644 issuer_critical,
645 name,
646 options,
647 }))
648 }
649 "issuewild" => {
650 let (name, options) = parse_caa_value(&unquoted_full);
651 Ok(DnsRecord::CAA(CAARecord::IssueWild {
652 issuer_critical,
653 name,
654 options,
655 }))
656 }
657 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
658 issuer_critical,
659 url: unquoted_full,
660 })),
661 other => Err(Error::Parse(format!("unknown CAA tag: {}", other))),
662 }
663}
664
665fn strip_caa_quotes(s: &str) -> String {
666 let s = s.trim();
667 if let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
668 inner.to_string()
669 } else {
670 s.to_string()
671 }
672}
673
674fn parse_caa_value(value: &str) -> (Option<String>, Vec<KeyValue>) {
675 let mut parts = value.split(';').map(str::trim);
676 let name_part = parts.next().unwrap_or("").trim().to_string();
677 let name = if name_part.is_empty() {
678 None
679 } else {
680 Some(name_part)
681 };
682 let options = parts
683 .filter(|p| !p.is_empty())
684 .map(|p| match p.split_once('=') {
685 Some((k, v)) => KeyValue {
686 key: k.trim().to_string(),
687 value: v.trim().to_string(),
688 },
689 None => KeyValue {
690 key: p.trim().to_string(),
691 value: String::new(),
692 },
693 })
694 .collect();
695 (name, options)
696}