Skip to main content

hb46pp/
model.rs

1use std::{collections::BTreeMap, fmt, str::FromStr};
2
3use serde::de::DeserializeOwned;
4use serde_json::{Map, Value};
5use thiserror::Error;
6use url::{Host, Url};
7
8const V6MIG_SPEC: &str = "v6mig-1";
9const MAX_TTL_SECS: u64 = 604_800;
10
11#[derive(Debug, Error)]
12#[non_exhaustive]
13/// Errors returned when parsing and validating a [`Bootstrap`] record.
14pub enum BootstrapError {
15    /// The provisioning endpoint is not a supported HTTP or HTTPS URL.
16    #[error("url: {0}, err: {1}")]
17    InvalidUrl(String, String),
18    /// The bootstrap record contains an unsupported `t` value.
19    #[error("extracting tls policy : {0}")]
20    InvalidTlsPolicy(String),
21    /// A bootstrap field does not have its required `key=value` form or order.
22    #[error("parsing field, expected: '{0}', got: '{1}'")]
23    MalformedField(String, String),
24    /// A required bootstrap field is absent.
25    #[error("missing field: {0}")]
26    MissingField(&'static str),
27    /// The bootstrap record declares an unsupported protocol version.
28    #[error("unsupported spec version: {0}")]
29    UnsupportedVersion(String),
30    /// Certificate validation was requested for an HTTP endpoint.
31    #[error("tls policy set to validate for http scheme")]
32    InvalidTlsForHttp,
33    /// The provisioning URL does not contain a host.
34    #[error("provisioning URL must contain a host")]
35    MissingUrlHost,
36    /// The record contains fields beyond the three fields defined by HB46PP.
37    #[error("record contain data beyond spec fields, record: {0}")]
38    InvalidRecord(String),
39    /// The provisioning endpoint uses an IPv4 address instead of IPv6.
40    #[error("provisioning URL cannot use an IPv4 address")]
41    Ipv4EndpointNotAllowed,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
45/// An IPv4-over-IPv6 method recognized by HB46PP.
46pub enum Capability {
47    /// 464XLAT.
48    Xlat464,
49    /// Dual-Stack Lite.
50    DsLite,
51    /// An RFC 2473 IP-in-IP tunnel.
52    IpIp,
53    /// Lightweight 4over6.
54    Lw4o6,
55    /// Mapping of Address and Port with Encapsulation.
56    MapE,
57    /// Mapping of Address and Port using Translation.
58    MapT,
59}
60
61impl Capability {
62    const ALL: [Self; 6] = [
63        Self::Xlat464,
64        Self::DsLite,
65        Self::IpIp,
66        Self::Lw4o6,
67        Self::MapE,
68        Self::MapT,
69    ];
70
71    /// Returns the capability name used in HB46PP requests and responses.
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::Xlat464 => "464xlat",
75            Self::DsLite => "dslite",
76            Self::IpIp => "ipip",
77            Self::Lw4o6 => "lw4o6",
78            Self::MapE => "map_e",
79            Self::MapT => "map_t",
80        }
81    }
82}
83
84#[derive(Debug, Error, PartialEq, Eq)]
85/// Errors returned when parsing a [`Capability`] name.
86pub enum CapabilityError {
87    /// The name is not one of the capabilities defined by HB46PP.
88    #[error("unsupported HB46PP capability: {0}")]
89    UnsupportedName(String),
90}
91
92impl FromStr for Capability {
93    type Err = CapabilityError;
94
95    fn from_str(value: &str) -> Result<Self, Self::Err> {
96        Self::ALL
97            .into_iter()
98            .find(|capability| capability.as_str() == value)
99            .ok_or_else(|| CapabilityError::UnsupportedName(value.to_string()))
100    }
101}
102
103#[derive(Debug, Error, PartialEq, Eq)]
104/// Errors returned when constructing a provisioning [`Ttl`].
105pub enum TtlError {
106    /// The lifetime exceeds the protocol maximum of seven days.
107    #[error("TTL must be at most {MAX_TTL_SECS} seconds")]
108    TooLarge,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112/// The validated lifetime of provisioning data, in seconds.
113///
114/// HB46PP limits this value to seven days.
115pub struct Ttl(u32);
116
117impl Ttl {
118    /// Returns the provisioning lifetime in seconds.
119    pub fn as_secs(self) -> u32 {
120        self.0
121    }
122}
123
124impl TryFrom<u64> for Ttl {
125    type Error = TtlError;
126
127    fn try_from(value: u64) -> Result<Self, Self::Error> {
128        if value > MAX_TTL_SECS {
129            return Err(TtlError::TooLarge);
130        }
131
132        Ok(Self(value as u32))
133    }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137/// The result of user/password authentication reported by the server.
138pub enum AuthStatus {
139    /// Credentials were absent, but providing them may yield more parameters.
140    Required,
141    /// Credentials were provided but authentication failed.
142    Rejected,
143    /// Credentials were provided and authentication succeeded.
144    Accepted,
145}
146
147impl AuthStatus {
148    /// Returns the authentication status value used in an HB46PP response.
149    pub fn as_str(self) -> &'static str {
150        match self {
151            Self::Required => "req",
152            Self::Rejected => "bad",
153            Self::Accepted => "ok",
154        }
155    }
156}
157
158#[derive(Debug, Error, PartialEq, Eq)]
159/// Errors returned when parsing an [`AuthStatus`].
160pub enum AuthStatusError {
161    /// The value is not an authentication status defined by HB46PP.
162    #[error("unsupported HB46PP auth status: {0}")]
163    UnsupportedStatus(String),
164}
165
166impl FromStr for AuthStatus {
167    type Err = AuthStatusError;
168
169    fn from_str(value: &str) -> Result<Self, Self::Err> {
170        match value {
171            "req" => Ok(Self::Required),
172            "bad" => Ok(Self::Rejected),
173            "ok" => Ok(Self::Accepted),
174            _ => Err(AuthStatusError::UnsupportedStatus(value.to_string())),
175        }
176    }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180/// Informational names identifying the provisioned service and its providers.
181///
182/// These names are intended for display. They distinguish the network operator
183/// enabling IPv4-over-IPv6 connectivity, that operator's service, and an
184/// Internet provider's service offered to customers when all three are supplied.
185pub struct ProviderInfo {
186    enabler_name: String,
187    service_name: Option<String>,
188    isp_name: Option<String>,
189}
190
191impl ProviderInfo {
192    /// Returns the name of the operator enabling IPv4-over-IPv6 connectivity.
193    pub fn enabler_name(&self) -> &str {
194        &self.enabler_name
195    }
196
197    /// Returns that operator's name for the IPv4-over-IPv6 service, if supplied.
198    pub fn service_name(&self) -> Option<&str> {
199        self.service_name.as_deref()
200    }
201
202    /// Returns the Internet provider's service name, if supplied.
203    pub fn isp_name(&self) -> Option<&str> {
204        self.isp_name.as_deref()
205    }
206}
207
208/// An offer supported by the caller and selected using the server's preference order.
209pub struct SelectedOffer<'a> {
210    capability: Capability,
211    parameters: &'a serde_json::Value,
212}
213
214impl SelectedOffer<'_> {
215    /// Returns the IPv4-over-IPv6 method selected for this offer.
216    pub fn capability(&self) -> Capability {
217        self.capability
218    }
219
220    /// Returns the method parameters without interpreting their contents.
221    ///
222    /// Parameters are a JSON object for every capability except `ipip`, whose
223    /// parameters are an array containing one JSON object for each tunnel.
224    pub fn parameters(&self) -> &serde_json::Value {
225        self.parameters
226    }
227}
228
229#[derive(Debug, Error)]
230#[non_exhaustive]
231/// Errors returned when parsing and validating [`ProvisioningData`].
232pub enum ProvisioningDataError {
233    /// The outer JSON value is not an object.
234    #[error("response is not a JSON object")]
235    NotObject,
236    /// A required field is absent from the response.
237    #[error("missing required response field: {0}")]
238    MissingField(&'static str),
239    /// A field is present with `null` instead of its expected value.
240    #[error("response field must not be null: {0}")]
241    NullField(&'static str),
242    /// A field cannot be decoded as its expected JSON type.
243    #[error("invalid response field {field}: {source}")]
244    InvalidField {
245        /// The name of the invalid response field.
246        field: &'static str,
247        /// The JSON decoding error for the field.
248        #[source]
249        source: serde_json::Error,
250    },
251    /// An informational name is too large after JSON encoding.
252    #[error("response field exceeds 256 bytes including quotes: {0}")]
253    InformationalNameTooLong(&'static str),
254    /// The response contains an invalid provisioning lifetime.
255    #[error(transparent)]
256    Ttl(#[from] TtlError),
257    /// The response contains an invalid token.
258    #[error(transparent)]
259    Token(#[from] TokenError),
260    /// The response contains an unsupported authentication status.
261    #[error(transparent)]
262    AuthStatus(#[from] AuthStatusError),
263    /// The response names an unsupported capability.
264    #[error(transparent)]
265    Capability(#[from] CapabilityError),
266    /// A capability appears more than once in the server preference order.
267    #[error("duplicate capability in response order: {0:?}")]
268    DuplicateOrder(Capability),
269    /// The preference order names a capability without providing its parameters.
270    #[error("response order lists a method without its provisioning payload: {0:?}")]
271    MissingOffer(Capability),
272    /// A capability's parameters are not in the JSON shape required by HB46PP.
273    #[error("invalid provisioning payload shape for capability: {0:?}")]
274    InvalidOfferShape(Capability),
275}
276
277#[derive(Debug, Clone)]
278/// Validated provisioning data returned by an HB46PP server.
279///
280/// The type validates the shared response fields and retains each method's
281/// parameters as JSON for interpretation by the application implementing that
282/// method. Unknown fields in the outer object are ignored.
283pub struct ProvisioningData {
284    provider_info: ProviderInfo,
285    ttl: Option<Ttl>,
286    token: Option<Token>,
287    auth: Option<AuthStatus>,
288    order: Vec<Capability>,
289    ipv6_mostly: Option<bool>,
290    offers: BTreeMap<Capability, Value>,
291}
292
293impl ProvisioningData {
294    /// Parses and validates an HB46PP provisioning JSON object.
295    pub fn parse(input: &str) -> Result<Self, ProvisioningDataError> {
296        let value =
297            serde_json::from_str(input).map_err(|source| ProvisioningDataError::InvalidField {
298                field: "response",
299                source,
300            })?;
301        let mut fields = match value {
302            Value::Object(fields) => fields,
303            _ => return Err(ProvisioningDataError::NotObject),
304        };
305
306        let enabler_name = take_required::<String>(&mut fields, "enabler_name")?;
307        validate_informational_name("enabler_name", &enabler_name)?;
308        let service_name = take_optional::<String>(&mut fields, "service_name")?;
309        if let Some(service_name) = &service_name {
310            validate_informational_name("service_name", service_name)?;
311        }
312        let isp_name = take_optional::<String>(&mut fields, "isp_name")?;
313        if let Some(isp_name) = &isp_name {
314            validate_informational_name("isp_name", isp_name)?;
315        }
316
317        let ttl = take_optional::<u64>(&mut fields, "ttl")
318            .map(|ttl| ttl.map(Ttl::try_from).transpose())??;
319        let token = take_optional::<String>(&mut fields, "token")
320            .map(|token| token.map(|token| token.parse()).transpose())??;
321        let auth = take_optional::<String>(&mut fields, "auth")
322            .map(|auth| auth.map(|auth| auth.parse()).transpose())??;
323        let order_names = take_required::<Vec<String>>(&mut fields, "order")?;
324        let ipv6_mostly = take_optional::<bool>(&mut fields, "ipv6_mostly")?;
325
326        let mut order = Vec::with_capacity(order_names.len());
327        for name in order_names {
328            let capability = name.parse()?;
329            if order.contains(&capability) {
330                return Err(ProvisioningDataError::DuplicateOrder(capability));
331            }
332            order.push(capability);
333        }
334
335        let mut offers = BTreeMap::new();
336        for capability in Capability::ALL {
337            let Some(parameters) = take_optional::<Value>(&mut fields, capability.as_str())? else {
338                continue;
339            };
340
341            let has_valid_shape = match capability {
342                Capability::IpIp => parameters
343                    .as_array()
344                    .is_some_and(|tunnels| tunnels.iter().all(Value::is_object)),
345                _ => parameters.is_object(),
346            };
347
348            if !has_valid_shape {
349                return Err(ProvisioningDataError::InvalidOfferShape(capability));
350            }
351
352            offers.insert(capability, parameters);
353        }
354        for capability in &order {
355            if !offers.contains_key(capability) {
356                return Err(ProvisioningDataError::MissingOffer(*capability));
357            }
358        }
359
360        Ok(Self {
361            provider_info: ProviderInfo {
362                enabler_name,
363                service_name,
364                isp_name,
365            },
366            ttl,
367            token,
368            auth,
369            order,
370            ipv6_mostly,
371            offers,
372        })
373    }
374
375    /// Selects the first supported offer in the server's preference order.
376    ///
377    /// The order of `supported` does not affect selection. If none of the
378    /// server's ordered offers are supported, this returns `None`.
379    pub fn select(&self, supported: &[Capability]) -> Option<SelectedOffer<'_>> {
380        for &capability in self.order() {
381            if !supported.contains(&capability) {
382                continue;
383            }
384
385            let Some(parameters) = self.offer(capability) else {
386                continue;
387            };
388
389            return Some(SelectedOffer {
390                capability,
391                parameters,
392            });
393        }
394
395        None
396    }
397
398    /// Returns the informational service and provider names.
399    pub fn provider_info(&self) -> &ProviderInfo {
400        &self.provider_info
401    }
402
403    /// Returns how long the provisioning data remains valid, if supplied.
404    pub fn ttl(&self) -> Option<Ttl> {
405        self.ttl
406    }
407
408    /// Returns the opaque token for a later provisioning request, if supplied.
409    ///
410    /// The token is sensitive and should not be logged. It must not be
411    /// persisted when the HTTP response prohibits storage.
412    pub fn token(&self) -> Option<&Token> {
413        self.token.as_ref()
414    }
415
416    /// Returns the server's user/password authentication result, if supplied.
417    pub fn auth(&self) -> Option<AuthStatus> {
418        self.auth
419    }
420
421    /// Returns the server's capability preference order.
422    pub fn order(&self) -> &[Capability] {
423        &self.order
424    }
425
426    /// Returns whether the router should provide an IPv6-Mostly local network.
427    ///
428    /// In this mode, local devices primarily use IPv6 and obtain IPv4
429    /// connectivity through 464XLAT. When this is `Some(true)`, the `464xlat`
430    /// offer supplies the NAT64 prefix to advertise to the local network even
431    /// if `464xlat` is absent from the preference order.
432    pub fn ipv6_mostly(&self) -> Option<bool> {
433        self.ipv6_mostly
434    }
435
436    /// Returns the uninterpreted parameters offered for a capability.
437    ///
438    /// An offer may be present even when the capability is not listed in the
439    /// server preference order, as required for IPv6-Mostly provisioning.
440    pub fn offer(&self, capability: Capability) -> Option<&Value> {
441        self.offers.get(&capability)
442    }
443}
444
445fn take_required<T>(
446    fields: &mut Map<String, Value>,
447    field: &'static str,
448) -> Result<T, ProvisioningDataError>
449where
450    T: DeserializeOwned,
451{
452    let value = fields
453        .remove(field)
454        .ok_or(ProvisioningDataError::MissingField(field))?;
455    if value.is_null() {
456        return Err(ProvisioningDataError::NullField(field));
457    }
458
459    serde_json::from_value(value)
460        .map_err(|source| ProvisioningDataError::InvalidField { field, source })
461}
462
463fn take_optional<T>(
464    fields: &mut Map<String, Value>,
465    field: &'static str,
466) -> Result<Option<T>, ProvisioningDataError>
467where
468    T: DeserializeOwned,
469{
470    let Some(value) = fields.remove(field) else {
471        return Ok(None);
472    };
473    if value.is_null() {
474        return Err(ProvisioningDataError::NullField(field));
475    }
476
477    serde_json::from_value(value)
478        .map(Some)
479        .map_err(|source| ProvisioningDataError::InvalidField { field, source })
480}
481
482fn validate_informational_name(
483    field: &'static str,
484    value: &str,
485) -> Result<(), ProvisioningDataError> {
486    if value.len() + 2 > 256 {
487        return Err(ProvisioningDataError::InformationalNameTooLong(field));
488    }
489
490    Ok(())
491}
492
493#[derive(Debug, Error, PartialEq, Eq)]
494/// Errors returned when constructing a [`ProvisioningRequest`].
495pub enum ProvisioningRequestError {
496    /// No supported IPv4-over-IPv6 capability was supplied.
497    #[error("at least one capability is required")]
498    EmptyCapabilities,
499    /// The supplied capability list contains the same capability more than once.
500    #[error("capabilities must not contain duplicates")]
501    DuplicateCapability,
502}
503
504#[derive(Debug, Error, PartialEq, Eq)]
505/// Errors returned when parsing a [`VendorId`].
506pub enum VendorIdError {
507    /// The value does not follow the HB46PP vendor identifier format.
508    #[error("vendor ID must be 6 ASCII hex digits with an optional 1..24 character suffix")]
509    InvalidFormat,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
513/// A validated value for the HB46PP `vendorid` request parameter.
514///
515/// The value starts with the vendor's 24-bit IEEE organization identifier,
516/// written as six hexadecimal digits. It may have a suffix of 1 to 24 ASCII
517/// letters, digits, or underscores separated by `-`.
518pub struct VendorId(String);
519
520impl VendorId {
521    /// Returns the validated vendor identifier.
522    pub fn as_str(&self) -> &str {
523        &self.0
524    }
525}
526
527impl FromStr for VendorId {
528    type Err = VendorIdError;
529
530    fn from_str(value: &str) -> Result<Self, Self::Err> {
531        let (oui, suffix) = match value.split_once('-') {
532            Some((oui, suffix)) => (oui, Some(suffix)),
533            None => (value, None),
534        };
535
536        if oui.len() != 6
537            || !oui.chars().all(|c| c.is_ascii_hexdigit())
538            || suffix.is_some_and(|suffix| {
539                suffix.is_empty()
540                    || suffix.len() > 24
541                    || !suffix
542                        .chars()
543                        .all(|c| c.is_ascii_alphanumeric() || c == '_')
544            })
545        {
546            return Err(VendorIdError::InvalidFormat);
547        }
548
549        Ok(Self(value.to_string()))
550    }
551}
552
553#[derive(Debug, Error, PartialEq, Eq)]
554/// Errors returned when parsing a [`Product`].
555pub enum ProductError {
556    /// The value does not follow the HB46PP product identifier format.
557    #[error("product must be 1..32 ASCII letters, digits, '_' or '-'")]
558    InvalidFormat,
559}
560
561#[derive(Debug, Clone, PartialEq, Eq)]
562/// A validated value for the HB46PP `product` request parameter.
563pub struct Product(String);
564
565impl Product {
566    /// Returns the validated product identifier.
567    pub fn as_str(&self) -> &str {
568        &self.0
569    }
570}
571
572impl FromStr for Product {
573    type Err = ProductError;
574
575    fn from_str(value: &str) -> Result<Self, Self::Err> {
576        if value.is_empty()
577            || value.len() > 32
578            || !value
579                .chars()
580                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
581        {
582            return Err(ProductError::InvalidFormat);
583        }
584
585        Ok(Self(value.to_string()))
586    }
587}
588
589#[derive(Debug, Error, PartialEq, Eq)]
590/// Errors returned when parsing a [`FirmwareVersion`].
591pub enum FirmwareVersionError {
592    /// The value does not follow the HB46PP firmware version format.
593    #[error("firmware version must be 1..32 ASCII digits or '_'")]
594    InvalidFormat,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq)]
598/// A validated value for the HB46PP `version` request parameter.
599///
600/// Versions contain only ASCII digits and underscores. A dotted software
601/// version such as `1.2.0` must therefore be supplied as `1_2_0`.
602pub struct FirmwareVersion(String);
603
604impl FirmwareVersion {
605    /// Returns the validated firmware version.
606    pub fn as_str(&self) -> &str {
607        &self.0
608    }
609}
610
611impl FromStr for FirmwareVersion {
612    type Err = FirmwareVersionError;
613
614    fn from_str(value: &str) -> Result<Self, Self::Err> {
615        if value.is_empty()
616            || value.len() > 32
617            || !value.chars().all(|c| c.is_ascii_digit() || c == '_')
618        {
619            return Err(FirmwareVersionError::InvalidFormat);
620        }
621
622        Ok(Self(value.to_string()))
623    }
624}
625
626#[derive(Debug, Error, PartialEq, Eq)]
627/// Errors returned when constructing [`Credentials`].
628pub enum CredentialsError {
629    /// The user name does not follow the HB46PP credential format.
630    #[error("user must be at most 32 ASCII letters, digits, '_' or '-'")]
631    InvalidUser,
632    /// The password does not follow the HB46PP credential format.
633    #[error("password must be at most 32 ASCII letters, digits, '_' or '-'")]
634    InvalidPassword,
635    /// The server name cannot be parsed as a URL host.
636    #[error("expected server name is not a valid URL host")]
637    InvalidExpectedServerName,
638}
639
640#[derive(Clone)]
641/// Optional user name and password sent with a provisioning request.
642///
643/// The custom [`Debug`](fmt::Debug) implementation redacts the password.
644pub struct Credentials {
645    user: String,
646    password: String,
647    expected_server_name: Option<Host<String>>,
648}
649
650impl Credentials {
651    /// Creates credentials restricted to one validated HTTPS server.
652    ///
653    /// The credentials can only be added to a request when certificate
654    /// validation is enabled and the request URL host matches
655    /// `expected_server_name`.
656    pub fn for_server(
657        user: String,
658        password: String,
659        expected_server_name: String,
660    ) -> Result<Self, CredentialsError> {
661        validate_credentials(&user, &password)?;
662
663        let expected_server_name = Host::parse(&expected_server_name)
664            .map_err(|_| CredentialsError::InvalidExpectedServerName)?;
665
666        Ok(Self {
667            user,
668            password,
669            expected_server_name: Some(expected_server_name),
670        })
671    }
672
673    /// Creates credentials that may be sent to any provisioning endpoint.
674    ///
675    /// HB46PP permits this when the user did not provide an expected server
676    /// name. This includes endpoints using HTTP, unvalidated HTTPS, and hosts
677    /// reached through redirects, so callers must choose this explicitly.
678    pub fn unrestricted(user: String, password: String) -> Result<Self, CredentialsError> {
679        validate_credentials(&user, &password)?;
680
681        Ok(Self {
682            user,
683            password,
684            expected_server_name: None,
685        })
686    }
687
688    /// Returns the user name.
689    pub fn user(&self) -> &str {
690        &self.user
691    }
692
693    /// Returns the password.
694    ///
695    /// The returned value is sensitive and should not be logged or persisted
696    /// without appropriate protection.
697    pub fn password(&self) -> &str {
698        &self.password
699    }
700}
701
702impl fmt::Debug for Credentials {
703    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
704        f.debug_struct("Credentials")
705            .field("user", &self.user)
706            .field("password", &"[redacted]")
707            .field("expected_server_name", &self.expected_server_name)
708            .finish()
709    }
710}
711
712fn validate_credentials(user: &str, password: &str) -> Result<(), CredentialsError> {
713    if !valid_credential_component(user) {
714        return Err(CredentialsError::InvalidUser);
715    }
716    if !valid_credential_component(password) {
717        return Err(CredentialsError::InvalidPassword);
718    }
719
720    Ok(())
721}
722
723fn valid_credential_component(value: &str) -> bool {
724    value.len() <= 32
725        && value
726            .chars()
727            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
728}
729
730#[derive(Debug, Error, PartialEq, Eq)]
731/// Errors returned when parsing a [`Token`].
732pub enum TokenError {
733    /// The value is not exactly 64 lowercase hexadecimal characters.
734    #[error("token must be lowercase ASCII hexadecimal only, 64 characters long")]
735    InvalidFormat,
736}
737
738#[derive(Clone, PartialEq, Eq)]
739/// An opaque token returned by a provisioning server for a later request.
740///
741/// The custom [`Debug`](fmt::Debug) implementation redacts the token.
742pub struct Token(String);
743
744impl Token {
745    /// Returns the token value.
746    ///
747    /// The returned value is sensitive and should not be logged. It must not
748    /// be persisted when the provisioning response prohibits storage.
749    pub fn as_str(&self) -> &str {
750        &self.0
751    }
752}
753
754impl fmt::Debug for Token {
755    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
756        f.write_str("Token([redacted])")
757    }
758}
759
760impl FromStr for Token {
761    type Err = TokenError;
762
763    fn from_str(value: &str) -> Result<Self, Self::Err> {
764        if value.len() != 64 || !value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
765            return Err(TokenError::InvalidFormat);
766        }
767
768        Ok(Self(value.to_string()))
769    }
770}
771
772#[derive(Debug, Error, PartialEq, Eq)]
773/// Errors returned when adding restricted credentials to a provisioning URL.
774pub enum ProvisioningUrlError {
775    /// Restricted credentials would be sent over a connection without HTTPS.
776    #[error("credentials with an expected server name require HTTPS")]
777    CredentialsRequireHttps,
778    /// Restricted credentials would be sent without certificate validation.
779    #[error("credentials with an expected server name require certificate validation")]
780    CredentialsRequireCertificateValidation,
781    /// The endpoint host does not match the host associated with the credentials.
782    #[error("provisioning URL host does not match the expected server name")]
783    UnexpectedProvisioningHost,
784}
785
786#[derive(Clone)]
787/// Validated parameters used to request HB46PP provisioning data.
788///
789/// The request identifies the device, declares its supported capabilities,
790/// and may carry a token or credentials. Its custom [`Debug`](fmt::Debug)
791/// implementation redacts those sensitive values.
792pub struct ProvisioningRequest {
793    vendor_id: VendorId,
794    product: Product,
795    version: FirmwareVersion,
796    capabilities: Vec<Capability>,
797    token: Option<Token>,
798    credentials: Option<Credentials>,
799}
800
801impl ProvisioningRequest {
802    /// Creates a provisioning request.
803    ///
804    /// At least one capability is required, and each capability may appear
805    /// only once. The order does not control offer selection; the server's
806    /// response order does.
807    pub fn new(
808        vendor_id: VendorId,
809        product: Product,
810        version: FirmwareVersion,
811        capabilities: Vec<Capability>,
812        token: Option<Token>,
813        credentials: Option<Credentials>,
814    ) -> Result<Self, ProvisioningRequestError> {
815        if capabilities.is_empty() {
816            return Err(ProvisioningRequestError::EmptyCapabilities);
817        }
818        if capabilities
819            .iter()
820            .enumerate()
821            .any(|(index, capability)| capabilities[..index].contains(capability))
822        {
823            return Err(ProvisioningRequestError::DuplicateCapability);
824        }
825
826        Ok(Self {
827            vendor_id,
828            product,
829            version,
830            capabilities,
831            token,
832            credentials,
833        })
834    }
835
836    /// Returns the device vendor identifier.
837    pub fn vendor_id(&self) -> &VendorId {
838        &self.vendor_id
839    }
840
841    /// Returns the device product identifier.
842    pub fn product(&self) -> &Product {
843        &self.product
844    }
845
846    /// Returns the device firmware version.
847    pub fn version(&self) -> &FirmwareVersion {
848        &self.version
849    }
850
851    /// Returns the capabilities declared by the device.
852    pub fn capabilities(&self) -> &[Capability] {
853        &self.capabilities
854    }
855
856    /// Returns the token to send with this request, if present.
857    ///
858    /// The returned value is sensitive and should not be logged.
859    pub fn token(&self) -> Option<&str> {
860        self.token.as_ref().map(Token::as_str)
861    }
862
863    /// Sets the token sent with subsequent provisioning requests.
864    ///
865    /// Passing `None` stops sending the current token.
866    pub fn set_token(&mut self, token: Option<Token>) {
867        self.token = token;
868    }
869
870    /// Returns the credentials to send with this request, if present.
871    pub fn credentials(&self) -> Option<&Credentials> {
872        self.credentials.as_ref()
873    }
874}
875
876impl fmt::Debug for ProvisioningRequest {
877    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
878        f.debug_struct("ProvisioningRequest")
879            .field("vendor_id", &self.vendor_id)
880            .field("product", &self.product)
881            .field("version", &self.version)
882            .field("capabilities", &self.capabilities)
883            .field("token", &self.token)
884            .field("credentials", &self.credentials)
885            .finish()
886    }
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890/// The certificate validation policy declared by an HB46PP bootstrap record.
891pub enum TlsPolicy {
892    /// Bootstrap field `t=a`: certificate validation is not required.
893    NoCertificateValidation, // t=a
894    /// Bootstrap field `t=b`: validate the HTTPS server certificate.
895    ValidateCertificate, // t=b
896}
897
898#[derive(Debug)]
899/// A validated HB46PP bootstrap record.
900///
901/// It contains the provisioning endpoint and TLS policy obtained from a DNS
902/// TXT record.
903pub struct Bootstrap {
904    url: Url,
905    tls_policy: TlsPolicy,
906}
907
908impl Bootstrap {
909    /// Parses and validates an HB46PP bootstrap TXT record.
910    pub fn parse(txt: &str) -> Result<Self, BootstrapError> {
911        let mut iter = txt.split(' ');
912
913        let version_field = iter.next().ok_or(BootstrapError::MissingField("v"))?;
914        let version_value = parse_field(version_field, "v")?;
915        if version_value != V6MIG_SPEC {
916            return Err(BootstrapError::UnsupportedVersion(
917                version_value.to_string(),
918            ));
919        }
920
921        let url_field = iter.next().ok_or(BootstrapError::MissingField("url"))?;
922        let url_value = parse_field(url_field, "url")?;
923
924        let tls_field = iter.next().ok_or(BootstrapError::MissingField("t"))?;
925        let tls_value = parse_field(tls_field, "t")?;
926
927        if iter.next().is_some() {
928            return Err(BootstrapError::InvalidRecord(txt.to_string()));
929        };
930
931        let tls_policy = match tls_value {
932            "a" => TlsPolicy::NoCertificateValidation,
933            "b" => TlsPolicy::ValidateCertificate,
934            _ => {
935                return Err(BootstrapError::InvalidTlsPolicy(format!(
936                    "invalid tls policy value: {tls_value}, expected '<a|b>'"
937                )));
938            }
939        };
940
941        let url = Url::parse(url_value)
942            .map_err(|e| BootstrapError::InvalidUrl(url_value.to_string(), e.to_string()))?;
943
944        if url.scheme() != "http" && url.scheme() != "https" {
945            return Err(BootstrapError::InvalidUrl(
946                url_value.to_string(),
947                format!(
948                    "unsuported url scheme: {}, supported: <http|https>",
949                    url.scheme(),
950                ),
951            ));
952        };
953
954        if url.scheme() == "http" && tls_policy == TlsPolicy::ValidateCertificate {
955            return Err(BootstrapError::InvalidTlsForHttp);
956        }
957
958        match url.host() {
959            Some(Host::Ipv4(_)) => return Err(BootstrapError::Ipv4EndpointNotAllowed),
960            Some(_) => {}
961            None => return Err(BootstrapError::MissingUrlHost),
962        }
963
964        Ok(Bootstrap { url, tls_policy })
965    }
966
967    /// Builds the provisioning URL for the initial bootstrap endpoint.
968    ///
969    /// The returned URL preserves endpoint query parameters and adds the
970    /// request's HB46PP query parameters.
971    pub fn provisioning_url(
972        &self,
973        request: &ProvisioningRequest,
974    ) -> Result<Url, ProvisioningUrlError> {
975        self.provisioning_url_for(self.url.clone(), request)
976    }
977
978    pub(crate) fn provisioning_url_for(
979        &self,
980        endpoint: Url,
981        request: &ProvisioningRequest,
982    ) -> Result<Url, ProvisioningUrlError> {
983        if let Some(credentials) = request.credentials() {
984            self.validate_credentials(&endpoint, credentials)?;
985        }
986        let mut request_url = endpoint;
987        let capabilities = request
988            .capabilities()
989            .iter()
990            .map(|c| c.as_str())
991            .collect::<Vec<_>>()
992            .join(",");
993        {
994            let mut query = request_url.query_pairs_mut();
995            query.append_pair("vendorid", request.vendor_id().as_str());
996            query.append_pair("product", request.product().as_str());
997            query.append_pair("version", request.version().as_str());
998            query.append_pair("capability", &capabilities);
999            if let Some(token) = request.token() {
1000                query.append_pair("token", token);
1001            }
1002            if let Some(credentials) = request.credentials() {
1003                query.append_pair("user", credentials.user());
1004                query.append_pair("pass", credentials.password());
1005            }
1006        }
1007        Ok(request_url)
1008    }
1009
1010    fn validate_credentials(
1011        &self,
1012        endpoint: &Url,
1013        credentials: &Credentials,
1014    ) -> Result<(), ProvisioningUrlError> {
1015        let Some(expected_server_name) = &credentials.expected_server_name else {
1016            return Ok(());
1017        };
1018
1019        if endpoint.scheme() != "https" {
1020            return Err(ProvisioningUrlError::CredentialsRequireHttps);
1021        }
1022        if self.tls_policy != TlsPolicy::ValidateCertificate {
1023            return Err(ProvisioningUrlError::CredentialsRequireCertificateValidation);
1024        }
1025
1026        let endpoint_host = endpoint.host().map(|host| host.to_owned());
1027        if endpoint_host.as_ref() != Some(expected_server_name) {
1028            return Err(ProvisioningUrlError::UnexpectedProvisioningHost);
1029        }
1030
1031        Ok(())
1032    }
1033
1034    /// Returns the TLS policy declared by the bootstrap record.
1035    pub fn tls_policy(&self) -> TlsPolicy {
1036        self.tls_policy
1037    }
1038
1039    /// Returns the provisioning endpoint from the bootstrap record.
1040    pub fn endpoint(&self) -> &Url {
1041        &self.url
1042    }
1043}
1044
1045fn parse_field<'a>(field: &'a str, expected_key: &'static str) -> Result<&'a str, BootstrapError> {
1046    let (key, value) = field.split_once('=').ok_or(BootstrapError::MalformedField(
1047        format!("{expected_key}=<value>"),
1048        field.to_string(),
1049    ))?;
1050
1051    if key != expected_key {
1052        return Err(BootstrapError::MalformedField(
1053            expected_key.to_string(),
1054            key.to_string(),
1055        ));
1056    };
1057    Ok(value)
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063
1064    const V6CONNECT_BOOTSTRAP: &str =
1065        "v=v6mig-1 url=https://prod.v6mig.v6connect.net/cpe/v1/config t=b";
1066    const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1067
1068    fn vendor_id() -> VendorId {
1069        "000000".parse().unwrap()
1070    }
1071
1072    fn product() -> Product {
1073        "dslite-b4".parse().unwrap()
1074    }
1075
1076    fn version() -> FirmwareVersion {
1077        "0_1_0".parse().unwrap()
1078    }
1079
1080    fn credentials_for_server(expected_server_name: &str) -> Credentials {
1081        Credentials::for_server(
1082            "user".to_string(),
1083            "pass".to_string(),
1084            expected_server_name.to_string(),
1085        )
1086        .unwrap()
1087    }
1088
1089    fn valid_request() -> ProvisioningRequest {
1090        ProvisioningRequest::new(
1091            vendor_id(),
1092            product(),
1093            version(),
1094            vec![Capability::DsLite],
1095            None,
1096            None,
1097        )
1098        .unwrap()
1099    }
1100
1101    #[test]
1102    fn serializes_capabilities_to_hb46pp_wire_names() {
1103        let names: Vec<_> = Capability::ALL
1104            .into_iter()
1105            .map(Capability::as_str)
1106            .collect();
1107
1108        assert_eq!(
1109            names,
1110            ["464xlat", "dslite", "ipip", "lw4o6", "map_e", "map_t"]
1111        );
1112    }
1113
1114    #[test]
1115    fn parses_hb46pp_capability_wire_names() {
1116        for capability in Capability::ALL {
1117            assert_eq!(capability.as_str().parse(), Ok(capability));
1118        }
1119    }
1120
1121    #[test]
1122    fn rejects_unknown_capability_wire_names() {
1123        for name in ["DS-Lite", "wireguard"] {
1124            let error = name.parse::<Capability>().unwrap_err();
1125
1126            assert_eq!(error, CapabilityError::UnsupportedName(name.to_string()));
1127        }
1128    }
1129
1130    #[test]
1131    fn accepts_ttl_at_the_specification_limit() {
1132        let ttl = Ttl::try_from(604_800).unwrap();
1133
1134        assert_eq!(ttl.as_secs(), 604_800);
1135    }
1136
1137    #[test]
1138    fn rejects_ttl_above_the_specification_limit() {
1139        let error = Ttl::try_from(604_801).unwrap_err();
1140
1141        assert_eq!(error, TtlError::TooLarge);
1142    }
1143
1144    #[test]
1145    fn parses_hb46pp_auth_statuses() {
1146        for (wire_name, status) in [
1147            ("req", AuthStatus::Required),
1148            ("bad", AuthStatus::Rejected),
1149            ("ok", AuthStatus::Accepted),
1150        ] {
1151            assert_eq!(wire_name.parse(), Ok(status));
1152            assert_eq!(status.as_str(), wire_name);
1153        }
1154    }
1155
1156    #[test]
1157    fn rejects_unknown_hb46pp_auth_status() {
1158        let error = "required".parse::<AuthStatus>().unwrap_err();
1159
1160        assert_eq!(
1161            error,
1162            AuthStatusError::UnsupportedStatus("required".to_string())
1163        );
1164    }
1165
1166    #[test]
1167    fn parses_v6connect_response_shape() {
1168        let response = ProvisioningData::parse(&format!(
1169            r#"{{
1170                "ttl": 86400,
1171                "token": "{TOKEN}",
1172                "service_name": "v6 コネクト",
1173                "enabler_name": "v6 コネクト",
1174                "dslite": {{"aftr": "dslite.v6connect.net"}},
1175                "order": ["dslite"],
1176                "future_extension": {{"ignored": true}}
1177            }}"#
1178        ))
1179        .unwrap();
1180
1181        assert_eq!(response.provider_info().enabler_name(), "v6 コネクト");
1182        assert_eq!(response.provider_info().service_name(), Some("v6 コネクト"));
1183        assert_eq!(response.provider_info().isp_name(), None);
1184        assert_eq!(response.ttl().unwrap().as_secs(), 86_400);
1185        assert_eq!(response.token().unwrap().as_str(), TOKEN);
1186        assert_eq!(response.auth(), None);
1187        assert_eq!(response.order(), [Capability::DsLite]);
1188        assert_eq!(
1189            response.offer(Capability::DsLite),
1190            Some(&serde_json::json!({"aftr": "dslite.v6connect.net"}))
1191        );
1192    }
1193
1194    #[test]
1195    fn retains_ipv6_mostly_xlat_offer_outside_activation_order() {
1196        let response = ProvisioningData::parse(
1197            r#"{
1198                "enabler_name": "example",
1199                "order": ["dslite"],
1200                "ipv6_mostly": true,
1201                "dslite": {"aftr": "dslite.example"},
1202                "464xlat": {"nat64prefix": "64:ff9b::/96"}
1203            }"#,
1204        )
1205        .unwrap();
1206
1207        assert_eq!(response.order(), [Capability::DsLite]);
1208        assert_eq!(response.ipv6_mostly(), Some(true));
1209        assert_eq!(
1210            response.offer(Capability::Xlat464),
1211            Some(&serde_json::json!({"nat64prefix": "64:ff9b::/96"}))
1212        );
1213    }
1214
1215    #[test]
1216    fn selects_the_first_server_ordered_supported_offer() {
1217        let response = ProvisioningData::parse(
1218            r#"{
1219                "enabler_name": "example",
1220                "order": ["map_e", "dslite"],
1221                "map_e": {"br": "2001:db8::1", "rules": []},
1222                "dslite": {"aftr": "dslite.example"}
1223            }"#,
1224        )
1225        .unwrap();
1226
1227        let selected = response
1228            .select(&[Capability::DsLite, Capability::MapE])
1229            .unwrap();
1230
1231        assert_eq!(selected.capability(), Capability::MapE);
1232        assert_eq!(
1233            selected.parameters(),
1234            &serde_json::json!({"br": "2001:db8::1", "rules": []})
1235        );
1236    }
1237
1238    #[test]
1239    fn selects_a_later_offer_when_higher_priority_offers_are_unsupported() {
1240        let response = ProvisioningData::parse(
1241            r#"{
1242                "enabler_name": "example",
1243                "order": ["map_e", "dslite"],
1244                "map_e": {"br": "2001:db8::1", "rules": []},
1245                "dslite": {"aftr": "dslite.example"}
1246            }"#,
1247        )
1248        .unwrap();
1249
1250        let selected = response.select(&[Capability::DsLite]).unwrap();
1251
1252        assert_eq!(selected.capability(), Capability::DsLite);
1253        assert_eq!(
1254            selected.parameters(),
1255            &serde_json::json!({"aftr": "dslite.example"})
1256        );
1257    }
1258
1259    #[test]
1260    fn selects_nothing_when_no_ordered_offer_is_supported() {
1261        let response = ProvisioningData::parse(
1262            r#"{
1263                "enabler_name": "example",
1264                "order": ["map_e"],
1265                "map_e": {"br": "2001:db8::1", "rules": []}
1266            }"#,
1267        )
1268        .unwrap();
1269
1270        assert!(response.select(&[Capability::DsLite]).is_none());
1271    }
1272
1273    #[test]
1274    fn rejects_null_for_an_optional_response_field() {
1275        let error = ProvisioningData::parse(
1276            r#"{
1277                "enabler_name": "example",
1278                "token": null,
1279                "order": []
1280            }"#,
1281        )
1282        .unwrap_err();
1283
1284        assert!(matches!(error, ProvisioningDataError::NullField("token")));
1285    }
1286
1287    #[test]
1288    fn rejects_non_object_method_payload() {
1289        let error = ProvisioningData::parse(
1290            r#"{
1291                "enabler_name": "example",
1292                "order": ["dslite"],
1293                "dslite": "invalid"
1294            }"#,
1295        )
1296        .unwrap_err();
1297
1298        assert!(matches!(
1299            error,
1300            ProvisioningDataError::InvalidOfferShape(Capability::DsLite)
1301        ));
1302    }
1303
1304    #[test]
1305    fn accepts_ipip_array_payload() {
1306        let response = ProvisioningData::parse(
1307            r#"{
1308                "enabler_name": "example",
1309                "order": ["ipip"],
1310                "ipip": [{
1311                    "ipv6_local": "2001:db8:1::1",
1312                    "ipv6_remote": "2001:db8:2::1",
1313                    "ipv4": "192.0.2.0/29"
1314                }]
1315            }"#,
1316        )
1317        .unwrap();
1318
1319        assert_eq!(
1320            response.offer(Capability::IpIp),
1321            Some(&serde_json::json!([{
1322                "ipv6_local": "2001:db8:1::1",
1323                "ipv6_remote": "2001:db8:2::1",
1324                "ipv4": "192.0.2.0/29"
1325            }]))
1326        );
1327    }
1328
1329    #[test]
1330    fn rejects_ipip_object_payload() {
1331        let error = ProvisioningData::parse(
1332            r#"{
1333                "enabler_name": "example",
1334                "order": ["ipip"],
1335                "ipip": {"ipv6_remote": "2001:db8:2::1"}
1336            }"#,
1337        )
1338        .unwrap_err();
1339
1340        assert!(matches!(
1341            error,
1342            ProvisioningDataError::InvalidOfferShape(Capability::IpIp)
1343        ));
1344    }
1345
1346    #[test]
1347    fn rejects_non_object_entry_in_ipip_array() {
1348        let error = ProvisioningData::parse(
1349            r#"{
1350                "enabler_name": "example",
1351                "order": ["ipip"],
1352                "ipip": ["invalid"]
1353            }"#,
1354        )
1355        .unwrap_err();
1356
1357        assert!(matches!(
1358            error,
1359            ProvisioningDataError::InvalidOfferShape(Capability::IpIp)
1360        ));
1361    }
1362
1363    #[test]
1364    fn rejects_an_ordered_capability_without_a_payload() {
1365        let error = ProvisioningData::parse(
1366            r#"{
1367                "enabler_name": "example",
1368                "order": ["dslite"]
1369            }"#,
1370        )
1371        .unwrap_err();
1372
1373        assert!(matches!(
1374            error,
1375            ProvisioningDataError::MissingOffer(Capability::DsLite)
1376        ));
1377    }
1378
1379    #[test]
1380    fn validates_ttl_and_token_in_a_response() {
1381        let ttl_error = ProvisioningData::parse(
1382            r#"{
1383                "enabler_name": "example",
1384                "ttl": 604801,
1385                "order": []
1386            }"#,
1387        )
1388        .unwrap_err();
1389        let token_error = ProvisioningData::parse(
1390            r#"{
1391                "enabler_name": "example",
1392                "token": "not-a-token",
1393                "order": []
1394            }"#,
1395        )
1396        .unwrap_err();
1397
1398        assert!(matches!(ttl_error, ProvisioningDataError::Ttl(_)));
1399        assert!(matches!(token_error, ProvisioningDataError::Token(_)));
1400    }
1401
1402    #[test]
1403    fn builds_valid_provisioning_request() {
1404        let request = valid_request();
1405
1406        assert_eq!(request.vendor_id().as_str(), "000000");
1407        assert_eq!(request.product().as_str(), "dslite-b4");
1408        assert_eq!(request.version().as_str(), "0_1_0");
1409        assert_eq!(request.capabilities(), [Capability::DsLite]);
1410        assert_eq!(request.token(), None);
1411    }
1412
1413    #[test]
1414    fn accepts_multiple_capabilities_and_a_token() {
1415        let request = ProvisioningRequest::new(
1416            "acde48-v6pc_swg_hgw".parse().unwrap(),
1417            "V6MIG-ROUTER".parse().unwrap(),
1418            "1_32".parse().unwrap(),
1419            vec![Capability::MapE, Capability::DsLite, Capability::Lw4o6],
1420            Some(TOKEN.parse().unwrap()),
1421            None,
1422        )
1423        .unwrap();
1424
1425        assert_eq!(
1426            request.capabilities(),
1427            [Capability::MapE, Capability::DsLite, Capability::Lw4o6]
1428        );
1429        assert_eq!(request.token(), Some(TOKEN));
1430    }
1431
1432    #[test]
1433    fn parses_valid_token() {
1434        let token: Token = TOKEN.parse().unwrap();
1435
1436        assert_eq!(token.as_str(), TOKEN);
1437    }
1438
1439    #[test]
1440    fn rejects_invalid_token_formats() {
1441        let invalid_tokens = [
1442            "0".repeat(63),
1443            "0".repeat(65),
1444            format!("A{}", "0".repeat(63)),
1445            format!("g{}", "0".repeat(63)),
1446        ];
1447
1448        for token in invalid_tokens {
1449            let error = token.parse::<Token>().unwrap_err();
1450
1451            assert_eq!(error, TokenError::InvalidFormat);
1452        }
1453    }
1454
1455    #[test]
1456    fn redacts_tokens_in_debug_output() {
1457        let token: Token = TOKEN.parse().unwrap();
1458
1459        let debug = format!("{token:?}");
1460
1461        assert_eq!(debug, "Token([redacted])");
1462    }
1463
1464    #[test]
1465    fn rejects_invalid_credentials() {
1466        let invalid_user =
1467            Credentials::unrestricted("user!".to_string(), "pass".to_string()).unwrap_err();
1468        let invalid_password =
1469            Credentials::unrestricted("user".to_string(), "pass!".to_string()).unwrap_err();
1470        let invalid_server_name = Credentials::for_server(
1471            "user".to_string(),
1472            "pass".to_string(),
1473            "[2001:db8::1".to_string(),
1474        )
1475        .unwrap_err();
1476
1477        assert_eq!(invalid_user, CredentialsError::InvalidUser);
1478        assert_eq!(invalid_password, CredentialsError::InvalidPassword);
1479        assert_eq!(
1480            invalid_server_name,
1481            CredentialsError::InvalidExpectedServerName
1482        );
1483    }
1484
1485    #[test]
1486    fn rejects_invalid_vendor_id() {
1487        let error = "not-an-oui".parse::<VendorId>().unwrap_err();
1488
1489        assert_eq!(error, VendorIdError::InvalidFormat);
1490    }
1491
1492    #[test]
1493    fn rejects_invalid_product() {
1494        let error = "dslite b4".parse::<Product>().unwrap_err();
1495
1496        assert_eq!(error, ProductError::InvalidFormat);
1497    }
1498
1499    #[test]
1500    fn rejects_invalid_version() {
1501        let error = "0.1.0".parse::<FirmwareVersion>().unwrap_err();
1502
1503        assert_eq!(error, FirmwareVersionError::InvalidFormat);
1504    }
1505
1506    #[test]
1507    fn rejects_empty_capabilities() {
1508        let error =
1509            ProvisioningRequest::new(vendor_id(), product(), version(), Vec::new(), None, None)
1510                .unwrap_err();
1511
1512        assert_eq!(error, ProvisioningRequestError::EmptyCapabilities);
1513    }
1514
1515    #[test]
1516    fn rejects_duplicate_capabilities() {
1517        let error = ProvisioningRequest::new(
1518            vendor_id(),
1519            product(),
1520            version(),
1521            vec![Capability::DsLite, Capability::DsLite],
1522            None,
1523            None,
1524        )
1525        .unwrap_err();
1526
1527        assert_eq!(error, ProvisioningRequestError::DuplicateCapability);
1528    }
1529
1530    #[test]
1531    fn parses_v6connect_bootstrap_record() {
1532        let bootstrap = Bootstrap::parse(V6CONNECT_BOOTSTRAP).unwrap();
1533
1534        assert_eq!(
1535            bootstrap.endpoint().as_str(),
1536            "https://prod.v6mig.v6connect.net/cpe/v1/config"
1537        );
1538        assert_eq!(bootstrap.tls_policy(), TlsPolicy::ValidateCertificate);
1539    }
1540
1541    #[test]
1542    fn accepts_http_without_tls_validation() {
1543        let bootstrap = Bootstrap::parse("v=v6mig-1 url=http://vne.example/rule.cgi t=a").unwrap();
1544
1545        assert_eq!(bootstrap.endpoint().scheme(), "http");
1546        assert_eq!(bootstrap.tls_policy(), TlsPolicy::NoCertificateValidation);
1547    }
1548
1549    #[test]
1550    fn builds_provisioning_url() {
1551        let bootstrap = Bootstrap::parse(V6CONNECT_BOOTSTRAP).unwrap();
1552        let request = valid_request();
1553
1554        let pairs: Vec<_> = bootstrap
1555            .provisioning_url(&request)
1556            .unwrap()
1557            .query_pairs()
1558            .into_owned()
1559            .collect();
1560
1561        assert_eq!(
1562            pairs,
1563            [
1564                ("vendorid".to_string(), "000000".to_string()),
1565                ("product".to_string(), "dslite-b4".to_string()),
1566                ("version".to_string(), "0_1_0".to_string()),
1567                ("capability".to_string(), "dslite".to_string()),
1568            ]
1569        );
1570    }
1571
1572    #[test]
1573    fn preserves_existing_query_pairs_and_appends_token() {
1574        let bootstrap =
1575            Bootstrap::parse("v=v6mig-1 url=https://vne.example/rule.cgi?provider=example t=b")
1576                .unwrap();
1577        let request = ProvisioningRequest::new(
1578            vendor_id(),
1579            product(),
1580            version(),
1581            vec![Capability::MapE, Capability::DsLite],
1582            Some(TOKEN.parse().unwrap()),
1583            None,
1584        )
1585        .unwrap();
1586
1587        let pairs: Vec<_> = bootstrap
1588            .provisioning_url(&request)
1589            .unwrap()
1590            .query_pairs()
1591            .into_owned()
1592            .collect();
1593
1594        assert_eq!(
1595            pairs,
1596            [
1597                ("provider".to_string(), "example".to_string()),
1598                ("vendorid".to_string(), "000000".to_string()),
1599                ("product".to_string(), "dslite-b4".to_string()),
1600                ("version".to_string(), "0_1_0".to_string()),
1601                ("capability".to_string(), "map_e,dslite".to_string()),
1602                ("token".to_string(), TOKEN.to_string()),
1603            ]
1604        );
1605    }
1606
1607    #[test]
1608    fn sends_credentials_without_expected_server_name() {
1609        let bootstrap = Bootstrap::parse(V6CONNECT_BOOTSTRAP).unwrap();
1610        let request = ProvisioningRequest::new(
1611            vendor_id(),
1612            product(),
1613            version(),
1614            vec![Capability::DsLite],
1615            None,
1616            Some(Credentials::unrestricted("user".to_string(), "pass".to_string()).unwrap()),
1617        )
1618        .unwrap();
1619
1620        let pairs: Vec<_> = bootstrap
1621            .provisioning_url(&request)
1622            .unwrap()
1623            .query_pairs()
1624            .into_owned()
1625            .collect();
1626
1627        assert!(pairs.contains(&("user".to_string(), "user".to_string())));
1628        assert!(pairs.contains(&("pass".to_string(), "pass".to_string())));
1629    }
1630
1631    #[test]
1632    fn sends_credentials_when_expected_server_name_matches_validated_https() {
1633        let bootstrap = Bootstrap::parse(V6CONNECT_BOOTSTRAP).unwrap();
1634        let request = ProvisioningRequest::new(
1635            vendor_id(),
1636            product(),
1637            version(),
1638            vec![Capability::DsLite],
1639            None,
1640            Some(credentials_for_server("prod.v6mig.v6connect.net")),
1641        )
1642        .unwrap();
1643
1644        assert!(bootstrap.provisioning_url(&request).is_ok());
1645    }
1646
1647    #[test]
1648    fn rejects_credentials_for_unvalidated_or_unexpected_bootstrap() {
1649        let request_with_expected_server = ProvisioningRequest::new(
1650            vendor_id(),
1651            product(),
1652            version(),
1653            vec![Capability::DsLite],
1654            None,
1655            Some(credentials_for_server("provision.example")),
1656        )
1657        .unwrap();
1658        let http = Bootstrap::parse("v=v6mig-1 url=http://provision.example/rule.cgi t=a").unwrap();
1659        let unvalidated_https =
1660            Bootstrap::parse("v=v6mig-1 url=https://provision.example/rule.cgi t=a").unwrap();
1661        let unexpected_host =
1662            Bootstrap::parse("v=v6mig-1 url=https://other.example/rule.cgi t=b").unwrap();
1663
1664        assert_eq!(
1665            http.provisioning_url(&request_with_expected_server),
1666            Err(ProvisioningUrlError::CredentialsRequireHttps)
1667        );
1668        assert_eq!(
1669            unvalidated_https.provisioning_url(&request_with_expected_server),
1670            Err(ProvisioningUrlError::CredentialsRequireCertificateValidation)
1671        );
1672        assert_eq!(
1673            unexpected_host.provisioning_url(&request_with_expected_server),
1674            Err(ProvisioningUrlError::UnexpectedProvisioningHost)
1675        );
1676    }
1677
1678    #[test]
1679    fn rejects_missing_url_field() {
1680        let error = Bootstrap::parse("v=v6mig-1").unwrap_err();
1681
1682        assert!(matches!(error, BootstrapError::MissingField(_)));
1683    }
1684
1685    #[test]
1686    fn rejects_fields_out_of_order() {
1687        let error = Bootstrap::parse("url=https://vne.example/rule.cgi v=v6mig-1 t=b").unwrap_err();
1688
1689        assert!(matches!(error, BootstrapError::MalformedField(_, _)));
1690    }
1691
1692    #[test]
1693    fn rejects_unsupported_version() {
1694        let error = Bootstrap::parse("v=v6mig-2 url=https://vne.example/rule.cgi t=b").unwrap_err();
1695
1696        assert!(matches!(error, BootstrapError::UnsupportedVersion(_)));
1697    }
1698
1699    #[test]
1700    fn rejects_non_http_url_scheme() {
1701        let error = Bootstrap::parse("v=v6mig-1 url=ftp://vne.example/rule.cgi t=a").unwrap_err();
1702
1703        assert!(matches!(error, BootstrapError::InvalidUrl(_, _)));
1704    }
1705
1706    #[test]
1707    fn rejects_http_with_tls_validation() {
1708        let error = Bootstrap::parse("v=v6mig-1 url=http://vne.example/rule.cgi t=b").unwrap_err();
1709
1710        assert!(matches!(error, BootstrapError::InvalidTlsForHttp));
1711    }
1712
1713    #[test]
1714    fn rejects_extra_fields() {
1715        let error = Bootstrap::parse("v=v6mig-1 url=https://vne.example/rule.cgi t=b extra=value")
1716            .unwrap_err();
1717
1718        assert!(matches!(error, BootstrapError::InvalidRecord(_)));
1719    }
1720
1721    #[test]
1722    fn rejects_ipv4_literal_provisioning_url() {
1723        let error = Bootstrap::parse("v=v6mig-1 url=https://192.0.2.1/provision t=b").unwrap_err();
1724
1725        assert!(matches!(error, BootstrapError::Ipv4EndpointNotAllowed));
1726    }
1727
1728    #[test]
1729    fn sets_and_clears_provisioning_request_token() {
1730        let mut request = valid_request();
1731        assert_eq!(request.token(), None);
1732
1733        request.set_token(Some(TOKEN.parse().unwrap()));
1734        assert_eq!(request.token(), Some(TOKEN));
1735
1736        request.set_token(None);
1737        assert_eq!(request.token(), None);
1738    }
1739}