fastxml 0.11.0

A fast, memory-efficient XML library with XPath and XSD validation support
Documentation
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
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
//! Post-compilation schema validity checks (schema-for-schemas constraints
//! that need resolved types): facet applicability per primitive value space,
//! facet bounds consistency, and enumeration value validity.

use crate::error::Result;
use crate::schema::error::SchemaError;
use crate::schema::types::{CompiledSchema, SimpleType, TypeDef};
use crate::schema::xsd::primitive::PrimitiveKind;
use crate::schema::xsd::value_compare::compare_values;

/// Checks every compiled type against schema-for-schemas constraints.
pub(crate) fn check_schema_validity(schema: &CompiledSchema) -> Result<()> {
    for (ns_name, type_def) in &schema.types_ns {
        let name = schema.display_name(ns_name);
        match type_def {
            TypeDef::Simple(st) => check_simple_type(schema, &name, st)?,
            TypeDef::Complex(ct) => {
                check_complex_restriction(schema, &name, ct)?;
                if let Some(elements) = content_elements(&ct.content) {
                    for elem in elements {
                        check_value_constraints(schema, elem)?;
                    }
                }
                for attr in &ct.attributes {
                    check_attr_id_value_constraint(schema, attr)?;
                }
            }
        }
    }
    for elem in schema.elements_ns.values() {
        check_value_constraints(schema, elem)?;
    }
    for attr in schema.attributes_ns.values() {
        check_attr_id_value_constraint(schema, attr)?;
    }
    Ok(())
}

/// Schema Component Constraint: Attribute Declaration Properties Correct (§3.2.6
/// clause 4) — an attribute whose type is (or derives from) `xs:ID` must not
/// carry a value constraint (`default`/`fixed`).
fn check_attr_id_value_constraint(
    schema: &CompiledSchema,
    attr: &crate::schema::types::AttributeDef,
) -> Result<()> {
    if attr.default.is_none() && attr.fixed.is_none() {
        return Ok(());
    }
    if attr_primitive_kind(schema, attr) == Some(PrimitiveKind::Id) {
        return Err(invalid(format!(
            "attribute '{}': an xs:ID-typed attribute must not have a default or fixed value",
            attr.name
        )));
    }
    Ok(())
}

/// Resolves an attribute declaration's built-in primitive kind, following user
/// simple-type derivations and (for a direct XSD-namespace reference not
/// materialised in the type table) the built-in type name.
fn attr_primitive_kind(
    schema: &CompiledSchema,
    attr: &crate::schema::types::AttributeDef,
) -> Option<PrimitiveKind> {
    if let Some(inline) = &attr.inline_type {
        return PrimitiveKind::resolve(schema, inline);
    }
    let type_ref = attr.type_ref.as_deref()?;
    if let Some(TypeDef::Simple(st)) = schema.type_by_ref(attr.type_ns.as_ref(), type_ref)
        && let Some(kind) = PrimitiveKind::resolve(schema, st)
    {
        return Some(kind);
    }
    // A direct built-in reference (e.g. `xs:ID`) may not be stored as a
    // SimpleType; map its XSD-namespace local name to the primitive kind.
    match &attr.type_ns {
        Some(ns) if &*ns.namespace_uri == crate::schema::xsd::builtin::XS_NAMESPACE => {
            PrimitiveKind::from_type_name(&ns.local_name)
        }
        _ => None,
    }
}

/// An element's default/fixed value must be a valid instance of its type.
fn check_value_constraints(
    schema: &CompiledSchema,
    elem: &crate::schema::types::ElementDef,
) -> Result<()> {
    let Some(value) = elem.default.as_deref().or(elem.fixed.as_deref()) else {
        return Ok(());
    };
    // C4: ns-first (compile-time resolved), string fallback.
    let simple = match elem
        .type_ref
        .as_deref()
        .and_then(|t| schema.type_by_ref(elem.type_ns.as_ref(), t))
    {
        Some(TypeDef::Simple(st)) => st,
        _ => return Ok(()),
    };
    if let Some(kind) = PrimitiveKind::resolve(schema, simple)
        && kind.validate(value).is_err()
    {
        return Err(invalid(format!(
            "element '{}': default/fixed value '{}' is not valid for its type",
            elem.name, value
        )));
    }
    Ok(())
}

