Skip to main content

ergo_sbe/
xsd.rs

1//! Optional SBE **XSD-shaped** structural validation (not a full W3C engine).
2//!
3//! | Item | Purpose |
4//! |------|---------|
5//! | [`SBE_XSD`] | Official FPL `sbe.xsd` text (for external tools / docs) |
6//! | [`validate_against_sbe_xsd`] | Pure-Rust checks: root, children, known attributes |
7//! | [`crate::parse_with_xsd_validation`] | Validate then [`crate::parse`] |
8//!
9//! Semantic IR rules (offsets, types, duplicates) still come from the main
10//! parser / resolver.
11//!
12//! # Relationship to [`crate::parse`]
13//!
14//! [`parse`](crate::parse) is **always on** and rejects malformed XML, a bad
15//! root, unexpected elements, and unknown attributes on its own. This module
16//! is an **opt-in, deliberately stricter** gate for schema authors; it is
17//! not a prerequisite for parsing.
18//!
19//! Attribute allow-lists are shared with the parser (one private
20//! `schema_attrs` module owns them) so the two cannot drift apart.
21//!
22//! Where the published XSD is stricter than sbe-tool itself, sbe-tool wins:
23//! the XSD marks `messageSchema/@version` `use="required"`, but upstream's own
24//! test resources omit it, so requiring it here would reject schemas the
25//! reference implementation accepts. Only `@id` is required.
26//!
27//! Vendor extensions the published XSD does not declare but sbe-tool accepts
28//! (`characterEncoding` on `<data>`, `unit` on `<type>`, `jsonValue` on
29//! `<validValue>` / `<choice>`, `package` on `<types>`) are permitted, as are
30//! all namespaced attributes (`xsi:*`, `xi:*`, and vendor namespaces such as
31//! Binance's `mbx:*`). Rejecting those would fail real-world schemas.
32
33use miette::Diagnostic;
34use thiserror::Error;
35
36/// Official FPL SBE 1.0 Draft Standard XSD, embedded for tooling / external
37/// validators. Content matches Real Logic's `sbe-tool` resource
38/// `fpl/sbe.xsd`.
39pub const SBE_XSD: &str = include_str!("xsd/sbe.xsd");
40
41/// Errors from [`validate_against_sbe_xsd`].
42#[derive(Debug, Error, Diagnostic)]
43pub enum XsdValidationError {
44    /// Input is not well-formed XML.
45    #[error("XML parse error: {0}")]
46    #[diagnostic(code(ergo_sbe::xsd::malformed))]
47    MalformedXml(String),
48
49    /// Root element is not `messageSchema`.
50    #[error("root element must be messageSchema, found `{found}`")]
51    #[diagnostic(code(ergo_sbe::xsd::bad_root))]
52    BadRoot {
53        /// Tag that was found.
54        found: String,
55    },
56
57    /// Required schema attribute is missing.
58    #[error("messageSchema is missing required attribute `{attr}`")]
59    #[diagnostic(code(ergo_sbe::xsd::missing_attr))]
60    MissingAttribute {
61        /// Attribute name.
62        attr: &'static str,
63    },
64
65    /// Element is not allowed by the SBE XSD element model.
66    #[error("element `{element}` is not allowed under `{parent}` by sbe.xsd")]
67    #[diagnostic(code(ergo_sbe::xsd::unexpected_element))]
68    UnexpectedElement {
69        /// Parent element local name.
70        parent: String,
71        /// Child element local name.
72        element: String,
73    },
74
75    /// Attribute is not recognised on this element.
76    #[error("attribute `{attr}` is not allowed on `{element}` by sbe.xsd")]
77    #[diagnostic(code(ergo_sbe::xsd::unexpected_attr))]
78    UnexpectedAttribute {
79        /// Element local name.
80        element: String,
81        /// Attribute name.
82        attr: String,
83    },
84}
85
86fn local_name(tag: &str) -> &str {
87    tag.rsplit(':').next().unwrap_or(tag)
88}
89
90/// Validate `xml` against the SBE XSD element model (structural, pure Rust).
91///
92/// This is **not** a full XSD processor. It catches schema-shape mistakes
93/// that the XSD would reject (wrong root, illegal children, unknown attrs on
94/// core elements). Semantic checks (duplicate ids, type resolution, …) remain
95/// in [`crate::parse`] / resolve.
96///
97/// # Example
98///
99/// ```rust
100/// use ergo_sbe::{validate_against_sbe_xsd, SBE_XSD};
101/// # let xml = r#"<?xml version="1.0"?><messageSchema package="t" id="1" version="0"
102/// # byteOrder="littleEndian"><types><composite name="messageHeader">
103/// # <type name="blockLength" primitiveType="uint16"/>
104/// # <type name="templateId" primitiveType="uint16"/>
105/// # <type name="schemaId" primitiveType="uint16"/>
106/// # <type name="version" primitiveType="uint16"/>
107/// # </composite></types></messageSchema>"#;
108/// // Also validates against the bundled SBE XSD:
109/// validate_against_sbe_xsd(xml)?;
110/// # Ok::<(), Box<dyn std::error::Error>>(())
111/// ```
112pub fn validate_against_sbe_xsd(xml: &str) -> Result<(), XsdValidationError> {
113    let doc = roxmltree::Document::parse(xml)
114        .map_err(|e| XsdValidationError::MalformedXml(e.to_string()))?;
115    let root = doc.root_element();
116    let root_name = local_name(root.tag_name().name());
117    if root_name != "messageSchema" {
118        return Err(XsdValidationError::BadRoot {
119            found: root_name.to_string(),
120        });
121    }
122
123    // `id` is required: without it there is no schema identity to encode.
124    //
125    // `version` is NOT required here even though the published XSD marks it
126    // `use="required"`. sbe-tool's own test resources ship schemas that omit
127    // it (e.g. `basic-schema.xml`, `new-order-single-schema.xml`), so the
128    // reference implementation does not enforce it either, and `parse`
129    // defaults it to 0. Enforcing it would reject upstream-mirrored fixtures
130    // — a validator stricter than both the parser and the reference tool only
131    // produces false positives.
132    if root.attribute("id").is_none() {
133        return Err(XsdValidationError::MissingAttribute { attr: "id" });
134    }
135    check_attrs("messageSchema", root, crate::schema_attrs::MESSAGE_SCHEMA)?;
136
137    for child in root.children().filter(|n| n.is_element()) {
138        let name = local_name(child.tag_name().name());
139        match name {
140            "types" => validate_types(child)?,
141            "message" => validate_message(child)?,
142            // XInclude is outside the stock XSD but supported by both tools.
143            "include" => {}
144            other => {
145                return Err(XsdValidationError::UnexpectedElement {
146                    parent: "messageSchema".into(),
147                    element: other.into(),
148                });
149            }
150        }
151    }
152    Ok(())
153}
154
155fn check_attrs(
156    element: &str,
157    node: roxmltree::Node<'_, '_>,
158    allowed: &[&str],
159) -> Result<(), XsdValidationError> {
160    for attr in node.attributes() {
161        // Any namespaced attribute is outside the SBE grammar — `xsi:*`,
162        // `xi:*`, and vendor extensions alike (Binance ships `mbx:exponent`).
163        // Note `attr.name()` is the LOCAL name, so a `contains(':')` test
164        // never fires; the namespace is what identifies these.
165        if attr.namespace().is_some() {
166            continue;
167        }
168        let name = local_name(attr.name());
169        if name.starts_with("xmlns") {
170            continue;
171        }
172        if !allowed.contains(&name) {
173            return Err(XsdValidationError::UnexpectedAttribute {
174                element: element.into(),
175                attr: name.into(),
176            });
177        }
178    }
179    Ok(())
180}
181
182fn validate_types(node: roxmltree::Node<'_, '_>) -> Result<(), XsdValidationError> {
183    // `package` on <types> is not in the published XSD but sbe-tool emits and
184    // accepts it (it scopes generated types for that block).
185    check_attrs("types", node, &["package"])?;
186    for child in node.children().filter(|n| n.is_element()) {
187        let name = local_name(child.tag_name().name());
188        match name {
189            "type" => check_attrs(
190                "type",
191                child,
192                &[
193                    "name",
194                    "primitiveType",
195                    "length",
196                    "presence",
197                    "nullValue",
198                    "minValue",
199                    "maxValue",
200                    "characterEncoding",
201                    "epoch",
202                    "timeUnit",
203                    // `unit` is an sbe-tool extension carried by real schemas.
204                    "unit",
205                    "semanticType",
206                    "description",
207                    "sinceVersion",
208                    "deprecated",
209                    "offset",
210                    "valueRef",
211                ],
212            )?,
213            "composite" => validate_composite(child)?,
214            "enum" => validate_enum(child)?,
215            "set" => validate_set(child)?,
216            other => {
217                return Err(XsdValidationError::UnexpectedElement {
218                    parent: "types".into(),
219                    element: other.into(),
220                });
221            }
222        }
223    }
224    Ok(())
225}
226
227fn validate_composite(node: roxmltree::Node<'_, '_>) -> Result<(), XsdValidationError> {
228    check_attrs(
229        "composite",
230        node,
231        &[
232            "name",
233            "description",
234            "semanticType",
235            "sinceVersion",
236            "deprecated",
237            "offset",
238        ],
239    )?;
240    for child in node.children().filter(|n| n.is_element()) {
241        let name = local_name(child.tag_name().name());
242        match name {
243            "type" | "enum" | "set" | "ref" | "composite" => {}
244            "description" | "comment" => {}
245            other => {
246                return Err(XsdValidationError::UnexpectedElement {
247                    parent: "composite".into(),
248                    element: other.into(),
249                });
250            }
251        }
252    }
253    Ok(())
254}
255
256fn validate_enum(node: roxmltree::Node<'_, '_>) -> Result<(), XsdValidationError> {
257    check_attrs("enum", node, crate::schema_attrs::ENUM)?;
258    for child in node.children().filter(|n| n.is_element()) {
259        let name = local_name(child.tag_name().name());
260        match name {
261            "validValue" => check_attrs(
262                "validValue",
263                child,
264                // `jsonValue` is an sbe-tool extension used by real schemas.
265                &[
266                    "name",
267                    "description",
268                    "sinceVersion",
269                    "deprecated",
270                    "jsonValue",
271                ],
272            )?,
273            "description" | "comment" => {}
274            other => {
275                return Err(XsdValidationError::UnexpectedElement {
276                    parent: "enum".into(),
277                    element: other.into(),
278                });
279            }
280        }
281    }
282    Ok(())
283}
284
285fn validate_set(node: roxmltree::Node<'_, '_>) -> Result<(), XsdValidationError> {
286    check_attrs(
287        "set",
288        node,
289        &[
290            "name",
291            "encodingType",
292            "description",
293            "sinceVersion",
294            "deprecated",
295            "semanticType",
296        ],
297    )?;
298    for child in node.children().filter(|n| n.is_element()) {
299        let name = local_name(child.tag_name().name());
300        match name {
301            "choice" => check_attrs(
302                "choice",
303                child,
304                // `jsonValue` is an sbe-tool extension used by real schemas.
305                &[
306                    "name",
307                    "description",
308                    "sinceVersion",
309                    "deprecated",
310                    "jsonValue",
311                ],
312            )?,
313            "description" | "comment" => {}
314            other => {
315                return Err(XsdValidationError::UnexpectedElement {
316                    parent: "set".into(),
317                    element: other.into(),
318                });
319            }
320        }
321    }
322    Ok(())
323}
324
325fn validate_message(node: roxmltree::Node<'_, '_>) -> Result<(), XsdValidationError> {
326    check_attrs("message", node, crate::schema_attrs::MESSAGE)?;
327    for child in node.children().filter(|n| n.is_element()) {
328        let name = local_name(child.tag_name().name());
329        match name {
330            "field" => check_attrs("field", child, crate::schema_attrs::FIELD_LIKE)?,
331            "group" => validate_group(child)?,
332            "data" => check_attrs("data", child, crate::schema_attrs::FIELD_LIKE)?,
333            "description" | "comment" => {}
334            other => {
335                return Err(XsdValidationError::UnexpectedElement {
336                    parent: "message".into(),
337                    element: other.into(),
338                });
339            }
340        }
341    }
342    Ok(())
343}
344
345fn validate_group(node: roxmltree::Node<'_, '_>) -> Result<(), XsdValidationError> {
346    check_attrs("group", node, crate::schema_attrs::GROUP)?;
347    for child in node.children().filter(|n| n.is_element()) {
348        let name = local_name(child.tag_name().name());
349        match name {
350            "field" | "group" | "data" | "description" | "comment" => {}
351            other => {
352                return Err(XsdValidationError::UnexpectedElement {
353                    parent: "group".into(),
354                    element: other.into(),
355                });
356            }
357        }
358    }
359    Ok(())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn accepts_minimal_valid_schema() -> Result<(), Box<dyn std::error::Error>> {
368        let xml = r#"<?xml version="1.0"?>
369        <messageSchema package="t" id="1" version="0" byteOrder="littleEndian">
370          <types>
371            <composite name="messageHeader">
372              <type name="blockLength" primitiveType="uint16"/>
373              <type name="templateId" primitiveType="uint16"/>
374              <type name="schemaId" primitiveType="uint16"/>
375              <type name="version" primitiveType="uint16"/>
376            </composite>
377            <type name="u32" primitiveType="uint32"/>
378          </types>
379          <message name="M" id="1">
380            <field name="x" id="1" type="u32"/>
381          </message>
382        </messageSchema>"#;
383        validate_against_sbe_xsd(xml)?;
384        Ok(())
385    }
386
387    #[test]
388    fn rejects_bad_root() {
389        let xml = r#"<?xml version="1.0"?><notSchema id="1" version="0"/>"#;
390        assert!(matches!(
391            validate_against_sbe_xsd(xml),
392            Err(XsdValidationError::BadRoot { .. })
393        ));
394    }
395
396    #[test]
397    fn rejects_unknown_message_child() {
398        let xml = r#"<?xml version="1.0"?>
399        <messageSchema id="1" version="0">
400          <types/>
401          <message name="M" id="1">
402            <notAField name="x"/>
403          </message>
404        </messageSchema>"#;
405        assert!(matches!(
406            validate_against_sbe_xsd(xml),
407            Err(XsdValidationError::UnexpectedElement { .. })
408        ));
409    }
410
411    #[test]
412    fn accepts_enum_set_group_and_var_data_shapes() -> Result<(), Box<dyn std::error::Error>> {
413        let xml = r#"<?xml version="1.0"?>
414        <messageSchema package="t" id="1" version="0">
415          <types>
416            <composite name="messageHeader">
417              <type name="blockLength" primitiveType="uint16"/>
418              <type name="templateId" primitiveType="uint16"/>
419              <type name="schemaId" primitiveType="uint16"/>
420              <type name="version" primitiveType="uint16"/>
421            </composite>
422            <composite name="groupSizeEncoding">
423              <type name="blockLength" primitiveType="uint16"/>
424              <type name="numInGroup" primitiveType="uint16"/>
425            </composite>
426            <composite name="varStringEncoding">
427              <type name="length" primitiveType="uint32"/>
428              <type name="varData" primitiveType="uint8" length="0"/>
429            </composite>
430            <enum name="Side" encodingType="uint8">
431              <validValue name="Buy">1</validValue>
432              <validValue name="Sell">2</validValue>
433            </enum>
434            <set name="Flags" encodingType="uint8">
435              <choice name="Firm">0</choice>
436            </set>
437          </types>
438          <message name="Order" id="1">
439            <field name="side" id="1" type="Side"/>
440            <group name="fills" id="2" dimensionType="groupSizeEncoding">
441              <field name="quantity" id="3" type="uint32"/>
442              <data name="venue" id="4" type="varStringEncoding"/>
443            </group>
444            <data name="account" id="5" type="varStringEncoding"/>
445          </message>
446        </messageSchema>"#;
447
448        validate_against_sbe_xsd(xml)?;
449        Ok(())
450    }
451
452    #[test]
453    fn rejects_malformed_missing_attributes_and_unknown_type_shapes() {
454        type ValidationCase<'a> = (&'a str, &'a str, fn(&XsdValidationError) -> bool);
455        let cases: [ValidationCase<'_>; 4] = [
456            (
457                "<messageSchema",
458                "malformed XML",
459                |error: &XsdValidationError| matches!(error, XsdValidationError::MalformedXml(_)),
460            ),
461            (
462                r#"<messageSchema package="t" version="0"/>"#,
463                "missing schema id",
464                |error: &XsdValidationError| {
465                    matches!(error, XsdValidationError::MissingAttribute { attr: "id" })
466                },
467            ),
468            (
469                r#"<messageSchema package="t" id="1" version="0" surprise="yes"/>"#,
470                "unknown root attribute",
471                |error: &XsdValidationError| {
472                    matches!(error, XsdValidationError::UnexpectedAttribute { .. })
473                },
474            ),
475            (
476                r#"<messageSchema package="t" id="1" version="0"><types><unknown/></types></messageSchema>"#,
477                "unknown type element",
478                |error: &XsdValidationError| {
479                    matches!(error, XsdValidationError::UnexpectedElement { .. })
480                },
481            ),
482        ];
483
484        for (xml, context, predicate) in cases {
485            let result = validate_against_sbe_xsd(xml);
486            assert!(
487                result.as_ref().is_err_and(predicate),
488                "{context}: {result:?}"
489            );
490        }
491    }
492
493    #[test]
494    fn embedded_xsd_is_present() {
495        assert!(SBE_XSD.contains("messageSchema"));
496        assert!(SBE_XSD.contains("xs:schema"));
497    }
498
499    fn enum_null_schema(encoding: &str, null_value: &str) -> String {
500        format!(
501            r#"<?xml version="1.0"?>
502            <messageSchema package="t" id="1" version="0" byteOrder="littleEndian">
503              <types>
504                <composite name="messageHeader">
505                  <type name="blockLength" primitiveType="uint16"/>
506                  <type name="templateId" primitiveType="uint16"/>
507                  <type name="schemaId" primitiveType="uint16"/>
508                  <type name="version" primitiveType="uint16"/>
509                </composite>
510                <enum name="Code" encodingType="{encoding}" nullValue="{null_value}">
511                  <validValue name="Ok">0</validValue>
512                </enum>
513              </types>
514              <message name="M" id="1">
515                <field name="code" id="1" type="Code"/>
516              </message>
517            </messageSchema>"#
518        )
519    }
520
521    #[test]
522    fn accepts_unsigned_enum_null_value() -> Result<(), Box<dyn std::error::Error>> {
523        let xml = enum_null_schema("uint8", "99");
524        validate_against_sbe_xsd(&xml)?;
525        crate::parse_with_xsd_validation(&xml)?;
526        Ok(())
527    }
528
529    #[test]
530    fn accepts_signed_enum_null_value() -> Result<(), Box<dyn std::error::Error>> {
531        let xml = enum_null_schema("int8", "-1");
532        validate_against_sbe_xsd(&xml)?;
533        crate::parse_with_xsd_validation(&xml)?;
534        Ok(())
535    }
536
537    #[test]
538    fn rejects_unknown_non_namespaced_enum_attribute() {
539        let xml = r#"<?xml version="1.0"?>
540        <messageSchema package="t" id="1" version="0">
541          <types>
542            <enum name="Code" encodingType="uint8" surprise="yes">
543              <validValue name="Ok">0</validValue>
544            </enum>
545          </types>
546        </messageSchema>"#;
547        assert!(matches!(
548            validate_against_sbe_xsd(xml),
549            Err(XsdValidationError::UnexpectedAttribute {
550                element,
551                attr
552            }) if element == "enum" && attr == "surprise"
553        ));
554    }
555}