oa_gateway_uci/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
//! Schema-aware UCI XML ↔ OMS JSON.
//!
//! The engine stays on opaque bytes. Adapters call [`Message::from_json`] /
//! [`Message::from_xml`] and emit the other serialization. Conversion
//! is deliberately forgiving; [`Message::violations`] is what names a
//! payload that converted and still does not follow the schema.
//!
//! A [`Schema`] tells the converter what it cannot infer from a single
//! document: whether a field repeats, and whether a leaf is a number, a
//! boolean, or a string. Compile one from the published XSD with
//! [`xsd::compile`]. The [`mod@slice`] module holds a small
//! hand-written schema for tests only.

mod error;
mod instance;
mod json;
pub mod primitive;
mod schema;
pub mod slice;
pub mod validate;
mod xml;
pub mod xsd;

/// Deepest element nesting accepted when converting a payload.
///
/// Conversion walks a document recursively, so nesting is a stack-depth
/// question: unbounded, a document deep enough ends the process rather than the
/// message.
///
/// The number is bracketed rather than chosen. `SystemReadiness`, the deepest
/// message in the published catalog, declares 39 levels — so a limit anywhere
/// near that would refuse real traffic, and the ignored `published_schema` test
/// re-measures it against whatever schema you compile. serde_json refuses at
/// 128 nested values on its own, so a limit above that would leave JSON and XML
/// failing at two different depths for the same payload. This sits between the
/// two, with room over the catalog and none borrowed from serde_json.
pub const MAX_DEPTH: usize = 96;

pub use error::UciError;
pub use instance::{Complex, Field, Message, Node, Simple};
pub use schema::{
    choice, el, el_many, el_opt, sequence, ComplexContent, ComplexType, Effective, Element, Facets,
    Group, GroupKind, MaxOccurs, Schema, SimpleType,
};
pub use validate::{validate, Mode as ValidateMode, Violation, ViolationKind};

impl Message {
    /// Every way this message departs from `schema`; empty when none.
    ///
    /// See [`mod@validate`] for what that does and does not cover.
    #[must_use]
    pub fn violations(&self, schema: &Schema) -> Vec<Violation> {
        validate::validate(self, schema)
    }

    /// Parses OMS JSON. The root is a single-key object whose key is a
    /// global element. `$type` selects a concrete type. An undeclared
    /// field is carried as `xs:string` rather than refused.
    ///
    /// # Errors
    ///
    /// Returns [`UciError`] if the text is not a single-key object, the
    /// element is unknown, a value cannot be mapped, or nesting exceeds
    /// [`MAX_DEPTH`].
    pub fn from_json(text: &str, schema: &Schema) -> Result<Self, UciError> {
        json::from_json(text, schema)
    }

    /// Parses UCI XML. The root local name is the global element.
    /// `xsi:type` selects a concrete type. Nesting is counted on the
    /// text before the tree is built.
    ///
    /// # Errors
    ///
    /// Returns [`UciError`] if the document is not well-formed, the
    /// element is unknown, an extension chain is cyclic, or nesting
    /// exceeds [`MAX_DEPTH`].
    pub fn from_xml(text: &str, schema: &Schema) -> Result<Self, UciError> {
        xml::from_xml(text, schema)
    }

    /// Serializes this message as OMS JSON.
    ///
    /// # Errors
    ///
    /// Returns [`UciError`] if an extension chain is cyclic or nesting
    /// exceeds [`MAX_DEPTH`].
    pub fn to_json(&self, schema: &Schema) -> Result<String, UciError> {
        json::to_json(self, schema)
    }

    /// Serializes this message as UCI XML, with the OAM namespace on
    /// the root.
    ///
    /// # Errors
    ///
    /// Returns [`UciError`] if an extension chain is cyclic or nesting
    /// exceeds [`MAX_DEPTH`].
    pub fn to_xml(&self, schema: &Schema) -> Result<String, UciError> {
        xml::to_xml(self, schema)
    }

    /// The global element name. Same as the OMS JSON root key.
    #[must_use]
    pub fn type_hint(&self) -> &str {
        &self.name
    }
}

/// Whether `bytes` look like XML: UTF-8 whose first non-space is `<`.
///
/// Not a schema check. A document that starts with a BOM or a
/// comment-only prologue is not recognized.
#[must_use]
pub fn looks_like_xml(bytes: &[u8]) -> bool {
    std::str::from_utf8(bytes)
        .ok()
        .is_some_and(|s| s.trim_start().starts_with('<'))
}