/// Pragmatic particle-restriction legality (a subset of the rcase-* rules):
/// a complex type derived by restriction must accept a subset of its base.
fn check_complex_restriction(
    schema: &CompiledSchema,
    name: &str,
    ct: &crate::schema::types::ComplexType,
) -> Result<()> {
    use crate::schema::types::{ContentModel, ProcessContents, WildcardNamespace};

    if ct.derivation != Some(crate::schema::types::DerivationMethod::Restriction) {
        return Ok(());
    }
    let Some(base_name) = ct.base_type.as_deref() else {
        return Ok(());
    };
    // C4: ns-first (compile-time resolved base_ns), string fallback.
    let Some(TypeDef::Complex(base)) = schema.type_by_ref(ct.base_ns.as_ref(), base_name) else {
        return Ok(());
    };
    // Self-reference via placeholder or unrelated lookup
    if std::ptr::eq(base, ct) {
        return Ok(());
    }

    // Wildcard rules: a restriction cannot introduce a wildcard, weaken its
    // processContents, or widen its namespace constraint.
    if let Some(ref dw) = ct.wildcard {
        match &base.wildcard {
            None => {
                // Only flag when the base genuinely has element content
                // (an empty base could not legally be restricted anyway).
                if !matches!(base.content, ContentModel::Empty) {
                    return Err(invalid(format!(
                        "type '{}': restriction introduces a wildcard not present in base '{}'",
                        name, base_name
                    )));
                }
            }
            Some(bw) => {
                let strength = |pc: ProcessContents| match pc {
                    ProcessContents::Strict => 3u8,
                    ProcessContents::Lax => 2,
                    ProcessContents::Skip => 1,
                };
                if strength(dw.process_contents) < strength(bw.process_contents) {
                    return Err(invalid(format!(
                        "type '{}': restriction weakens wildcard processContents of base '{}'",
                        name, base_name
                    )));
                }
                let subset = match (&dw.namespace, &bw.namespace) {
                    (_, WildcardNamespace::Any) => true,
                    (WildcardNamespace::Any, _) => false,
                    (WildcardNamespace::Other, WildcardNamespace::Other) => {
                        dw.target_namespace == bw.target_namespace
                    }
                    (WildcardNamespace::List(d), WildcardNamespace::List(b)) => {
                        d.iter().all(|u| b.contains(u))
                    }
                    (WildcardNamespace::List(d), WildcardNamespace::Other) => d.iter().all(|u| {
                        !u.is_empty() && Some(u.as_str()) != bw.target_namespace.as_deref()
                    }),
                    (WildcardNamespace::Other, WildcardNamespace::List(_)) => false,
                };
                if !subset {
                    return Err(invalid(format!(
                        "type '{}': restriction widens wildcard namespace of base '{}'",
                        name, base_name
                    )));
                }
            }
        }
    }

    // Element rules on the flattened content models.
    let derived_elems = content_elements(&ct.content);
    let base_elems = content_elements(&base.content);
    let (Some(derived_elems), Some(base_elems)) = (derived_elems, base_elems) else {
        return Ok(());
    };

    for d in derived_elems {
        match base_elems.iter().find(|b| b.name == d.name) {
            None => {
                // A substitution-group member may stand in for its head.
                let substitutes_into_base = {
                    let mut current = d.name.as_str();
                    let mut found = false;
                    for _ in 0..16 {
                        // Heads may be registered under qualified or local
                        // names; try both at every hop.
                        let head = schema.substitution_group_heads.get(current).or_else(|| {
                            let local = current.rsplit(':').next().unwrap_or(current);
                            schema.substitution_group_heads.get(local)
                        });
                        match head {
                            Some(head) => {
                                let head_local = head.rsplit(':').next().unwrap_or(head);
                                if base_elems.iter().any(|b| {
                                    b.name.rsplit(':').next().unwrap_or(&b.name) == head_local
                                }) {
                                    found = true;
                                    break;
                                }
                                current = head;
                            }
                            None => break,
                        }
                    }
                    found
                };
                // An element reference we cannot resolve at all most
                // likely lives in an unresolved import; don't reject the
                // schema based on incomplete knowledge.
                let unknown_ref = schema.get_element(&d.name).is_none();
                if base.wildcard.is_none() && !substitutes_into_base && !unknown_ref {
                    return Err(invalid(format!(
                        "type '{}': restriction adds element '{}' not present in base '{}'",
                        name, d.name, base_name
                    )));
                }
            }
            // A base wildcard can absorb additional occurrences, so the
            // pairwise occurrence comparison only holds without one.
            Some(b) if base.wildcard.is_none() => {
                // Occurrence range must narrow, never widen. Flattening
                // turns choice members into min=0, so only compare minima
                // when both content models are sequences.
                let both_sequences = matches!(ct.content, ContentModel::Sequence(_))
                    && matches!(base.content, ContentModel::Sequence(_));
                if both_sequences && d.min_occurs < b.min_occurs {
                    return Err(invalid(format!(
                        "type '{}': restriction lowers minOccurs of element '{}' below base '{}'",
                        name, d.name, base_name
                    )));
                }
                let widened = match (d.max_occurs, b.max_occurs) {
                    (None, Some(_)) => true,
                    (Some(dm), Some(bm)) => dm > bm,
                    _ => false,
                };
                if widened {
                    return Err(invalid(format!(
                        "type '{}': restriction raises maxOccurs of element '{}' above base '{}'",
                        name, d.name, base_name
                    )));
                }
            }
            Some(_) => {}
        }
    }

    Ok(())
}

