Skip to main content

sccp_protocol/phone/
provisioning.rs

1//! Bounded station provisioning documents.
2//!
3//! Phone-service XML and boot configuration XML are distinct protocols. This
4//! module models the bootable `device` and `Default` roots without accepting
5//! arbitrary XML maps, while retaining open string values where firmware
6//! vocabularies vary by model.
7
8use std::fmt;
9use std::net::IpAddr;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14use super::xml::{PhoneXmlError, from_bytes, to_string, to_writer as write_document};
15
16/// Maximum encoded size of a provisioning XML document, in bytes.
17pub const PROVISIONING_MAX_BYTES: usize = 128 * 1_024;
18/// Maximum number of prioritized signaling servers in one device pool.
19pub const PROVISIONING_MAX_CALL_MANAGERS: usize = 5;
20/// Maximum number of time servers in one device pool.
21pub const PROVISIONING_MAX_NTP_SERVERS: usize = 5;
22/// Maximum number of model-specific firmware selections in one document.
23pub const PROVISIONING_MAX_FIRMWARE_LOADS: usize = 8;
24const PROVISIONING_MAX_TEXT_CHARS: usize = 256;
25
26/// Secret provisioning material whose diagnostics never reveal its value.
27#[derive(Clone, Eq, PartialEq, Deserialize, Serialize)]
28#[serde(transparent)]
29pub struct ProvisioningSecret(String);
30
31impl ProvisioningSecret {
32    /// Validates and wraps non-empty secret material for provisioning output.
33    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
34        let value = value.into();
35        validate_text(
36            "provisioning secret",
37            &value,
38            1,
39            PROVISIONING_MAX_TEXT_CHARS,
40        )?;
41        Ok(Self(value))
42    }
43
44    /// Exposes the value only to code that must consume or transmit the secret.
45    pub fn expose(&self) -> &str {
46        &self.0
47    }
48}
49
50impl fmt::Debug for ProvisioningSecret {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        formatter.write_str("ProvisioningSecret(<redacted>)")
53    }
54}
55
56impl TryFrom<String> for ProvisioningSecret {
57    type Error = PhoneXmlError;
58
59    fn try_from(value: String) -> Result<Self, Self::Error> {
60        Self::new(value)
61    }
62}
63
64/// Signaling protocol selected by a boot configuration.
65#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
66pub enum ProvisioningProtocol {
67    #[default]
68    #[serde(rename = "SCCP")]
69    Sccp,
70}
71
72/// Transport required for the signaling connection.
73#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
74pub enum ProvisioningTransport {
75    #[default]
76    #[serde(rename = "TCP")]
77    Clear,
78    #[serde(rename = "TLS")]
79    Tls,
80}
81
82/// Numeric boolean representation used by provisioning XML.
83#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
84pub enum ProvisioningBoolean {
85    /// Serializes as `0`; also the default when a value is omitted.
86    #[default]
87    #[serde(rename = "0")]
88    Disabled,
89    /// Serializes as `1`.
90    #[serde(rename = "1")]
91    Enabled,
92}
93
94/// IPv4, IPv6, or DNS endpoint used by call-control and NTP entries.
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct ProvisioningHost(String);
97
98impl ProvisioningHost {
99    /// Validates an IP address or bounded DNS hostname.
100    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
101        let value = value.into();
102        validate_host(&value)?;
103        Ok(Self(value))
104    }
105
106    pub fn as_str(&self) -> &str {
107        &self.0
108    }
109}
110
111impl AsRef<str> for ProvisioningHost {
112    fn as_ref(&self) -> &str {
113        self.as_str()
114    }
115}
116
117impl TryFrom<String> for ProvisioningHost {
118    type Error = PhoneXmlError;
119
120    fn try_from(value: String) -> Result<Self, Self::Error> {
121        Self::new(value)
122    }
123}
124
125impl FromStr for ProvisioningHost {
126    type Err = PhoneXmlError;
127
128    fn from_str(value: &str) -> Result<Self, Self::Err> {
129        Self::new(value)
130    }
131}
132
133impl Serialize for ProvisioningHost {
134    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
135    where
136        S: serde::Serializer,
137    {
138        serializer.serialize_str(&self.0)
139    }
140}
141
142impl<'de> Deserialize<'de> for ProvisioningHost {
143    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
144    where
145        D: serde::Deserializer<'de>,
146    {
147        let value = String::deserialize(deserializer)?;
148        Self::new(value).map_err(serde::de::Error::custom)
149    }
150}
151
152/// Clear and optional secure signaling ports for one server.
153#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
154#[serde(deny_unknown_fields)]
155pub struct ProvisioningPorts {
156    #[serde(rename = "ethernetPhonePort")]
157    /// Required nonzero clear-signaling port.
158    pub signaling: u16,
159    #[serde(
160        rename = "securedEthernetPhonePort",
161        default,
162        skip_serializing_if = "Option::is_none"
163    )]
164    /// Nonzero TLS port; required when [`ProvisioningTransport::Tls`] is selected.
165    pub secure_signaling: Option<u16>,
166}
167
168/// Host and transport endpoints for one signaling server.
169#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
170#[serde(deny_unknown_fields)]
171pub struct ProvisioningCallManager {
172    #[serde(rename = "processNodeName")]
173    pub host: ProvisioningHost,
174    pub ports: ProvisioningPorts,
175}
176
177/// One server plus its unique failover priority.
178#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
179#[serde(deny_unknown_fields)]
180pub struct ProvisioningCallManagerMember {
181    #[serde(rename = "@priority")]
182    /// Unique priority within the containing server group.
183    pub priority: u8,
184    #[serde(rename = "callManager")]
185    pub call_manager: ProvisioningCallManager,
186}
187
188/// XML wrapper around a bounded list of prioritized servers.
189#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
190#[serde(deny_unknown_fields)]
191pub struct ProvisioningCallManagerMembers {
192    #[serde(rename = "member", default)]
193    pub entries: Vec<ProvisioningCallManagerMember>,
194}
195
196/// Server failover group assigned to a device pool.
197#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
198#[serde(deny_unknown_fields)]
199pub struct ProvisioningCallManagerGroup {
200    pub members: ProvisioningCallManagerMembers,
201}
202
203/// One network time source and its query mode.
204#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
205#[serde(deny_unknown_fields)]
206pub struct ProvisioningNtpServer {
207    pub name: ProvisioningHost,
208    #[serde(rename = "ntpMode", default)]
209    pub mode: ProvisioningNtpMode,
210}
211
212/// Request mode used for a configured network time source.
213#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
214pub enum ProvisioningNtpMode {
215    #[default]
216    Unicast,
217    DirectedBroadcast,
218}
219
220/// XML wrapper around a bounded list of network time sources.
221#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
222#[serde(deny_unknown_fields)]
223pub struct ProvisioningNtpServers {
224    #[serde(rename = "ntp", default)]
225    pub entries: Vec<ProvisioningNtpServer>,
226}
227
228/// Display date/time policy and network time sources for a device pool.
229#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
230#[serde(deny_unknown_fields)]
231pub struct ProvisioningDateTime {
232    #[serde(rename = "dateTemplate")]
233    /// Display-format token validated to at most 16 characters.
234    pub date_template: String,
235    #[serde(rename = "timeZone")]
236    /// Time-zone identifier validated to at most 64 characters.
237    pub time_zone: String,
238    pub ntps: ProvisioningNtpServers,
239}
240
241/// Shared locale, time, and signaling-server assignment.
242#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
243#[serde(deny_unknown_fields)]
244pub struct ProvisioningDevicePool {
245    #[serde(rename = "dateTimeSetting")]
246    pub date_time: ProvisioningDateTime,
247    #[serde(rename = "callManagerGroup")]
248    pub call_managers: ProvisioningCallManagerGroup,
249}
250
251/// Optional user and network locale names and package versions.
252#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
253#[serde(deny_unknown_fields)]
254pub struct ProvisioningLocales {
255    #[serde(
256        rename = "userLocale",
257        default,
258        skip_serializing_if = "Option::is_none"
259    )]
260    pub user: Option<String>,
261    #[serde(
262        rename = "networkLocale",
263        default,
264        skip_serializing_if = "Option::is_none"
265    )]
266    pub network: Option<String>,
267    #[serde(
268        rename = "userLocaleVersion",
269        default,
270        skip_serializing_if = "Option::is_none"
271    )]
272    pub user_version: Option<String>,
273    #[serde(
274        rename = "networkLocaleVersion",
275        default,
276        skip_serializing_if = "Option::is_none"
277    )]
278    pub network_version: Option<String>,
279}
280
281/// Optional HTTP endpoints exposed by the phone's service menu and actions.
282#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
283#[serde(deny_unknown_fields)]
284pub struct ProvisioningServiceUrls {
285    #[serde(
286        rename = "authenticationURL",
287        default,
288        skip_serializing_if = "Option::is_none"
289    )]
290    pub authentication: Option<String>,
291    #[serde(
292        rename = "directoryURL",
293        default,
294        skip_serializing_if = "Option::is_none"
295    )]
296    pub directory: Option<String>,
297    #[serde(rename = "idleURL", default, skip_serializing_if = "Option::is_none")]
298    pub idle: Option<String>,
299    #[serde(
300        rename = "informationURL",
301        default,
302        skip_serializing_if = "Option::is_none"
303    )]
304    pub information: Option<String>,
305    #[serde(
306        rename = "messagesURL",
307        default,
308        skip_serializing_if = "Option::is_none"
309    )]
310    pub messages: Option<String>,
311    #[serde(
312        rename = "proxyServerURL",
313        default,
314        skip_serializing_if = "Option::is_none"
315    )]
316    pub proxy: Option<String>,
317    #[serde(
318        rename = "servicesURL",
319        default,
320        skip_serializing_if = "Option::is_none"
321    )]
322    pub services: Option<String>,
323}
324
325/// Optional six-bit DSCP values for signaling and media traffic classes.
326#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
327#[serde(deny_unknown_fields)]
328pub struct ProvisioningDscp {
329    #[serde(
330        rename = "dscpForCallControl",
331        default,
332        skip_serializing_if = "Option::is_none"
333    )]
334    /// Signaling DSCP in the inclusive range `0..=63`.
335    pub signaling: Option<u8>,
336    #[serde(
337        rename = "dscpForAudio",
338        default,
339        skip_serializing_if = "Option::is_none"
340    )]
341    /// Audio DSCP in the inclusive range `0..=63`.
342    pub audio: Option<u8>,
343    #[serde(
344        rename = "dscpForVideo",
345        default,
346        skip_serializing_if = "Option::is_none"
347    )]
348    /// Video DSCP in the inclusive range `0..=63`.
349    pub video: Option<u8>,
350}
351
352/// Optional feature switches; omitted entries defer to the device default.
353#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
354#[serde(deny_unknown_fields)]
355pub struct ProvisioningFeatures {
356    #[serde(
357        rename = "enblocDialing",
358        default,
359        skip_serializing_if = "Option::is_none"
360    )]
361    pub enbloc_dialing: Option<ProvisioningBoolean>,
362    #[serde(
363        rename = "dndControl",
364        default,
365        skip_serializing_if = "Option::is_none"
366    )]
367    pub do_not_disturb: Option<ProvisioningBoolean>,
368    #[serde(
369        rename = "joinAcrossLines",
370        default,
371        skip_serializing_if = "Option::is_none"
372    )]
373    pub join_across_lines: Option<ProvisioningBoolean>,
374    #[serde(
375        rename = "callPickup",
376        default,
377        skip_serializing_if = "Option::is_none"
378    )]
379    pub call_pickup: Option<ProvisioningBoolean>,
380    #[serde(rename = "barge", default, skip_serializing_if = "Option::is_none")]
381    pub barge: Option<ProvisioningBoolean>,
382    #[serde(rename = "blf", default, skip_serializing_if = "Option::is_none")]
383    pub blf: Option<ProvisioningBoolean>,
384    #[serde(rename = "mwi", default, skip_serializing_if = "Option::is_none")]
385    pub mwi: Option<ProvisioningBoolean>,
386    #[serde(rename = "mobility", default, skip_serializing_if = "Option::is_none")]
387    pub mobility: Option<ProvisioningBoolean>,
388}
389
390/// Preferred codec name and optional per-codec enablement switches.
391#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
392#[serde(deny_unknown_fields)]
393pub struct ProvisioningCodecPolicy {
394    #[serde(
395        rename = "preferredCodec",
396        default,
397        skip_serializing_if = "Option::is_none"
398    )]
399    pub preferred: Option<String>,
400    #[serde(
401        rename = "g722CodecSupport",
402        default,
403        skip_serializing_if = "Option::is_none"
404    )]
405    pub g722: Option<ProvisioningBoolean>,
406    #[serde(
407        rename = "g729CodecSupport",
408        default,
409        skip_serializing_if = "Option::is_none"
410    )]
411    pub g729: Option<ProvisioningBoolean>,
412}
413
414/// Certificate-enrollment endpoint and optional authentication material.
415#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
416#[serde(deny_unknown_fields)]
417pub struct ProvisioningCapf {
418    #[serde(rename = "phonePort")]
419    /// Nonzero enrollment-service port.
420    pub phone_port: u16,
421    #[serde(rename = "processNodeName")]
422    pub host: ProvisioningHost,
423    #[serde(
424        rename = "authenticationMode",
425        default,
426        skip_serializing_if = "Option::is_none"
427    )]
428    pub authentication_mode: Option<String>,
429    #[serde(
430        rename = "authenticationToken",
431        default,
432        skip_serializing_if = "Option::is_none"
433    )]
434    /// Optional secret token, redacted by its [`Debug`](std::fmt::Debug) implementation.
435    pub authentication_token: Option<ProvisioningSecret>,
436    #[serde(
437        rename = "certificateOperation",
438        default,
439        skip_serializing_if = "Option::is_none"
440    )]
441    pub certificate_operation: Option<String>,
442}
443
444/// Signaling transport and optional certificate/trust configuration.
445#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
446#[serde(deny_unknown_fields)]
447pub struct ProvisioningSecurity {
448    #[serde(rename = "transportLayerProtocol", default)]
449    /// Selects the transport and determines whether every server needs a secure port.
450    pub transport: ProvisioningTransport,
451    #[serde(
452        rename = "deviceSecurityMode",
453        default,
454        skip_serializing_if = "Option::is_none"
455    )]
456    pub device_security_mode: Option<u8>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    /// Optional enrollment configuration; its port must be nonzero.
459    pub capf: Option<ProvisioningCapf>,
460    #[serde(
461        rename = "encryptedConfig",
462        default,
463        skip_serializing_if = "Option::is_none"
464    )]
465    /// Opaque protected configuration material omitted when unavailable.
466    pub encrypted_config: Option<ProvisioningSecret>,
467    #[serde(rename = "trustList", default, skip_serializing_if = "Option::is_none")]
468    /// Opaque trust material omitted when unavailable.
469    pub trust_list: Option<ProvisioningSecret>,
470}
471
472/// Firmware filename, optionally restricted to one model identifier.
473#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
474#[serde(deny_unknown_fields)]
475pub struct ProvisioningFirmwareLoad {
476    #[serde(rename = "@model", default, skip_serializing_if = "Option::is_none")]
477    /// Optional model selector; duplicate selectors are rejected by validation.
478    pub model: Option<String>,
479    #[serde(rename = "$text")]
480    pub file: String,
481}
482
483/// Device-specific quality-of-service, codec, and feature settings.
484#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
485#[serde(deny_unknown_fields)]
486pub struct ProvisioningVendorConfig {
487    #[serde(default)]
488    pub dscp: ProvisioningDscp,
489    #[serde(default)]
490    pub codecs: ProvisioningCodecPolicy,
491    #[serde(default)]
492    pub features: ProvisioningFeatures,
493}
494
495/// Complete per-device boot configuration rooted at the `device` XML element.
496#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
497#[serde(rename = "device", deny_unknown_fields)]
498pub struct DeviceProvisioning {
499    #[serde(rename = "deviceProtocol", default)]
500    pub protocol: ProvisioningProtocol,
501    #[serde(rename = "devicePool")]
502    pub device_pool: ProvisioningDevicePool,
503    #[serde(default)]
504    pub locales: ProvisioningLocales,
505    #[serde(rename = "phoneServices", default)]
506    pub service_urls: ProvisioningServiceUrls,
507    #[serde(rename = "securityProfile", default)]
508    pub security: ProvisioningSecurity,
509    #[serde(rename = "vendorConfig", default)]
510    pub vendor: ProvisioningVendorConfig,
511    #[serde(rename = "loadInformation", default)]
512    /// Model-specific firmware entries, bounded by [`PROVISIONING_MAX_FIRMWARE_LOADS`].
513    pub firmware_loads: Vec<ProvisioningFirmwareLoad>,
514    #[serde(rename = "sshUserId", default, skip_serializing_if = "Option::is_none")]
515    /// Optional remote-access user identifier; never treated as secret diagnostics.
516    pub ssh_user_id: Option<String>,
517    #[serde(
518        rename = "sshPassword",
519        default,
520        skip_serializing_if = "Option::is_none"
521    )]
522    /// Optional remote-access password with redacted diagnostics.
523    pub ssh_password: Option<ProvisioningSecret>,
524}
525
526impl DeviceProvisioning {
527    /// Parses, bounds, and validates a complete per-device XML document.
528    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
529        let value: Self = from_bytes(document, PROVISIONING_MAX_BYTES)?;
530        value.validate()?;
531        Ok(value)
532    }
533
534    /// Validates and serializes the document within [`PROVISIONING_MAX_BYTES`].
535    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
536        self.validate()?;
537        to_string(self, PROVISIONING_MAX_BYTES)
538    }
539
540    /// Validates and writes the document to a formatting sink.
541    pub fn write_xml(&self, writer: impl fmt::Write) -> Result<(), PhoneXmlError> {
542        self.validate()?;
543        write_document(writer, self, PROVISIONING_MAX_BYTES)
544    }
545
546    /// Checks collection bounds, endpoint consistency, and all textual invariants.
547    pub fn validate(&self) -> Result<(), PhoneXmlError> {
548        validate_device_pool(&self.device_pool)?;
549        validate_transport_endpoints(&self.device_pool, self.security.transport)?;
550        validate_locales(&self.locales)?;
551        validate_service_urls(&self.service_urls)?;
552        validate_dscp(&self.vendor.dscp)?;
553        validate_codec_policy(&self.vendor.codecs)?;
554        validate_security(&self.security)?;
555        validate_count(
556            "firmware load entries",
557            self.firmware_loads.len(),
558            PROVISIONING_MAX_FIRMWARE_LOADS,
559        )?;
560        validate_firmware_loads(&self.firmware_loads)?;
561        validate_optional_text("SSH user ID", self.ssh_user_id.as_deref(), 1, 64)
562    }
563}
564
565fn validate_transport_endpoints(
566    pool: &ProvisioningDevicePool,
567    transport: ProvisioningTransport,
568) -> Result<(), PhoneXmlError> {
569    if transport == ProvisioningTransport::Tls
570        && !pool
571            .call_managers
572            .members
573            .entries
574            .iter()
575            .all(|member| member.call_manager.ports.secure_signaling.is_some())
576    {
577        return Err(PhoneXmlError::InvalidField {
578            field: "secure signaling endpoint",
579            expected: "a secure signaling port for every configured call manager",
580        });
581    }
582    Ok(())
583}
584
585/// Shared fallback configuration rooted at the `Default` XML element.
586#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
587#[serde(rename = "Default", deny_unknown_fields)]
588pub struct DefaultProvisioning {
589    #[serde(rename = "devicePool")]
590    pub device_pool: ProvisioningDevicePool,
591    #[serde(rename = "loadInformation", default)]
592    pub firmware_loads: Vec<ProvisioningFirmwareLoad>,
593}
594
595impl DefaultProvisioning {
596    /// Parses, bounds, and validates a complete fallback XML document.
597    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
598        let value: Self = from_bytes(document, PROVISIONING_MAX_BYTES)?;
599        value.validate()?;
600        Ok(value)
601    }
602
603    /// Validates and serializes the document within [`PROVISIONING_MAX_BYTES`].
604    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
605        self.validate()?;
606        to_string(self, PROVISIONING_MAX_BYTES)
607    }
608
609    /// Validates and writes the document to a formatting sink.
610    pub fn write_xml(&self, writer: impl fmt::Write) -> Result<(), PhoneXmlError> {
611        self.validate()?;
612        write_document(writer, self, PROVISIONING_MAX_BYTES)
613    }
614
615    /// Checks device-pool and firmware-list invariants.
616    pub fn validate(&self) -> Result<(), PhoneXmlError> {
617        validate_device_pool(&self.device_pool)?;
618        validate_firmware_loads(&self.firmware_loads)
619    }
620}
621
622fn validate_device_pool(pool: &ProvisioningDevicePool) -> Result<(), PhoneXmlError> {
623    validate_text("date template", &pool.date_time.date_template, 1, 16)?;
624    validate_text("time zone", &pool.date_time.time_zone, 1, 64)?;
625    validate_count(
626        "NTP servers",
627        pool.date_time.ntps.entries.len(),
628        PROVISIONING_MAX_NTP_SERVERS,
629    )?;
630    let members = &pool.call_managers.members.entries;
631    if members.is_empty() {
632        return Err(PhoneXmlError::InvalidField {
633            field: "call-manager group",
634            expected: "at least one server",
635        });
636    }
637    validate_count(
638        "call-manager servers",
639        members.len(),
640        PROVISIONING_MAX_CALL_MANAGERS,
641    )?;
642    let mut priorities = members
643        .iter()
644        .map(|member| member.priority)
645        .collect::<Vec<_>>();
646    priorities.sort_unstable();
647    if priorities.windows(2).any(|pair| pair[0] == pair[1]) {
648        return Err(PhoneXmlError::InvalidField {
649            field: "call-manager priority",
650            expected: "unique priorities",
651        });
652    }
653    if members.iter().any(|member| {
654        member.call_manager.ports.signaling == 0
655            || member.call_manager.ports.secure_signaling == Some(0)
656    }) {
657        return Err(PhoneXmlError::InvalidField {
658            field: "call-manager port",
659            expected: "between 1 and 65535",
660        });
661    }
662    Ok(())
663}
664
665fn validate_firmware_loads(loads: &[ProvisioningFirmwareLoad]) -> Result<(), PhoneXmlError> {
666    validate_count(
667        "firmware load entries",
668        loads.len(),
669        PROVISIONING_MAX_FIRMWARE_LOADS,
670    )?;
671    for load in loads {
672        validate_optional_text("firmware model", load.model.as_deref(), 1, 64)?;
673        validate_text("firmware load", &load.file, 1, 128)?;
674    }
675    Ok(())
676}
677
678fn validate_locales(locales: &ProvisioningLocales) -> Result<(), PhoneXmlError> {
679    for value in [
680        locales.user.as_deref(),
681        locales.network.as_deref(),
682        locales.user_version.as_deref(),
683        locales.network_version.as_deref(),
684    ] {
685        validate_optional_text("locale", value, 1, 64)?;
686    }
687    Ok(())
688}
689
690fn validate_service_urls(urls: &ProvisioningServiceUrls) -> Result<(), PhoneXmlError> {
691    for value in [
692        urls.authentication.as_deref(),
693        urls.directory.as_deref(),
694        urls.idle.as_deref(),
695        urls.information.as_deref(),
696        urls.messages.as_deref(),
697        urls.proxy.as_deref(),
698        urls.services.as_deref(),
699    ] {
700        validate_optional_text("provisioning service URL", value, 1, 256)?;
701    }
702    Ok(())
703}
704
705fn validate_dscp(dscp: &ProvisioningDscp) -> Result<(), PhoneXmlError> {
706    if [dscp.signaling, dscp.audio, dscp.video]
707        .into_iter()
708        .flatten()
709        .any(|value| value > 63)
710    {
711        return Err(PhoneXmlError::InvalidField {
712            field: "provisioning DSCP",
713            expected: "between 0 and 63",
714        });
715    }
716    Ok(())
717}
718
719fn validate_codec_policy(policy: &ProvisioningCodecPolicy) -> Result<(), PhoneXmlError> {
720    validate_optional_text("preferred codec", policy.preferred.as_deref(), 1, 32)
721}
722
723fn validate_security(security: &ProvisioningSecurity) -> Result<(), PhoneXmlError> {
724    if let Some(capf) = &security.capf {
725        if capf.phone_port == 0 {
726            return Err(PhoneXmlError::InvalidField {
727                field: "CAPF port",
728                expected: "between 1 and 65535",
729            });
730        }
731        validate_optional_text(
732            "CAPF authentication mode",
733            capf.authentication_mode.as_deref(),
734            1,
735            64,
736        )?;
737        validate_optional_text(
738            "CAPF certificate operation",
739            capf.certificate_operation.as_deref(),
740            1,
741            64,
742        )?;
743    }
744    Ok(())
745}
746
747fn validate_host(value: &str) -> Result<(), PhoneXmlError> {
748    if value.parse::<IpAddr>().is_ok() {
749        return Ok(());
750    }
751    validate_text("provisioning host", value, 1, 253)?;
752    if value.split('.').any(|label| {
753        label.is_empty()
754            || label.len() > 63
755            || label.starts_with('-')
756            || label.ends_with('-')
757            || !label
758                .bytes()
759                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
760    }) {
761        return Err(PhoneXmlError::InvalidField {
762            field: "provisioning host",
763            expected: "an IPv4, IPv6, or DNS name",
764        });
765    }
766    Ok(())
767}
768
769fn validate_count(kind: &'static str, actual: usize, maximum: usize) -> Result<(), PhoneXmlError> {
770    if actual > maximum {
771        Err(PhoneXmlError::LimitExceeded {
772            kind,
773            actual,
774            maximum,
775        })
776    } else {
777        Ok(())
778    }
779}
780
781fn validate_optional_text(
782    field: &'static str,
783    value: Option<&str>,
784    minimum: usize,
785    maximum: usize,
786) -> Result<(), PhoneXmlError> {
787    match value {
788        Some(value) => validate_text(field, value, minimum, maximum),
789        None => Ok(()),
790    }
791}
792
793fn validate_text(
794    field: &'static str,
795    value: &str,
796    minimum: usize,
797    maximum: usize,
798) -> Result<(), PhoneXmlError> {
799    let length = value.chars().count();
800    if !(minimum..=maximum).contains(&length)
801        || value.chars().any(|character| character.is_control())
802    {
803        Err(PhoneXmlError::InvalidField {
804            field,
805            expected: "within the provisioning text bounds",
806        })
807    } else {
808        Ok(())
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    fn device() -> DeviceProvisioning {
817        DeviceProvisioning {
818            protocol: ProvisioningProtocol::Sccp,
819            device_pool: ProvisioningDevicePool {
820                date_time: ProvisioningDateTime {
821                    date_template: "D/M/Ya".into(),
822                    time_zone: "Pacific Standard/Daylight Time".into(),
823                    ntps: ProvisioningNtpServers {
824                        entries: vec![ProvisioningNtpServer {
825                            name: ProvisioningHost::new("192.0.2.10").unwrap(),
826                            mode: ProvisioningNtpMode::Unicast,
827                        }],
828                    },
829                },
830                call_managers: ProvisioningCallManagerGroup {
831                    members: ProvisioningCallManagerMembers {
832                        entries: vec![ProvisioningCallManagerMember {
833                            priority: 0,
834                            call_manager: ProvisioningCallManager {
835                                host: ProvisioningHost::new("pbx.example.test").unwrap(),
836                                ports: ProvisioningPorts {
837                                    signaling: 2000,
838                                    secure_signaling: Some(2443),
839                                },
840                            },
841                        }],
842                    },
843                },
844            },
845            locales: ProvisioningLocales {
846                user: Some("English_United_States".into()),
847                network: Some("United_States".into()),
848                ..Default::default()
849            },
850            service_urls: ProvisioningServiceUrls {
851                directory: Some("https://pbx.example.test/sccp/directory".into()),
852                services: Some("https://pbx.example.test/sccp/services".into()),
853                ..Default::default()
854            },
855            security: ProvisioningSecurity {
856                transport: ProvisioningTransport::Tls,
857                capf: Some(ProvisioningCapf {
858                    phone_port: 3804,
859                    host: ProvisioningHost::new("198.51.100.8").unwrap(),
860                    authentication_mode: Some("ByAuthenticationString".into()),
861                    authentication_token: Some(ProvisioningSecret::new("secret-token").unwrap()),
862                    certificate_operation: Some("InstallUpgrade".into()),
863                }),
864                encrypted_config: Some(ProvisioningSecret::new("encrypted-config-token").unwrap()),
865                ..Default::default()
866            },
867            vendor: ProvisioningVendorConfig {
868                dscp: ProvisioningDscp {
869                    signaling: Some(24),
870                    audio: Some(46),
871                    video: Some(34),
872                },
873                codecs: ProvisioningCodecPolicy {
874                    preferred: Some("g711alaw".into()),
875                    g722: Some(ProvisioningBoolean::Enabled),
876                    g729: Some(ProvisioningBoolean::Disabled),
877                },
878                features: ProvisioningFeatures {
879                    enbloc_dialing: Some(ProvisioningBoolean::Enabled),
880                    do_not_disturb: Some(ProvisioningBoolean::Enabled),
881                    call_pickup: Some(ProvisioningBoolean::Enabled),
882                    barge: Some(ProvisioningBoolean::Enabled),
883                    blf: Some(ProvisioningBoolean::Enabled),
884                    mwi: Some(ProvisioningBoolean::Enabled),
885                    mobility: Some(ProvisioningBoolean::Enabled),
886                    ..Default::default()
887                },
888            },
889            firmware_loads: vec![ProvisioningFirmwareLoad {
890                model: Some("Cisco 7961".into()),
891                file: "term61.default.loads".into(),
892            }],
893            ssh_user_id: Some("phone-admin".into()),
894            ssh_password: Some(ProvisioningSecret::new("ssh-password").unwrap()),
895        }
896    }
897
898    #[test]
899    fn device_and_default_roots_round_trip_bootable_subset() {
900        let mut expected = device();
901        expected
902            .device_pool
903            .call_managers
904            .members
905            .entries
906            .push(ProvisioningCallManagerMember {
907                priority: 1,
908                call_manager: ProvisioningCallManager {
909                    host: ProvisioningHost::new("2001:db8::20").unwrap(),
910                    ports: ProvisioningPorts {
911                        signaling: 2000,
912                        secure_signaling: Some(2443),
913                    },
914                },
915            });
916        expected.service_urls.information =
917            Some("https://pbx.example.test/info?name=A&B=<all>".into());
918        let xml = expected.to_xml().unwrap();
919        assert!(xml.starts_with("<device>"));
920        assert!(xml.contains("A&amp;B=&lt;all&gt;"));
921        assert!(
922            xml.find("pbx.example.test").unwrap() < xml.find("2001:db8::20").unwrap(),
923            "call-manager order must remain deterministic"
924        );
925        assert_eq!(
926            DeviceProvisioning::from_xml(xml.as_bytes()).unwrap(),
927            expected
928        );
929
930        let default = DefaultProvisioning {
931            device_pool: expected.device_pool.clone(),
932            firmware_loads: expected.firmware_loads.clone(),
933        };
934        let xml = default.to_xml().unwrap();
935        assert_eq!(
936            DefaultProvisioning::from_xml(xml.as_bytes()).unwrap(),
937            default
938        );
939    }
940
941    #[test]
942    fn provisioning_rejects_ambiguous_endpoints_priorities_and_dscp() {
943        assert!(ProvisioningHost::new("bad host").is_err());
944        let mut invalid = device();
945        invalid
946            .device_pool
947            .call_managers
948            .members
949            .entries
950            .push(invalid.device_pool.call_managers.members.entries[0].clone());
951        assert!(invalid.validate().is_err());
952
953        let mut invalid = device();
954        let mut clear_only = invalid.device_pool.call_managers.members.entries[0].clone();
955        clear_only.priority = 1;
956        clear_only.call_manager.ports.secure_signaling = None;
957        invalid
958            .device_pool
959            .call_managers
960            .members
961            .entries
962            .push(clear_only);
963        assert!(invalid.validate().is_err());
964
965        let mut invalid = device();
966        invalid
967            .device_pool
968            .call_managers
969            .members
970            .entries
971            .iter_mut()
972            .for_each(|member| member.call_manager.ports.secure_signaling = None);
973        assert!(invalid.validate().is_err());
974
975        let mut invalid = device();
976        invalid.vendor.dscp.audio = Some(64);
977        assert!(invalid.validate().is_err());
978
979        let mut invalid = device();
980        invalid.device_pool.call_managers.members.entries[0]
981            .call_manager
982            .ports
983            .secure_signaling = Some(0);
984        assert!(invalid.validate().is_err());
985
986        let mut invalid = DefaultProvisioning {
987            device_pool: device().device_pool,
988            firmware_loads: vec![ProvisioningFirmwareLoad {
989                model: None,
990                file: String::new(),
991            }],
992        };
993        assert!(invalid.validate().is_err());
994        invalid.firmware_loads[0].file = "term.default.loads".into();
995        assert!(invalid.validate().is_ok());
996    }
997
998    #[test]
999    fn provisioning_debug_redacts_every_secret() {
1000        let debug = format!("{:?}", device());
1001        assert!(!debug.contains("secret-token"));
1002        assert!(!debug.contains("encrypted-config-token"));
1003        assert!(!debug.contains("ssh-password"));
1004        assert!(debug.contains("<redacted>"));
1005    }
1006
1007    #[test]
1008    fn provisioning_rejects_wrong_unsafe_unknown_malformed_and_oversized_xml() {
1009        assert!(DeviceProvisioning::from_xml(b"<Default/>").is_err());
1010        assert!(DeviceProvisioning::from_xml(b"<device><unknown/></device>").is_err());
1011        assert!(DeviceProvisioning::from_xml(b"<device>").is_err());
1012        assert!(matches!(
1013            DeviceProvisioning::from_xml(
1014                br#"<!DOCTYPE device [<!ENTITY host "pbx.example.test">]><device/>"#,
1015            ),
1016            Err(PhoneXmlError::DocumentTypeForbidden)
1017        ));
1018        assert!(matches!(
1019            DeviceProvisioning::from_xml(&vec![b'x'; PROVISIONING_MAX_BYTES + 1]),
1020            Err(PhoneXmlError::LimitExceeded { .. })
1021        ));
1022
1023        #[derive(Debug)]
1024        struct FailingWriter;
1025        impl fmt::Write for FailingWriter {
1026            fn write_str(&mut self, _value: &str) -> fmt::Result {
1027                Err(fmt::Error)
1028            }
1029        }
1030        assert!(matches!(
1031            device().write_xml(FailingWriter),
1032            Err(PhoneXmlError::Write(_))
1033        ));
1034    }
1035}