/// Whether `bytes` look like OMS JSON: UTF-8 whose first non-space is `{`.
#[must_use]
pub fn looks_like_json(bytes: &[u8]) -> bool {
    std::str::from_utf8(bytes)
        .ok()
        .is_some_and(|s| s.trim_start().starts_with('{'))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::{json, Value};

    fn schema() -> &'static Schema {
        slice::v25()
    }

    fn json_eq(a: &str, b: &str) {
        let va: Value = serde_json::from_str(a).unwrap();
        let vb: Value = serde_json::from_str(b).unwrap();
        assert_eq!(va, vb);
    }

    /// A type that contains itself, so nesting can be declared without end.
    ///
    /// The published catalog has no such type, but a payload's depth is chosen
    /// by whoever sends it, and a schema is an input too.
    fn recursive_schema() -> Schema {
        let mut s = Schema::new();
        s.complex(
            "NestType",
            vec![el_opt("Nest", "NestType"), el_opt("leaf", "xs:string")],
        )
        .element("Nest", "NestType");
        s
    }

    fn nested_json(levels: usize) -> String {
        let mut out = String::from(r#"{"leaf":"x"}"#);
        for _ in 0..levels {
            out = format!(r#"{{"Nest":{out}}}"#);
        }
        format!(r#"{{"Nest":{out}}}"#)
    }

    fn nested_xml(levels: usize) -> String {
        let mut out = String::from("<leaf>x</leaf>");
        for _ in 0..levels {
            out = format!("<Nest>{out}</Nest>");
        }
        format!(r#"<Nest xmlns="https://www.vdl.afrl.af.mil/programs/oam">{out}</Nest>"#)
    }

    // The exact boundary is not a contract; that ordinary nesting converts and
    // hostile nesting fails cleanly is.
    #[test]
    fn nesting_within_the_limit_converts() {
        let schema = recursive_schema();
        let levels = MAX_DEPTH / 2;

        let json = nested_json(levels);
        let from_json = Message::from_json(&json, &schema).expect("json within the limit");
        assert_eq!(from_json.type_hint(), "Nest");
        from_json.to_xml(&schema).expect("xml out within the limit");

        let xml = nested_xml(levels);
        let from_xml = Message::from_xml(&xml, &schema).expect("xml within the limit");
        from_xml
            .to_json(&schema)
            .expect("json out within the limit");
    }

    #[test]
    fn nesting_past_the_limit_is_refused() {
        let schema = recursive_schema();
        let levels = MAX_DEPTH + 2;

        let err = Message::from_json(&nested_json(levels), &schema).unwrap_err();
        assert!(matches!(err, UciError::TooDeep { .. }), "json: {err}");

        let err = Message::from_xml(&nested_xml(levels), &schema).unwrap_err();
        assert!(matches!(err, UciError::TooDeep { .. }), "xml: {err}");
    }

    /// The point of the limit: a document deep enough to exhaust the stack has to
    /// come back as an error, from the parser or from us, and not as a crash.
    #[test]
    fn absurdly_deep_json_fails_instead_of_aborting() {
        let schema = recursive_schema();
        assert!(Message::from_json(&nested_json(50_000), &schema).is_err());
    }

    #[test]
    fn absurdly_deep_xml_fails_instead_of_aborting() {
        let schema = recursive_schema();
        assert!(Message::from_xml(&nested_xml(50_000), &schema).is_err());
    }

    #[test]
    fn a_cyclic_extension_chain_is_reported_not_followed() {
        let schema = xsd::compile(&[r#"
            <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
              <xs:complexType name="AType">
                <xs:complexContent>
                  <xs:extension base="BType"/>
                </xs:complexContent>
              </xs:complexType>
              <xs:complexType name="BType">
                <xs:complexContent>
                  <xs:extension base="AType"/>
                </xs:complexContent>
              </xs:complexType>
            </xs:schema>
        "#])
        .expect("both bases resolve, so compiling is not where this fails");

        let err = schema.flatten("AType").unwrap_err();
        match err {
            UciError::Xsd(message) => assert!(
                message.contains("cyclic extension chain"),
                "unexpected message: {message}"
            ),
            other => panic!("expected an XSD error, got {other}"),
        }
    }

    #[test]
    fn ping_json_xml_json() {
        let src = r#"{"Ping":{"n":7}}"#;
        let msg = Message::from_json(src, schema()).unwrap();
        assert_eq!(msg.type_hint(), "Ping");
        let xml = msg.to_xml(schema()).unwrap();
        assert!(xml.contains("<Ping"));
        assert!(xml.contains("<n>7</n>"));
        let back = Message::from_xml(&xml, schema()).unwrap();
        json_eq(&back.to_json(schema()).unwrap(), src);
    }

    #[test]
    fn position_report_sleet_fixture_roundtrip() {
        let src = oa_gateway_testing::fixtures::POSITION_REPORT_JSON;
        let msg = Message::from_json(src, schema()).unwrap();
        let xml = msg.to_xml(schema()).unwrap();
        assert!(xml.contains("<OwnerProducer>"));
        let owners = match &msg.body {
            Node::Complex(c) => c.get("SecurityInformation"),
            Node::Simple(_) => None,
        };
        assert!(owners.is_some());
        let back = Message::from_xml(&xml, schema()).unwrap();
        json_eq(&back.to_json(schema()).unwrap(), src);
    }

    #[test]
    fn fixture_xml_to_json() {
        let xml = oa_gateway_testing::fixtures::POSITION_REPORT_XML;
        let msg = Message::from_xml(xml, schema()).unwrap();
        let value: Value = serde_json::from_str(&msg.to_json(schema()).unwrap()).unwrap();
        assert_eq!(
            value.pointer("/PositionReport/MessageData/n"),
            Some(&json!(1))
        );
        assert_eq!(
            value.pointer("/PositionReport/SecurityInformation/Classification"),
            Some(&json!("U"))
        );
    }

    #[test]
    fn owner_producer_is_json_array() {
        let src = r#"{
            "PositionReport": {
                "SecurityInformation": {
                    "Classification": "U",
                    "OwnerProducer": [
                        {"GovernmentIdentifier": "USA"},
                        {"GovernmentIdentifier": "GBR"}
                    ]
                }
            }
        }"#;
        let msg = Message::from_json(src, schema()).unwrap();
        let xml = msg.to_xml(schema()).unwrap();
        assert_eq!(xml.matches("<OwnerProducer>").count(), 2);
        let back: Value = serde_json::from_str(
            &Message::from_xml(&xml, schema())
                .unwrap()
                .to_json(schema())
                .unwrap(),
        )
        .unwrap();
        assert_eq!(
            back.pointer("/PositionReport/SecurityInformation/OwnerProducer")
                .and_then(Value::as_array)
                .map(Vec::len),
            Some(2)
        );
    }

    #[test]
    fn poly_sample_type_attribute() {
        let src = r#"{
            "PolySample": {
                "Detail": {
                    "$type": "InertialDetail",
                    "kind": "pos",
                    "Latitude": 1.0,
                    "Longitude": 2.0
                }
            }
        }"#;
        let msg = Message::from_json(src, schema()).unwrap();
        let xml = msg.to_xml(schema()).unwrap();
        assert!(xml.contains("xsi:type=\"InertialDetail\""), "{xml}");
        let back = Message::from_xml(&xml, schema()).unwrap();
        let json = back.to_json(schema()).unwrap();
        let v: Value = serde_json::from_str(&json).unwrap();
        assert_eq!(
            v.pointer("/PolySample/Detail/$type"),
            Some(&json!("InertialDetail"))
        );
        assert_eq!(v.pointer("/PolySample/Detail/Latitude"), Some(&json!(1.0)));
    }

    #[test]
    fn ma_rx_hex_payload_survives() {
        let src = r#"{
            "MA_RxDataPayload": {
                "MessageData": {
                    "EncodedPayload": "DEADBEEF",
                    "MessageType": "POSITION_REPORT"
                }
            }
        }"#;
        let xml = Message::from_json(src, schema())
            .unwrap()
            .to_xml(schema())
            .unwrap();
        assert!(xml.contains("<EncodedPayload>DEADBEEF</EncodedPayload>"));
        let back = Message::from_xml(&xml, schema())
            .unwrap()
            .to_json(schema())
            .unwrap();
        json_eq(&back, src);
    }
}