/// The immediate flattened element list of a content model, when it has one.
fn content_elements(
    content: &crate::schema::types::ContentModel,
) -> Option<&[crate::schema::types::ElementDef]> {
    use crate::schema::types::ContentModel;
    match content {
        ContentModel::Sequence(e) | ContentModel::Choice(e) | ContentModel::All(e) => Some(e),
        _ => None,
    }
}

fn invalid(message: String) -> crate::error::Error {
    SchemaError::InvalidSchema { message }.into()
}

fn check_simple_type(schema: &CompiledSchema, name: &str, st: &SimpleType) -> Result<()> {
    let kind = PrimitiveKind::resolve(schema, st);
    let is_list = st.item_type.is_some();

    // Facet applicability. `None` covers both the string family and
    // unresolved bases; flag nothing there to avoid false rejections.
    if let Some(kind) = kind
        && !is_list
    {
        let length_ok = matches!(
            kind,
            PrimitiveKind::HexBinary
                | PrimitiveKind::Base64Binary
                | PrimitiveKind::AnyUri
                | PrimitiveKind::QName
                | PrimitiveKind::Name
                | PrimitiveKind::Ncname
                | PrimitiveKind::Nmtoken
                | PrimitiveKind::Language
                | PrimitiveKind::Id
                | PrimitiveKind::Idref
                | PrimitiveKind::Entity
        );
        if !length_ok && (st.length.is_some() || st.min_length.is_some() || st.max_length.is_some())
        {
            return Err(invalid(format!(
                "type '{}': length facets are not applicable to its base type",
                name
            )));
        }

        let ordered = !matches!(
            kind,
            PrimitiveKind::Boolean
                | PrimitiveKind::HexBinary
                | PrimitiveKind::Base64Binary
                | PrimitiveKind::AnyUri
                | PrimitiveKind::QName
                | PrimitiveKind::Name
                | PrimitiveKind::Ncname
                | PrimitiveKind::Nmtoken
                | PrimitiveKind::Language
                | PrimitiveKind::Id
                | PrimitiveKind::Idref
                | PrimitiveKind::Entity
        );
        if !ordered
            && (st.min_inclusive.is_some()
                || st.max_inclusive.is_some()
                || st.min_exclusive.is_some()
                || st.max_exclusive.is_some())
        {
            return Err(invalid(format!(
                "type '{}': range facets are not applicable to its base type",
                name
            )));
        }

        let decimal_family = matches!(
            kind,
            PrimitiveKind::Decimal
                | PrimitiveKind::Integer
                | PrimitiveKind::Long
                | PrimitiveKind::Int
                | PrimitiveKind::Short
                | PrimitiveKind::Byte
                | PrimitiveKind::NonNegativeInteger
                | PrimitiveKind::PositiveInteger
                | PrimitiveKind::NonPositiveInteger
                | PrimitiveKind::NegativeInteger
                | PrimitiveKind::UnsignedLong
                | PrimitiveKind::UnsignedInt
                | PrimitiveKind::UnsignedShort
                | PrimitiveKind::UnsignedByte
        );
        if !decimal_family && (st.total_digits.is_some() || st.fraction_digits.is_some()) {
            return Err(invalid(format!(
                "type '{}': digit facets are not applicable to its base type",
                name
            )));
        }

        // Enumeration values must be valid instances of the base type.
        for value in &st.enumeration {
            if kind.validate(value).is_err() {
                return Err(invalid(format!(
                    "type '{}': enumeration value '{}' is not valid for its base type",
                    name, value
                )));
            }
        }

        // Range facet values themselves must be valid for the base type.
        for (facet, value) in [
            ("minInclusive", &st.min_inclusive),
            ("maxInclusive", &st.max_inclusive),
            ("minExclusive", &st.min_exclusive),
            ("maxExclusive", &st.max_exclusive),
        ] {
            if let Some(v) = value
                && kind.validate(v).is_err()
            {
                return Err(invalid(format!(
                    "type '{}': {} value '{}' is not valid for its base type",
                    name, facet, v
                )));
            }
        }
    }

    // Mutually exclusive bounds.
    if st.min_inclusive.is_some() && st.min_exclusive.is_some() {
        return Err(invalid(format!(
            "type '{}': minInclusive and minExclusive cannot both be present",
            name
        )));
    }
    if st.max_inclusive.is_some() && st.max_exclusive.is_some() {
        return Err(invalid(format!(
            "type '{}': maxInclusive and maxExclusive cannot both be present",
            name
        )));
    }

    // Bound ordering, compared in the type's value space.
    if let (Some(min), Some(max)) = (&st.min_inclusive, &st.max_inclusive)
        && compare_values(kind, min, max) == Some(std::cmp::Ordering::Greater)
    {
        return Err(invalid(format!(
            "type '{}': minInclusive '{}' is greater than maxInclusive '{}'",
            name, min, max
        )));
    }
    if let (Some(min), Some(max)) = (&st.min_exclusive, &st.max_exclusive)
        && compare_values(kind, min, max) == Some(std::cmp::Ordering::Greater)
    {
        return Err(invalid(format!(
            "type '{}': minExclusive '{}' is greater than maxExclusive '{}'",
            name, min, max
        )));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use crate::schema::xsd::parse_xsd;

    fn schema_of(body: &str) -> crate::error::Result<crate::schema::types::CompiledSchema> {
        let xsd = format!(
            r#"<?xml version="1.0"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">{}</xs:schema>"#,
            body
        );
        parse_xsd(xsd.as_bytes())
    }

    #[test]
    fn rejects_enum_invalid_for_base() {
        let result = schema_of(
            r#"<xs:simpleType name="t"><xs:restriction base="xs:integer">
                 <xs:enumeration value="10"/><xs:enumeration value="CA"/>
               </xs:restriction></xs:simpleType>"#,
        );
        assert!(result.is_err());
    }

    #[test]
    fn rejects_length_on_numeric() {
        let result = schema_of(
            r#"<xs:simpleType name="t"><xs:restriction base="xs:decimal">
                 <xs:maxLength value="5"/>
               </xs:restriction></xs:simpleType>"#,
        );
        assert!(result.is_err());
    }

    #[test]
    fn rejects_range_on_boolean() {
        let result = schema_of(
            r#"<xs:simpleType name="t"><xs:restriction base="xs:boolean">
                 <xs:minInclusive value="0"/>
               </xs:restriction></xs:simpleType>"#,
        );
        assert!(result.is_err());
    }

    #[test]
    fn rejects_inverted_bounds() {
        let result = schema_of(
            r#"<xs:simpleType name="t"><xs:restriction base="xs:int">
                 <xs:minInclusive value="10"/><xs:maxInclusive value="5"/>
               </xs:restriction></xs:simpleType>"#,
        );
        assert!(result.is_err());
    }

    #[test]
    fn rejects_id_attribute_with_fixed() {
        // §3.2.6 clause 4: an ID-typed attribute may not have a value
        // constraint. Covers a direct built-in reference and a user type
        // derived from xs:ID.
        for ty in [r#"type="xs:ID""#, r#"type="idlike""#] {
            let result = schema_of(&format!(
                r#"<xs:simpleType name="idlike"><xs:restriction base="xs:ID"/></xs:simpleType>
                   <xs:complexType name="ct"><xs:attribute name="a" {ty} fixed="abc"/></xs:complexType>"#,
            ));
            assert!(result.is_err(), "should reject ID attr with fixed ({ty})");
        }
    }

    #[test]
    fn rejects_global_id_attribute_with_default() {
        let result = schema_of(r#"<xs:attribute name="a" type="xs:ID" default="x"/>"#);
        assert!(result.is_err());
    }

    #[test]
    fn accepts_id_attribute_without_value_constraint() {
        let result = schema_of(
            r#"<xs:complexType name="ct"><xs:attribute name="a" type="xs:ID"/></xs:complexType>"#,
        );
        assert!(result.is_ok(), "{result:?}");
    }

    #[test]
    fn accepts_valid_restrictions() {
        let result = schema_of(
            r#"<xs:simpleType name="t"><xs:restriction base="xs:string">
                 <xs:maxLength value="5"/><xs:enumeration value="ab"/>
               </xs:restriction></xs:simpleType>
               <xs:simpleType name="n"><xs:restriction base="xs:int">
                 <xs:minInclusive value="1"/><xs:maxInclusive value="10"/>
               </xs:restriction></xs:simpleType>"#,
        );
        assert!(result.is_ok(), "{result:?}");
    }
}