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