Skip to main content

sccp_protocol/phone/xml/
telemetry.rs

1//! Telemetry phone XML document family.
2
3use super::*;
4
5pub(super) const LAST_OUT_OF_SERVICE_ALARM: &str = "LastOutOfServiceInformation";
6
7/// One named string value in a phone alarm parameter list.
8#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
9#[serde(deny_unknown_fields)]
10pub struct CiscoIpPhoneAlarmString {
11    #[serde(rename = "@name")]
12    pub name: String,
13    #[serde(rename = "$text", default)]
14    /// Free-form value omitted from [`Debug`](std::fmt::Debug) output.
15    pub value: String,
16}
17
18impl fmt::Debug for CiscoIpPhoneAlarmString {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        formatter
21            .debug_struct("CiscoIpPhoneAlarmString")
22            .field("name", &self.name)
23            .field("value", &"<redacted>")
24            .finish()
25    }
26}
27
28/// One named numeric enumeration in a phone alarm parameter list.
29#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
30#[serde(deny_unknown_fields)]
31pub struct CiscoIpPhoneAlarmEnum {
32    #[serde(rename = "@name")]
33    pub name: String,
34    #[serde(rename = "$text")]
35    pub value: i32,
36}
37
38impl fmt::Debug for CiscoIpPhoneAlarmEnum {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter
41            .debug_struct("CiscoIpPhoneAlarmEnum")
42            .field("name", &self.name)
43            .field("value", &self.value)
44            .finish()
45    }
46}
47
48/// An ordered, typed alarm parameter.
49#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
50pub enum CiscoIpPhoneAlarmParameter {
51    /// Textual parameter whose value remains redacted in diagnostics.
52    #[serde(rename = "String")]
53    String(CiscoIpPhoneAlarmString),
54    /// Numeric parameter safe for typed summaries when explicitly allowlisted.
55    #[serde(rename = "Enum")]
56    Enum(CiscoIpPhoneAlarmEnum),
57}
58
59impl fmt::Debug for CiscoIpPhoneAlarmParameter {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::String(value) => value.fmt(formatter),
63            Self::Enum(value) => value.fmt(formatter),
64        }
65    }
66}
67
68/// Ordered parameters carried by a supported phone alarm.
69#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct CiscoIpPhoneAlarmParameterList {
72    #[serde(rename = "$value", default)]
73    pub parameters: Vec<CiscoIpPhoneAlarmParameter>,
74}
75
76impl fmt::Debug for CiscoIpPhoneAlarmParameterList {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter
79            .debug_struct("CiscoIpPhoneAlarmParameterList")
80            .field("parameter_count", &self.parameters.len())
81            .finish()
82    }
83}
84
85/// The single supported alarm entry within an alarm document.
86#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
87#[serde(deny_unknown_fields)]
88pub struct CiscoIpPhoneAlarmEntry {
89    #[serde(rename = "@Name")]
90    pub name: String,
91    #[serde(rename = "ParameterList")]
92    pub parameter_list: CiscoIpPhoneAlarmParameterList,
93}
94
95impl fmt::Debug for CiscoIpPhoneAlarmEntry {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        formatter
98            .debug_struct("CiscoIpPhoneAlarmEntry")
99            .field("name", &self.name)
100            .field("parameter_count", &self.parameter_list.parameters.len())
101            .finish()
102    }
103}
104
105/// A typed `LastOutOfServiceInformation` alarm document.
106#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
107#[serde(rename = "x-cisco-alarm", deny_unknown_fields)]
108pub struct CiscoIpPhoneAlarm {
109    #[serde(rename = "Alarm")]
110    pub alarm: CiscoIpPhoneAlarmEntry,
111}
112
113impl fmt::Debug for CiscoIpPhoneAlarm {
114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115        formatter
116            .debug_struct("CiscoIpPhoneAlarm")
117            .field("name", &self.alarm.name)
118            .field(
119                "parameter_count",
120                &self.alarm.parameter_list.parameters.len(),
121            )
122            .finish()
123    }
124}
125
126impl CiscoIpPhoneAlarm {
127    /// Checks the supported alarm name and uniqueness/bounds of all parameters.
128    pub fn validate(&self) -> Result<(), PhoneXmlError> {
129        if self.alarm.name != LAST_OUT_OF_SERVICE_ALARM {
130            return Err(PhoneXmlError::InvalidField {
131                field: "phone alarm name",
132                expected: "LastOutOfServiceInformation",
133            });
134        }
135        let mut names = HashSet::new();
136        for parameter in &self.alarm.parameter_list.parameters {
137            let name = match parameter {
138                CiscoIpPhoneAlarmParameter::String(value) => {
139                    validate_optional_text(
140                        "phone alarm string name",
141                        Some(&value.name),
142                        1,
143                        PHONE_ALARM_MAX_BYTES,
144                    )?;
145                    validate_optional_text(
146                        "phone alarm string value",
147                        Some(&value.value),
148                        0,
149                        PHONE_ALARM_MAX_BYTES,
150                    )?;
151                    &value.name
152                }
153                CiscoIpPhoneAlarmParameter::Enum(value) => {
154                    validate_optional_text(
155                        "phone alarm enumeration name",
156                        Some(&value.name),
157                        1,
158                        PHONE_ALARM_MAX_BYTES,
159                    )?;
160                    &value.name
161                }
162            };
163            if !names.insert(name.as_str()) {
164                return Err(PhoneXmlError::InvalidField {
165                    field: "phone alarm parameter names",
166                    expected: "unique across string and enumeration parameters",
167                });
168            }
169        }
170        Ok(())
171    }
172
173    /// Parses the supported alarm schema while redacting schema-error details.
174    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
175        #[derive(serde::Deserialize)]
176        enum AlarmEnvelope {
177            #[serde(rename = "x-cisco-alarm")]
178            Alarm(CiscoIpPhoneAlarm),
179        }
180        let AlarmEnvelope::Alarm(document) =
181            from_bytes(document, PHONE_ALARM_MAX_BYTES).map_err(redact_alarm_schema_error)?;
182        document.validate()?;
183        Ok(document)
184    }
185
186    /// Validates and serializes the alarm within [`PHONE_ALARM_MAX_BYTES`].
187    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
188        self.validate()?;
189        to_string(self, PHONE_ALARM_MAX_BYTES)
190    }
191
192    /// Returns a textual parameter by name without exposing it through diagnostics.
193    pub fn string(&self, name: &str) -> Option<&str> {
194        self.alarm
195            .parameter_list
196            .parameters
197            .iter()
198            .find_map(|parameter| match parameter {
199                CiscoIpPhoneAlarmParameter::String(value) if value.name == name => {
200                    Some(value.value.as_str())
201                }
202                _ => None,
203            })
204    }
205
206    /// Returns a numeric parameter by name.
207    pub fn enumeration(&self, name: &str) -> Option<i32> {
208        self.alarm
209            .parameter_list
210            .parameters
211            .iter()
212            .find_map(|parameter| match parameter {
213                CiscoIpPhoneAlarmParameter::Enum(value) if value.name == name => Some(value.value),
214                _ => None,
215            })
216    }
217
218    /// Returns the allowlisted numeric out-of-service reason, when present.
219    pub fn reason_for_out_of_service(&self) -> Option<i32> {
220        self.enumeration("ReasonForOutOfService")
221    }
222}
223
224/// Exact bounded bytes for a syntactically valid but unsupported alarm schema.
225#[derive(Clone, Eq, PartialEq)]
226pub struct OpaquePhoneAlarm(Vec<u8>);
227
228impl OpaquePhoneAlarm {
229    pub fn as_bytes(&self) -> &[u8] {
230        &self.0
231    }
232
233    pub fn into_bytes(self) -> Vec<u8> {
234        self.0
235    }
236}
237
238impl fmt::Debug for OpaquePhoneAlarm {
239    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
240        formatter
241            .debug_struct("OpaquePhoneAlarm")
242            .field("byte_count", &self.0.len())
243            .finish_non_exhaustive()
244    }
245}
246
247/// Parsed alarm telemetry or a bounded lossless unknown schema.
248#[derive(Clone, Eq, PartialEq)]
249pub enum PhoneAlarmTelemetry {
250    /// The supported out-of-service schema, retained as typed parameters.
251    LastOutOfService(CiscoIpPhoneAlarm),
252    /// A syntactically valid unsupported schema retained losslessly.
253    Opaque(OpaquePhoneAlarm),
254}
255
256/// Allowlisted alarm family that is safe to publish without parameter data.
257#[derive(Clone, Copy, Debug, Eq, PartialEq)]
258pub enum PhoneAlarmKind {
259    LastOutOfService,
260}
261
262/// Secret-safe fields selected from a known alarm document.
263#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub struct PhoneAlarmSummary {
265    pub kind: PhoneAlarmKind,
266    /// Optional numeric reason; no free-form parameter data is published.
267    pub reason_for_out_of_service: Option<i32>,
268}
269
270impl fmt::Debug for PhoneAlarmTelemetry {
271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272        match self {
273            Self::LastOutOfService(alarm) => alarm.fmt(formatter),
274            Self::Opaque(alarm) => alarm.fmt(formatter),
275        }
276    }
277}
278
279impl PhoneAlarmTelemetry {
280    /// Returns only allowlisted numeric fields for known alarm schemas.
281    /// Opaque schemas never produce a publishable summary.
282    pub fn summary(&self) -> Option<PhoneAlarmSummary> {
283        match self {
284            Self::LastOutOfService(alarm) => Some(PhoneAlarmSummary {
285                kind: PhoneAlarmKind::LastOutOfService,
286                reason_for_out_of_service: alarm.reason_for_out_of_service(),
287            }),
288            Self::Opaque(_) => None,
289        }
290    }
291
292    pub fn is_opaque(&self) -> bool {
293        matches!(self, Self::Opaque(_))
294    }
295}
296
297/// Parse one bounded alarm document without treating malformed known XML as
298/// an opaque schema.
299pub fn parse_phone_alarm(document: &[u8]) -> Result<PhoneAlarmTelemetry, PhoneXmlError> {
300    #[derive(Debug, serde::Deserialize)]
301    struct AlarmProbe {
302        #[serde(rename = "Alarm", default)]
303        alarms: Vec<AlarmNameProbe>,
304    }
305
306    #[derive(Debug, serde::Deserialize)]
307    struct AlarmNameProbe {
308        #[serde(rename = "@Name")]
309        name: String,
310    }
311
312    #[derive(serde::Deserialize)]
313    enum AlarmProbeEnvelope {
314        #[serde(rename = "x-cisco-alarm")]
315        Alarm(AlarmProbe),
316        #[serde(other)]
317        Unknown,
318    }
319
320    let supported = match from_bytes(document, PHONE_ALARM_MAX_BYTES)
321        .map_err(redact_alarm_schema_error)?
322    {
323        AlarmProbeEnvelope::Alarm(probe) => {
324            matches!(probe.alarms.as_slice(), [alarm] if alarm.name == LAST_OUT_OF_SERVICE_ALARM)
325        }
326        AlarmProbeEnvelope::Unknown => false,
327    };
328    if supported {
329        CiscoIpPhoneAlarm::from_xml(document).map(PhoneAlarmTelemetry::LastOutOfService)
330    } else {
331        Ok(PhoneAlarmTelemetry::Opaque(OpaquePhoneAlarm(
332            document.to_vec(),
333        )))
334    }
335}
336
337pub(super) fn redact_alarm_schema_error(error: PhoneXmlError) -> PhoneXmlError {
338    match error {
339        PhoneXmlError::Deserialize(_) => PhoneXmlError::InvalidAlarmSchema,
340        error => error,
341    }
342}
343
344/// A six-octet wireless basic-service-set address with redacted diagnostics.
345#[derive(Clone, Copy, Eq, Hash, PartialEq)]
346pub struct PhoneBssid([u8; 6]);
347
348impl PhoneBssid {
349    /// Wraps the exact six address octets.
350    pub const fn from_octets(octets: [u8; 6]) -> Self {
351        Self(octets)
352    }
353
354    pub const fn octets(self) -> [u8; 6] {
355        self.0
356    }
357
358    /// Parses six colon-separated hexadecimal octets.
359    pub fn parse(value: &str) -> Result<Self, PhoneXmlError> {
360        parse_bssid(value).ok_or(PhoneXmlError::InvalidField {
361            field: "phone location BSSID",
362            expected: "six hexadecimal octets separated by colons",
363        })
364    }
365}
366
367impl fmt::Display for PhoneBssid {
368    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369        write!(
370            formatter,
371            "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
372            self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5]
373        )
374    }
375}
376
377impl fmt::Debug for PhoneBssid {
378    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
379        formatter.write_str("PhoneBssid(<redacted>)")
380    }
381}
382
383impl Serialize for PhoneBssid {
384    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
385    where
386        S: serde::Serializer,
387    {
388        serializer.serialize_str(&self.to_string())
389    }
390}
391
392impl<'de> serde::Deserialize<'de> for PhoneBssid {
393    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
394    where
395        D: serde::Deserializer<'de>,
396    {
397        let value = String::deserialize(deserializer)?;
398        parse_bssid(&value).map_or_else(
399            || {
400                Err(serde::de::Error::custom(
401                    "BSSID must contain six hexadecimal octets separated by colons",
402                ))
403            },
404            Ok,
405        )
406    }
407}
408
409pub(super) fn parse_bssid(value: &str) -> Option<PhoneBssid> {
410    let mut octets = [0u8; 6];
411    let mut components = value.split(':');
412    for octet in &mut octets {
413        let component = components.next()?;
414        if component.len() != 2 {
415            return None;
416        }
417        *octet = u8::from_str_radix(component, 16).ok()?;
418    }
419    components.next().is_none().then_some(PhoneBssid(octets))
420}
421
422/// Wireless location fields reported for the phone's first interface.
423///
424/// Diagnostics expose only lengths and never the address or network names.
425#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
426#[serde(deny_unknown_fields)]
427pub struct CiscoIpPhoneWifiLocation {
428    #[serde(rename = "BSSID")]
429    pub bssid: PhoneBssid,
430    #[serde(rename = "SSID")]
431    pub ssid: String,
432    #[serde(rename = "APName")]
433    pub access_point_name: String,
434}
435
436impl fmt::Debug for CiscoIpPhoneWifiLocation {
437    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
438        formatter
439            .debug_struct("CiscoIpPhoneWifiLocation")
440            .field("bssid", &self.bssid)
441            .field("ssid_byte_count", &self.ssid.len())
442            .field(
443                "access_point_name_char_count",
444                &self.access_point_name.chars().count(),
445            )
446            .finish()
447    }
448}
449
450/// Empty marker indicating that the phone considers itself off premises.
451#[derive(Clone, Default, serde::Deserialize, Eq, PartialEq, Serialize)]
452#[serde(deny_unknown_fields)]
453pub struct CiscoIpPhoneOffPremises {
454    #[serde(rename = "$text", default, skip_serializing_if = "String::is_empty")]
455    marker: String,
456}
457
458impl fmt::Debug for CiscoIpPhoneOffPremises {
459    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
460        formatter.write_str("CiscoIpPhoneOffPremises")
461    }
462}
463
464impl CiscoIpPhoneOffPremises {
465    /// Creates the required empty marker element.
466    pub const fn new() -> Self {
467        Self {
468            marker: String::new(),
469        }
470    }
471
472    fn validate(&self) -> Result<(), PhoneXmlError> {
473        if self.marker.is_empty() {
474            Ok(())
475        } else {
476            Err(PhoneXmlError::InvalidField {
477                field: "phone off-premises marker",
478                expected: "an empty element",
479            })
480        }
481    }
482}
483
484/// Typed wireless location-information document for interface one.
485///
486/// Diagnostics retain only the off-premises flag and redacted wireless data.
487#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
488#[serde(rename = "Interface1", deny_unknown_fields)]
489pub struct CiscoIpPhoneLocationInformation {
490    #[serde(rename = "wifi")]
491    pub wifi: CiscoIpPhoneWifiLocation,
492    #[serde(rename = "OffPrem", default, skip_serializing_if = "Option::is_none")]
493    pub off_premises: Option<CiscoIpPhoneOffPremises>,
494}
495
496impl fmt::Debug for CiscoIpPhoneLocationInformation {
497    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
498        formatter
499            .debug_struct("CiscoIpPhoneLocationInformation")
500            .field("wifi", &self.wifi)
501            .field("off_premises", &self.off_premises.is_some())
502            .finish()
503    }
504}
505
506impl CiscoIpPhoneLocationInformation {
507    /// Validates network-name bounds and the empty off-premises marker.
508    pub fn validate(&self) -> Result<(), PhoneXmlError> {
509        validate_optional_text(
510            "phone location SSID",
511            Some(&self.wifi.ssid),
512            0,
513            PHONE_LOCATION_MAX_BYTES,
514        )?;
515        validate_optional_text(
516            "phone location access-point name",
517            Some(&self.wifi.access_point_name),
518            0,
519            PHONE_LOCATION_MAX_BYTES,
520        )?;
521        if self.wifi.ssid.len() > 32 {
522            return Err(PhoneXmlError::InvalidField {
523                field: "phone location SSID",
524                expected: "at most 32 bytes",
525            });
526        }
527        if let Some(off_premises) = &self.off_premises {
528            off_premises.validate()?;
529        }
530        Ok(())
531    }
532
533    /// Parses the supported wireless-interface schema with redacted failures.
534    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
535        #[derive(serde::Deserialize)]
536        enum LocationEnvelope {
537            #[serde(rename = "Interface1")]
538            Location(CiscoIpPhoneLocationInformation),
539        }
540
541        let LocationEnvelope::Location(location) =
542            from_bytes(document, PHONE_LOCATION_MAX_BYTES).map_err(redact_location_schema_error)?;
543        location.validate()?;
544        Ok(location)
545    }
546
547    /// Validates and serializes location telemetry within [`PHONE_LOCATION_MAX_BYTES`].
548    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
549        self.validate()?;
550        to_string(self, PHONE_LOCATION_MAX_BYTES)
551    }
552
553    pub const fn is_off_premises(&self) -> bool {
554        self.off_premises.is_some()
555    }
556}
557
558/// Exact bounded bytes for a syntactically valid unsupported location schema.
559#[derive(Clone, Eq, PartialEq)]
560pub struct OpaquePhoneLocation(Vec<u8>);
561
562impl OpaquePhoneLocation {
563    pub fn as_bytes(&self) -> &[u8] {
564        &self.0
565    }
566
567    pub fn into_bytes(self) -> Vec<u8> {
568        self.0
569    }
570}
571
572impl fmt::Debug for OpaquePhoneLocation {
573    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
574        formatter
575            .debug_struct("OpaquePhoneLocation")
576            .field("byte_count", &self.0.len())
577            .finish_non_exhaustive()
578    }
579}
580
581/// Parsed location telemetry or a bounded lossless unsupported schema.
582#[derive(Clone, Eq, PartialEq)]
583pub enum PhoneLocationTelemetry {
584    /// The supported wireless-interface schema.
585    WirelessInterface(CiscoIpPhoneLocationInformation),
586    /// A syntactically valid unsupported schema retained losslessly.
587    Opaque(OpaquePhoneLocation),
588}
589
590impl fmt::Debug for PhoneLocationTelemetry {
591    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
592        match self {
593            Self::WirelessInterface(location) => location.fmt(formatter),
594            Self::Opaque(location) => location.fmt(formatter),
595        }
596    }
597}
598
599/// Allowlisted location family that is safe to publish without location data.
600#[derive(Clone, Copy, Debug, Eq, PartialEq)]
601pub enum PhoneLocationKind {
602    WirelessInterface,
603}
604
605/// Secret-safe location summary without network names or addresses.
606#[derive(Clone, Copy, Debug, Eq, PartialEq)]
607pub struct PhoneLocationSummary {
608    pub kind: PhoneLocationKind,
609    /// Whether the marker was present; network names and addresses are omitted.
610    pub off_premises: bool,
611}
612
613impl PhoneLocationTelemetry {
614    /// Returns only allowlisted non-identifying fields for a known schema.
615    pub fn summary(&self) -> Option<PhoneLocationSummary> {
616        match self {
617            Self::WirelessInterface(location) => Some(PhoneLocationSummary {
618                kind: PhoneLocationKind::WirelessInterface,
619                off_premises: location.is_off_premises(),
620            }),
621            Self::Opaque(_) => None,
622        }
623    }
624
625    pub fn is_opaque(&self) -> bool {
626        matches!(self, Self::Opaque(_))
627    }
628}
629
630/// Parse one bounded location-information document without treating a malformed
631/// supported root as an opaque schema.
632pub fn parse_phone_location(document: &[u8]) -> Result<PhoneLocationTelemetry, PhoneXmlError> {
633    #[derive(Debug, serde::Deserialize)]
634    struct LocationProbe;
635
636    #[derive(serde::Deserialize)]
637    enum LocationProbeEnvelope {
638        #[serde(rename = "Interface1")]
639        Location(LocationProbe),
640        #[serde(other)]
641        Unknown,
642    }
643
644    let supported = matches!(
645        from_bytes(document, PHONE_LOCATION_MAX_BYTES).map_err(redact_location_schema_error)?,
646        LocationProbeEnvelope::Location(_)
647    );
648    if supported {
649        CiscoIpPhoneLocationInformation::from_xml(document)
650            .map(PhoneLocationTelemetry::WirelessInterface)
651    } else {
652        Ok(PhoneLocationTelemetry::Opaque(OpaquePhoneLocation(
653            document.to_vec(),
654        )))
655    }
656}
657
658pub(super) fn redact_location_schema_error(error: PhoneXmlError) -> PhoneXmlError {
659    match error {
660        PhoneXmlError::Deserialize(_) => PhoneXmlError::InvalidLocationSchema,
661        error => error,
662    }
663}