Skip to main content

dns_update/providers/
ovh.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use 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(&params)
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}