Skip to main content

cadmpeg_codec_step/
codec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! STEP codec backend and encoder.
3
4use cadmpeg_core::container::{ContainerRole, EntryStorage, VerbatimLabel};
5
6use std::collections::{btree_map::Entry, BTreeMap};
7use std::fmt;
8
9use cadmpeg_core::decode::DecodeContext;
10use cadmpeg_core::dialect::{DialectLayers, DialectMatch};
11use cadmpeg_core::{CodecError, ContainerEntry};
12use cadmpeg_ir::codec::write::{
13    target::{Catalog, ResolvedWrite},
14    EncodeInput, EncoderBackend, ExportBody,
15};
16use cadmpeg_ir::codec::{CodecBackend, Confidence, Decoded, FormatId};
17use cadmpeg_ir::report::loss::LossNote;
18use cadmpeg_ir::ContainerSummary;
19
20use crate::archive;
21use crate::dialect::refuse_alternate_encoding;
22use crate::options::{StepSchema, StepWriteOptions};
23use crate::parse;
24use crate::reader;
25
26/// STEP encoder with per-export header options.
27#[derive(Debug, Clone, Default)]
28pub struct StepCodec {
29    /// Header metadata and deterministic writer options.
30    pub options: StepWriteOptions,
31}
32
33impl EncoderBackend for StepCodec {
34    const FORMAT: FormatId = <Self as CodecBackend>::FORMAT;
35    type Target = Catalog;
36    const TARGET: Catalog = Catalog::new(StepSchema::TARGETS, Some(2));
37
38    /// Synthesis-only encoder. An off-catalog STEP source cannot be reproduced
39    /// because every emitted schema stamps object-identifier arcs.
40    fn plan_resolved(
41        &self,
42        input: EncodeInput<'_>,
43        target: ResolvedWrite<'_>,
44    ) -> Result<ExportBody, CodecError> {
45        crate::writer::target::plan(self, input, &target)
46    }
47}
48
49impl CodecBackend for StepCodec {
50    const FORMAT: FormatId = FormatId::new(crate::dialect::FORMAT);
51
52    fn detect_impl(
53        &self,
54        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
55        prefix: cadmpeg_core::decode::View<'_>,
56    ) -> Result<Confidence, cadmpeg_core::CodecError> {
57        let view = prefix;
58        let prefix = prefix.window();
59        ctx.charge_work(
60            cadmpeg_core::decode::u64_from_index(prefix.len()),
61            "detect STEP trivia",
62        )?;
63        if starts_with_step_magic(prefix) {
64            return Ok(Confidence::High);
65        }
66        if archive::has_root_marker(ctx, view)? {
67            return Ok(Confidence::Medium);
68        }
69        ctx.charge_work(
70            cadmpeg_core::decode::u64_from_index(prefix.len()),
71            "detect STEP HDF5",
72        )?;
73        if is_part26_hdf5(prefix) {
74            return Ok(Confidence::Medium);
75        }
76        let xml_bytes = cadmpeg_core::decode::u64_from_index(prefix.len().min(4096));
77        ctx.charge_work(xml_bytes * 6, "detect STEP Part 28 XML")?;
78        if is_part28_xml(prefix) {
79            return Ok(Confidence::Medium);
80        }
81        ctx.charge_work(xml_bytes * 5, "detect STEP business-object XML")?;
82        if is_ap242_bo_model_xml(prefix) {
83            return Ok(Confidence::Medium);
84        }
85        ctx.charge_work(
86            cadmpeg_core::decode::u64_from_index(prefix.len().min(4)),
87            "detect STEP ZIP",
88        )?;
89        Ok(if archive::has_zip_magic(prefix) {
90            Confidence::Low
91        } else {
92            Confidence::No
93        })
94    }
95
96    fn inspect_impl(
97        &self,
98        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
99        root: cadmpeg_core::decode::View<'_>,
100    ) -> Result<ContainerSummary, CodecError> {
101        let bytes = root.window();
102        if archive::has_zip_magic(bytes)
103            || (!starts_with_step_magic(bytes)
104                && cadmpeg_container::ArchiveSnapshot::has_footer(ctx, bytes)?)
105        {
106            return inspect_zip(ctx, root);
107        }
108        let inspected = inspect_exchange(self, ctx, root)?;
109        Ok(ContainerSummary::classified(
110            DialectLayers::of(inspected.matched),
111            cadmpeg_ir::ContainerKind::Iso10303ClearText,
112            inspected.entries,
113            inspected.losses,
114            inspected.notes,
115        ))
116    }
117
118    fn decode_impl(
119        &self,
120        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
121        root: cadmpeg_core::decode::View<'_>,
122    ) -> Result<Decoded, CodecError> {
123        let bytes = root.window();
124        if archive::has_zip_magic(bytes)
125            || (!starts_with_step_magic(bytes)
126                && cadmpeg_container::ArchiveSnapshot::has_footer(ctx, bytes)?)
127        {
128            return decode_zip(ctx, root);
129        }
130        refuse_alternate_encoding(bytes)?;
131        if self.detect_impl(ctx, root)? == Confidence::No {
132            return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
133        }
134        reader::decode(bytes, ctx, reader::Packaging::Bare)
135    }
136}
137
138/// One Part 21 exchange as `inspect` sees it: its single classification and
139/// the container facts every packaging of it reports.
140struct InspectedExchange {
141    matched: DialectMatch,
142    entries: Vec<ContainerEntry>,
143    losses: Vec<LossNote>,
144    notes: Vec<String>,
145}
146
147fn insert_attribute(
148    ctx: &DecodeContext<'_>,
149    attributes: &mut BTreeMap<String, String>,
150    key: &'static str,
151    value: String,
152) -> Result<(), CodecError> {
153    ctx.charge_work(
154        cadmpeg_core::decode::u64_from_index(key.len()),
155        "step_inspect_attribute_key",
156    )?;
157    let key = ctx.copy_retained_text(key, "step_inspect_attribute_key")?;
158    ctx.insert_btree_map(attributes, key, value, "step_inspect_attributes")?;
159    Ok(())
160}
161
162struct CountItem<'a> {
163    name: &'a str,
164    count: usize,
165}
166
167impl fmt::Display for CountItem<'_> {
168    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
169        write!(output, "{}:{}", self.name, self.count)
170    }
171}
172
173/// Deep semantic analysis of a STEP exchange, exposed as container inspect.
174///
175/// Runs the semantic decode path to populate `unknown_entities` and related
176/// attributes. Not a cheap syntactic census; see `docs/formats/step-inspect.md`.
177fn inspect_exchange(
178    codec: &StepCodec,
179    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
180    root: cadmpeg_core::decode::View<'_>,
181) -> Result<InspectedExchange, CodecError> {
182    let bytes = root.window();
183    refuse_alternate_encoding(bytes)?;
184    if codec.detect_impl(ctx, root)? == Confidence::No {
185        return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
186    }
187    let (mut exchange, diagnostics) = parse::parse_with_context(bytes, ctx)?;
188    inspect_parsed_exchange(bytes, ctx, &mut exchange, &diagnostics)
189}
190
191fn inspect_parsed_exchange(
192    bytes: &[u8],
193    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
194    exchange: &mut parse::Exchange,
195    diagnostics: &[parse::ParseDiagnostic],
196) -> Result<InspectedExchange, CodecError> {
197    let reader::AnalyzedExchange {
198        decoded,
199        matched,
200        opaque_offsets,
201    } = reader::analyze_exchange(bytes, exchange, diagnostics, ctx)?;
202    let mut entries = Vec::new();
203    ctx.push_vec(
204        &mut entries,
205        ContainerEntry {
206            name: ctx.copy_retained_text("HEADER", "step_inspect_section_label")?,
207            role: ContainerRole::Metadata,
208            storage: EntryStorage::unreported(VerbatimLabel::None),
209            attributes: BTreeMap::default(),
210        },
211        "step_inspect_entries",
212    )?;
213    if !exchange.anchors().is_empty() {
214        let mut attributes = std::collections::BTreeMap::new();
215        insert_attribute(
216            ctx,
217            &mut attributes,
218            "anchor_count",
219            ctx.format_retained(
220                format_args!("{}", exchange.anchors().len()),
221                "step_inspect_attribute_count",
222            )?,
223        )?;
224        ctx.push_vec(
225            &mut entries,
226            ContainerEntry {
227                name: ctx.copy_retained_text("ANCHOR", "step_inspect_section_label")?,
228                role: ContainerRole::InFileAnchors,
229                storage: EntryStorage::unreported(VerbatimLabel::None),
230                attributes,
231            },
232            "step_inspect_entries",
233        )?;
234    }
235    if !exchange.references().is_empty() {
236        let mut attributes = std::collections::BTreeMap::new();
237        insert_attribute(
238            ctx,
239            &mut attributes,
240            "external_count",
241            ctx.format_retained(
242                format_args!("{}", exchange.references().len()),
243                "step_inspect_attribute_count",
244            )?,
245        )?;
246        insert_attribute(
247            ctx,
248            &mut attributes,
249            "external_uris",
250            ctx.join_display_retained(
251                exchange.references().iter().map(|entry| entry.uri.as_str()),
252                ",",
253                "step_inspect_external_uris",
254            )?,
255        )?;
256        ctx.push_vec(
257            &mut entries,
258            ContainerEntry {
259                name: ctx.copy_retained_text("REFERENCE", "step_inspect_section_label")?,
260                role: ContainerRole::ExternalReferences,
261                storage: EntryStorage::unreported(VerbatimLabel::None),
262                attributes,
263            },
264            "step_inspect_entries",
265        )?;
266    }
267    let mut section_counts = ctx.alloc_filled(
268        exchange.data().len(),
269        (0_usize, BTreeMap::<&str, usize>::new()),
270        "step_inspect_data_counts",
271    )?;
272    let section_lookup_work = u64::from(
273        exchange
274            .data()
275            .len()
276            .checked_ilog2()
277            .map_or(0, |bits| bits + 1),
278    );
279    for record in exchange.records().values() {
280        ctx.charge_work(section_lookup_work, "step_inspect_data_owner")?;
281        let index = exchange
282            .data()
283            .partition_point(|section| section.span.end <= record.span.start);
284        let (entity_count, counts) = section_counts
285            .get_mut(index)
286            .ok_or_else(|| CodecError::malformed("STEP instance has no DATA section"))?;
287        *entity_count += 1;
288        if !opaque_offsets.contains(&record.span.start) {
289            continue;
290        }
291        for partial in &record.partials {
292            let name = partial.name.as_str();
293            ctx.admit_btree_entry(counts, &name, "step_inspect_unknown_counts")?;
294            match counts.entry(name) {
295                Entry::Occupied(mut entry) => *entry.get_mut() += 1,
296                Entry::Vacant(entry) => {
297                    entry.insert(1);
298                }
299            }
300        }
301    }
302    for (index, (entity_count, counts)) in section_counts.into_iter().enumerate() {
303        let unknown = ctx.join_display_retained(
304            counts
305                .iter()
306                .map(|(&name, &count)| CountItem { name, count }),
307            ",",
308            "step_inspect_unknown_entities",
309        )?;
310        let mut attributes = std::collections::BTreeMap::new();
311        insert_attribute(
312            ctx,
313            &mut attributes,
314            "entity_count",
315            ctx.format_retained(
316                format_args!("{entity_count}"),
317                "step_inspect_attribute_count",
318            )?,
319        )?;
320        insert_attribute(ctx, &mut attributes, "unknown_entities", unknown)?;
321        ctx.push_vec(
322            &mut entries,
323            ContainerEntry {
324                name: ctx
325                    .format_retained(format_args!("DATA[{index}]"), "step_inspect_section_name")?,
326                role: ContainerRole::EntityRecords,
327                storage: EntryStorage::unreported(VerbatimLabel::None),
328                attributes,
329            },
330            "step_inspect_entries",
331        )?;
332    }
333    let external_dependencies = decoded.body.notes.iter().filter(|note| {
334        note.starts_with("external document ") || note.starts_with("external source ")
335    });
336    let dependency_count = external_dependencies.clone().count();
337    if dependency_count > 0 {
338        let mut attributes = std::collections::BTreeMap::new();
339        insert_attribute(
340            ctx,
341            &mut attributes,
342            "dependency_count",
343            ctx.format_retained(
344                format_args!("{dependency_count}"),
345                "step_inspect_attribute_count",
346            )?,
347        )?;
348        insert_attribute(
349            ctx,
350            &mut attributes,
351            "dependencies",
352            ctx.join_display_retained(
353                external_dependencies.map(String::as_str),
354                ",",
355                "step_inspect_dependency_text",
356            )?,
357        )?;
358        ctx.push_vec(
359            &mut entries,
360            ContainerEntry {
361                name: ctx
362                    .copy_retained_text("EXTERNAL_DEPENDENCIES", "step_inspect_section_label")?,
363                role: ContainerRole::ExternalReferences,
364                storage: EntryStorage::unreported(VerbatimLabel::None),
365                attributes,
366            },
367            "step_inspect_entries",
368        )?;
369    }
370    for (index, signature) in exchange.signatures().iter().enumerate() {
371        ctx.push_vec(
372            &mut entries,
373            ContainerEntry {
374                name: if index == 0 {
375                    ctx.copy_retained_text("SIGNATURE", "step_inspect_signature_name")?
376                } else {
377                    ctx.format_retained(
378                        format_args!("SIGNATURE[{index}]"),
379                        "step_inspect_signature_indexed_name",
380                    )?
381                },
382                role: ContainerRole::Signature,
383                storage: EntryStorage::verbatim(
384                    VerbatimLabel::None,
385                    cadmpeg_core::decode::u64_from_index(signature.len()),
386                ),
387                attributes: BTreeMap::default(),
388            },
389            "step_inspect_entries",
390        )?;
391    }
392    let identifiers = exchange.joined_schema_identifiers(ctx)?;
393    let schema = if identifiers.is_empty() {
394        "unspecified".into()
395    } else {
396        identifiers
397    };
398    let dialect = matched.dialect();
399    let mut notes = Vec::new();
400    ctx.push_vec(
401        &mut notes,
402        ctx.format_retained(
403            format_args!("schema {schema}; dialect {dialect}"),
404            "step_inspect_schema_note",
405        )?,
406        "step_codec_notes",
407    )?;
408    for diagnostic in diagnostics {
409        let note = ctx.format_retained(
410            format_args!("{}", diagnostic.message),
411            "step_inspect_diagnostic_copy",
412        )?;
413        ctx.push_vec(&mut notes, note, "step_codec_notes")?;
414    }
415    Ok(InspectedExchange {
416        matched,
417        entries,
418        losses: decoded.body.losses,
419        notes,
420    })
421}
422
423fn starts_with_step_magic(bytes: &[u8]) -> bool {
424    starts_with_exchange_magic(bytes, b"ISO-10303-21;", false)
425        || draft_exchange_offset(bytes).is_some()
426}
427
428pub(crate) fn draft_exchange_offset(bytes: &[u8]) -> Option<usize> {
429    if starts_with_exchange_magic(bytes, b"STEP;", true) {
430        return Some(0);
431    }
432    // A leading identifier ends at a comment delimiter. A complete comment
433    // followed by the draft magic supplies a known exchange boundary.
434    let prefix_end = bytes
435        .iter()
436        .take_while(|byte| byte.is_ascii_alphanumeric() || **byte == b'_')
437        .count();
438    (bytes.first().is_some_and(u8::is_ascii_alphabetic)
439        && matches!(bytes.get(prefix_end..prefix_end + 2), Some(b"/*" | b"!*"))
440        && starts_with_exchange_magic(&bytes[prefix_end..], b"STEP;", true))
441    .then_some(prefix_end)
442}
443
444fn starts_with_exchange_magic(bytes: &[u8], magic: &[u8], draft: bool) -> bool {
445    let mut at = 0;
446    loop {
447        while bytes
448            .get(at)
449            .is_some_and(|byte| byte.is_ascii_control() || *byte == b' ')
450        {
451            at += 1;
452        }
453        if bytes.get(at..at + 2) == Some(b"/*") || (draft && bytes.get(at..at + 2) == Some(b"!*")) {
454            let close = if bytes[at] == b'/' { b"*/" } else { b"*!" };
455            at += 2;
456            let Some(relative_end) = bytes[at..].windows(2).position(|window| window == close)
457            else {
458                return false;
459            };
460            at += relative_end + 2;
461            continue;
462        }
463        if bytes
464            .get(at..at + 3)
465            .is_some_and(|prefix| prefix == b"\\N\\" || prefix == b"\\F\\")
466        {
467            at += 3;
468            continue;
469        }
470        break;
471    }
472    for &expected_byte in magic {
473        while bytes.get(at).is_some_and(u8::is_ascii_control) {
474            at += 1;
475        }
476        if !bytes
477            .get(at)
478            .is_some_and(|byte| byte.eq_ignore_ascii_case(&expected_byte))
479        {
480            return false;
481        }
482        at += 1;
483    }
484    true
485}
486
487fn inspect_zip(
488    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
489    root: cadmpeg_core::decode::View<'_>,
490) -> Result<ContainerSummary, CodecError> {
491    let archive::OpenedRoot {
492        archive,
493        view: root_view,
494        data_start: root_data_offset,
495    } = archive::open_root(ctx, root)?;
496    let root_bytes = root_view.window();
497    refuse_alternate_encoding(root_bytes)?;
498    if StepCodec::default().detect_impl(ctx, root_view)? == Confidence::No {
499        return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
500    }
501    let (mut exchange, diagnostics) = parse::parse_with_context(root_bytes, ctx)?;
502    let resource_notes = archive::root_reference_notes(ctx, &archive, &exchange);
503    let mut inspected = inspect_parsed_exchange(root_bytes, ctx, &mut exchange, &diagnostics)?;
504    let resource_notes = resource_notes?;
505    let entry_count = archive.entries().len();
506    let logical_entries = ctx.join_display_retained(
507        inspected.entries.iter().map(|entry| entry.name.as_str()),
508        ",",
509        "step_inspect_logical_sections",
510    )?;
511    let mut entries = archive.container_entries(ctx, archive::classify_entry)?;
512    if let Some(root_entry) = entries
513        .iter_mut()
514        .find(|entry| entry.name == archive::ROOT_NAME)
515    {
516        insert_attribute(
517            ctx,
518            &mut root_entry.attributes,
519            "logical_sections",
520            logical_entries,
521        )?;
522    }
523    let mut notes = Vec::new();
524    {
525        ctx.push_formatted_retained(
526            &mut notes,
527            format_args!("root {}", archive::ROOT_NAME),
528            "step_codec_notes",
529            "step_codec_root_note",
530        )?;
531        ctx.push_formatted_retained(
532            &mut notes,
533            format_args!("archive entries={entry_count}; root data offset={root_data_offset}"),
534            "step_codec_notes",
535            "step_codec_archive_note",
536        )
537    }?;
538    ctx.extend_vec(
539        &mut notes,
540        std::mem::take(&mut inspected.notes),
541        "step_codec_notes",
542    )?;
543    let mut losses = std::mem::take(&mut inspected.losses);
544    ctx.extend_vec(
545        &mut losses,
546        archive::recovery::losses(ctx, &archive)?,
547        "STEP ZIP combined losses",
548    )?;
549    ctx.extend_vec(&mut notes, resource_notes, "step_codec_notes")?;
550    // ZIP packaging is a container fact, not an identity axis: the
551    // `ISO-10303.p21` root carries the FILE_SCHEMA that classifies the
552    // document, so the root's own match is this summary's match.
553    Ok(ContainerSummary::classified(
554        DialectLayers::of(inspected.matched),
555        cadmpeg_ir::ContainerKind::Iso10303Zip,
556        entries,
557        losses,
558        notes,
559    ))
560}
561
562fn decode_zip(
563    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
564    root: cadmpeg_core::decode::View<'_>,
565) -> Result<Decoded, CodecError> {
566    let archive::OpenedRoot {
567        archive,
568        view: root_view,
569        data_start: root_data_offset,
570    } = archive::open_root(ctx, root)?;
571    let (exchange, diagnostics) = parse::parse_with_context(root_view.window(), ctx)?;
572    let resource_notes = archive::root_reference_notes(ctx, &archive, &exchange)?;
573    let entry_count = archive.entries().len();
574    let mut decoded = reader::decode_exchange(
575        root_view.window(),
576        exchange,
577        &diagnostics,
578        ctx,
579        reader::Packaging::Zip {
580            entry_count,
581            root_data_offset,
582        },
583    )?;
584    ctx.push_formatted_retained(
585        &mut decoded.body.notes,
586        format_args!(
587            "container root {}; archive entries={entry_count}",
588            archive::ROOT_NAME
589        ),
590        "step_codec_notes",
591        "step_codec_container_note",
592    )?;
593    ctx.extend_vec(&mut decoded.body.notes, resource_notes, "step_codec_notes")?;
594    ctx.extend_vec(
595        &mut decoded.body.losses,
596        archive::recovery::losses(ctx, &archive)?,
597        "STEP ZIP combined losses",
598    )?;
599    archive::recovery::retain(ctx, &archive, root, &mut decoded)?;
600    Ok(decoded)
601}
602
603pub(crate) fn is_part26_hdf5(bytes: &[u8]) -> bool {
604    const SIGNATURE: &[u8] = b"\x89HDF\r\n\x1a\n";
605    if bytes.starts_with(SIGNATURE) {
606        return true;
607    }
608
609    let mut offset = 512;
610    while offset < bytes.len() {
611        if bytes[offset..].starts_with(SIGNATURE) {
612            return true;
613        }
614        let Some(next) = offset.checked_mul(2) else {
615            break;
616        };
617        offset = next;
618    }
619    false
620}
621
622pub(crate) fn is_part28_xml(bytes: &[u8]) -> bool {
623    let bytes = &bytes[..bytes.len().min(4096)];
624    let Some((name, attributes)) = xml_root_start_tag(bytes) else {
625        return false;
626    };
627    let local_name = name
628        .iter()
629        .rposition(|byte| *byte == b':')
630        .map_or(name, |separator| &name[separator + 1..]);
631    if local_name.eq_ignore_ascii_case(b"iso_10303_28")
632        || ascii_starts_with(local_name, b"iso_10303_28_")
633    {
634        return true;
635    }
636
637    // A configured UOS can use a local name other than the document marker.
638    // Its governing-schema namespace varies by AP, but the Part 28 common
639    // namespace remains the bounded admission marker. Schema selection and
640    // the derived XML Schema remain caller inputs.
641    PART28_COMMON_NAMESPACES
642        .iter()
643        .any(|namespace| has_namespace_value(attributes, namespace))
644}
645
646const PART28_COMMON_NAMESPACES: [&[u8]; 3] = [
647    b"urn:oid:1.0.10303.28.2.1.1",
648    b"urn:iso:std:iso:10303:-28:ed-2:tech:XMLschema:common",
649    b"urn:iso.org:standard:10303:part(28):version(2):xmlschema:common",
650];
651
652fn ascii_starts_with(value: &[u8], prefix: &[u8]) -> bool {
653    value.len() >= prefix.len()
654        && value[..prefix.len()]
655            .iter()
656            .zip(prefix)
657            .all(|(value, prefix)| value.eq_ignore_ascii_case(prefix))
658}
659
660fn xml_root_start_tag(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
661    let mut cursor = if bytes.starts_with(b"\xef\xbb\xbf") {
662        3
663    } else {
664        0
665    };
666    loop {
667        while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
668            cursor += 1;
669        }
670        if bytes.get(cursor) != Some(&b'<') {
671            return None;
672        }
673        if bytes.get(cursor + 1) == Some(&b'?') {
674            let end = bytes
675                .get(cursor + 2..)?
676                .windows(2)
677                .position(|window| window == b"?>")?
678                + cursor
679                + 2;
680            cursor = end + 2;
681            continue;
682        }
683        if bytes.get(cursor + 1..cursor + 4) == Some(b"!--") {
684            let end = bytes
685                .get(cursor + 4..)?
686                .windows(3)
687                .position(|window| window == b"-->")?
688                + cursor
689                + 4;
690            cursor = end + 3;
691            continue;
692        }
693        if bytes.get(cursor + 1) == Some(&b'!') {
694            let end = bytes
695                .get(cursor + 2..)?
696                .iter()
697                .position(|byte| *byte == b'>')?
698                + cursor
699                + 2;
700            cursor = end + 1;
701            continue;
702        }
703        break;
704    }
705
706    let tag_end = find_xml_tag_end(bytes, cursor + 1)?;
707    let mut name_end = cursor + 1;
708    while bytes
709        .get(name_end)
710        .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'/' && *byte != b'>')
711    {
712        name_end += 1;
713    }
714    if name_end == cursor + 1 {
715        return None;
716    }
717    Some((&bytes[cursor + 1..name_end], &bytes[name_end..tag_end]))
718}
719
720fn find_xml_tag_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
721    let mut quote = None;
722    while let Some(byte) = bytes.get(cursor).copied() {
723        match quote {
724            Some(delimiter) if byte == delimiter => quote = None,
725            Some(_) => {}
726            None if byte == b'\'' || byte == b'"' => quote = Some(byte),
727            None if byte == b'>' => return Some(cursor),
728            None => {}
729        }
730        cursor += 1;
731    }
732    None
733}
734
735fn has_namespace_value(attributes: &[u8], expected: &[u8]) -> bool {
736    xml_attribute_value(attributes, |name, value| {
737        (name == b"xmlns" || name.starts_with(b"xmlns:")) && value == expected
738    })
739    .is_some()
740}
741
742fn xml_attribute_value(
743    attributes: &[u8],
744    mut matches: impl FnMut(&[u8], &[u8]) -> bool,
745) -> Option<&[u8]> {
746    let mut cursor = 0;
747    while cursor < attributes.len() {
748        while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
749            cursor += 1;
750        }
751        if attributes.get(cursor).is_none_or(|byte| *byte == b'/') {
752            break;
753        }
754        let name_start = cursor;
755        while attributes
756            .get(cursor)
757            .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'=' && *byte != b'/')
758        {
759            cursor += 1;
760        }
761        let name = &attributes[name_start..cursor];
762        while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
763            cursor += 1;
764        }
765        if attributes.get(cursor) != Some(&b'=') {
766            return None;
767        }
768        cursor += 1;
769        while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
770            cursor += 1;
771        }
772        let delimiter = *attributes.get(cursor)?;
773        if delimiter != b'\'' && delimiter != b'"' {
774            return None;
775        }
776        cursor += 1;
777        let value_start = cursor;
778        while attributes
779            .get(cursor)
780            .is_some_and(|byte| *byte != delimiter)
781        {
782            cursor += 1;
783        }
784        let value = &attributes[value_start..cursor];
785        cursor += 1;
786        if matches(name, value) {
787            return Some(value);
788        }
789    }
790    None
791}
792
793pub(crate) fn is_ap242_bo_model_xml(bytes: &[u8]) -> bool {
794    let bytes = &bytes[..bytes.len().min(4096)];
795    let Some((name, attributes)) = xml_root_start_tag(bytes) else {
796        return false;
797    };
798    let local_name = name
799        .iter()
800        .rposition(|byte| *byte == b':')
801        .map_or(name, |separator| &name[separator + 1..]);
802    // BM-03: the published namespace must be bound on the Uos document
803    // element. Text, comments, schemaLocation values, and local names do not
804    // identify the alternate encoding.
805    local_name == b"Uos"
806        && BO_MODEL_NAMESPACES
807            .iter()
808            .any(|namespace| has_namespace_value(attributes, namespace))
809}
810
811const BO_MODEL_NAMESPACES: [&[u8]; 2] = [
812    b"http://standards.iso.org/iso/ts/10303/-3001/-ed-1/tech/xml-schema/bo_model",
813    b"http://standards.iso.org/iso/ts/10303/-3001/-ed-2/tech/xml-schema/bo_model",
814];
815
816#[cfg(test)]
817mod tests {
818    use std::io::Cursor;
819
820    use cadmpeg_core::decode::{
821        DecodeArena, DecodeContext, DecodePolicy, InspectOptions, ResourceDimension,
822    };
823    use cadmpeg_core::CodecError;
824    use cadmpeg_ir::codec::{Codec, Confidence, DecodeOptions};
825
826    use super::{insert_attribute, starts_with_step_magic, StepCodec};
827
828    const INSPECTION_TEXT_SOURCE: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
829
830    #[test]
831    fn inspection_count_text_refuses_retained_limit() {
832        inspect_text_refuses(INSPECTION_TEXT_SOURCE, "step_inspect_attribute_count");
833    }
834
835    #[test]
836    fn inspection_attribute_key_refuses_retained_limit() {
837        inspect_text_refuses(INSPECTION_TEXT_SOURCE, "step_inspect_attribute_key");
838    }
839
840    #[test]
841    fn inspection_owned_attribute_value_is_not_charged_twice() {
842        let mut policy = cadmpeg_core::decode::DecodePolicy::service();
843        policy.limits.max_retained_bytes = u64::try_from(
844            13 + 11 * std::mem::size_of::<(String, String)>()
845                + 16 * std::mem::size_of::<usize>()
846                + 2 * std::mem::align_of::<String>(),
847        )
848        .expect("attribute node layout");
849        crate::test_support::with_policy_context(b"", &policy, |_, ctx| {
850            let mut attributes = std::collections::BTreeMap::new();
851            let value = ctx
852                .copy_retained_text("1", "step_test_attribute_value")
853                .expect("one-byte value");
854            insert_attribute(ctx, &mut attributes, "entity_count", value)
855                .expect("twelve-byte key fits once");
856            assert_eq!(attributes["entity_count"], "1");
857        });
858    }
859
860    #[test]
861    fn inspect_attribute_refuses_collection_limit() {
862        let arena = DecodeArena::new();
863        let mut policy = DecodePolicy::service();
864        policy.limits.max_collection_items = 0;
865        let (ctx, _) = DecodeContext::from_root_bytes(b"", &arena, &policy)
866            .expect("empty root fits selected policy");
867        let mut attributes = std::collections::BTreeMap::new();
868        assert!(matches!(
869            insert_attribute(&ctx, &mut attributes, "unknown_entities", "ITEM:1".into()),
870            Err(CodecError::ResourceLimit(refusal))
871                if refusal.dimension == ResourceDimension::CollectionItems
872                    && refusal.operation == "step_inspect_attributes"
873        ));
874    }
875
876    fn inspect_text_refuses(source: &[u8], operation: &str) {
877        let mut limit = 0u64;
878        for _ in 0..512 {
879            let (mut exchange, diagnostics) =
880                crate::test_support::with_service_context(source, crate::parse::parse_inner)
881                    .expect("valid inspect source");
882            let arena = DecodeArena::new();
883            let mut policy = DecodePolicy::service();
884            policy.limits.max_retained_bytes = limit;
885            let (ctx, _) = DecodeContext::from_root_bytes(source, &arena, &policy)
886                .expect("root fits retained policy");
887            match super::inspect_parsed_exchange(source, &ctx, &mut exchange, &diagnostics) {
888                Err(CodecError::ResourceLimit(refusal))
889                    if refusal.dimension == ResourceDimension::RetainedBytes
890                        && refusal.operation == operation =>
891                {
892                    return;
893                }
894                Err(CodecError::ResourceLimit(refusal))
895                    if refusal.dimension == ResourceDimension::RetainedBytes =>
896                {
897                    let next = refusal
898                        .used
899                        .checked_add(refusal.additional)
900                        .expect("retained requirement fits u64");
901                    assert!(next > limit, "retained limit must advance");
902                    limit = next;
903                }
904                Ok(_) => panic!("inspect completed before {operation}"),
905                Err(error) => panic!("inspect did not reach {operation}: {error}"),
906            }
907        }
908        panic!("inspect never reached {operation}");
909    }
910
911    #[test]
912    fn inspect_external_uris_refuse_retained_limit() {
913        const SOURCE: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'4;2');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;REFERENCE;#10=<parts/child.p21>;ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
914        inspect_text_refuses(SOURCE, "step_inspect_external_uris");
915    }
916
917    #[test]
918    fn inspect_unknown_entities_refuse_retained_limit() {
919        const SOURCE: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
920        inspect_text_refuses(SOURCE, "step_inspect_unknown_entities");
921    }
922
923    #[test]
924    fn inspect_schema_note_refuses_retained_limit() {
925        const SOURCE: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
926        inspect_text_refuses(SOURCE, "step_inspect_schema_note");
927    }
928
929    #[test]
930    fn inspect_diagnostic_copy_refuses_retained_limit() {
931        const SOURCE: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'4;9');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
932        inspect_text_refuses(SOURCE, "step_inspect_diagnostic_copy");
933    }
934
935    #[test]
936    fn inspect_dependency_text_refuses_retained_limit() {
937        let source = include_bytes!("../tests/fixtures/ap242_external_documents.p21");
938        inspect_text_refuses(source, "step_inspect_dependency_text");
939    }
940
941    #[test]
942    fn inspect_unknown_count_entries_refuse_collection_limit() {
943        const SOURCE: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
944        let mut limit = 0u64;
945        for _ in 0..512 {
946            let (mut exchange, diagnostics) =
947                crate::test_support::with_service_context(SOURCE, crate::parse::parse_inner)
948                    .expect("valid inspect source");
949            let arena = DecodeArena::new();
950            let mut policy = DecodePolicy::service();
951            policy.limits.max_collection_items = limit;
952            let (ctx, _) = DecodeContext::from_root_bytes(SOURCE, &arena, &policy)
953                .expect("root fits collection policy");
954            match super::inspect_parsed_exchange(SOURCE, &ctx, &mut exchange, &diagnostics) {
955                Err(CodecError::ResourceLimit(refusal))
956                    if refusal.dimension == ResourceDimension::CollectionItems
957                        && refusal.operation == "step_inspect_unknown_counts" =>
958                {
959                    return;
960                }
961                Err(CodecError::ResourceLimit(refusal))
962                    if refusal.dimension == ResourceDimension::CollectionItems =>
963                {
964                    let next = refusal
965                        .used
966                        .checked_add(refusal.additional)
967                        .expect("collection requirement fits u64");
968                    assert!(next > limit, "collection limit must advance");
969                    limit = next;
970                }
971                Ok(_) => panic!("inspect completed before count admission"),
972                Err(error) => panic!("inspect did not reach count admission: {error}"),
973            }
974        }
975        panic!("inspect never reached count admission");
976    }
977
978    fn zip_text_refuses(operation: &str, inspect: bool) {
979        use std::io::Write;
980
981        const ROOT: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
982        let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
983        writer
984            .start_file(
985                "ISO-10303.p21",
986                zip::write::SimpleFileOptions::default()
987                    .compression_method(zip::CompressionMethod::Stored),
988            )
989            .expect("start ZIP root");
990        writer.write_all(ROOT).expect("write ZIP root");
991        let bytes = writer.finish().expect("finish STEP ZIP").into_inner();
992
993        let mut limit = 0u64;
994        for _ in 0..1024 {
995            let mut policy = DecodePolicy::service();
996            policy.limits.max_retained_bytes = limit;
997            let result = crate::test_support::with_policy_context(&bytes, &policy, |bytes, ctx| {
998                let root = cadmpeg_core::decode::View::over_retained(bytes);
999                if inspect {
1000                    super::inspect_zip(ctx, root).map(|_| ())
1001                } else {
1002                    super::decode_zip(ctx, root).map(|_| ())
1003                }
1004            });
1005            match result {
1006                Err(CodecError::ResourceLimit(refusal))
1007                    if refusal.dimension == ResourceDimension::RetainedBytes
1008                        && refusal.operation == operation =>
1009                {
1010                    return;
1011                }
1012                Err(CodecError::ResourceLimit(refusal))
1013                    if refusal.dimension == ResourceDimension::RetainedBytes =>
1014                {
1015                    let next = refusal
1016                        .used
1017                        .checked_add(refusal.additional)
1018                        .expect("retained requirement fits u64");
1019                    assert!(next > limit, "retained limit must advance");
1020                    limit = next;
1021                }
1022                Ok(()) => panic!("ZIP operation completed before {operation}"),
1023                Err(error) => panic!("ZIP operation did not reach {operation}: {error}"),
1024            }
1025        }
1026        panic!("ZIP operation never reached {operation}");
1027    }
1028
1029    #[test]
1030    fn inspect_zip_logical_sections_refuse_retained_limit() {
1031        zip_text_refuses("step_inspect_logical_sections", true);
1032    }
1033
1034    #[test]
1035    fn inspection_section_name_refuses_retained_limit() {
1036        inspect_text_refuses(INSPECTION_TEXT_SOURCE, "step_inspect_section_name");
1037    }
1038
1039    #[test]
1040    fn inspection_signature_name_refuses_retained_limit() {
1041        const SOURCE: &[u8] = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'4;2');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;SIGNATURE;MFoGCSqGSIb3DQEHAqBNMEsCAQExDTALBglghkgBZQMEAgEwCwYJKoZIhvcNAQcBMSowKAIBATAFMAACAQEwCwYJYIZIAWUDBAIBMA0GCSqGSIb3DQEBAQUABAA= ENDSEC;SIGNATURE;MFoGCSqGSIb3DQEHAqBNMEsCAQExDTALBglghkgBZQMEAgEwCwYJKoZIhvcNAQcBMSowKAIBATAFMAACAQEwCwYJYIZIAWUDBAIBMA0GCSqGSIb3DQEBAQUABAA= ENDSEC;";
1042        inspect_text_refuses(SOURCE, "step_inspect_signature_indexed_name");
1043    }
1044
1045    #[test]
1046    fn zip_inspection_archive_note_refuses_retained_limit() {
1047        zip_text_refuses("step_codec_archive_note", true);
1048    }
1049
1050    #[test]
1051    fn zip_decode_container_note_refuses_retained_limit() {
1052        zip_text_refuses("step_codec_container_note", false);
1053    }
1054
1055    #[test]
1056    fn detects_magic_after_ignored_controls_and_inside_token() {
1057        let source = b"\0 /* leading comment */ \\N\\ ISO-10303-\n21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
1058        let codec = StepCodec::default();
1059
1060        assert!(starts_with_step_magic(source));
1061        assert_eq!(
1062            cadmpeg_test_support::detection::confidence(&codec, source),
1063            Confidence::High
1064        );
1065        codec
1066            .decode(&mut Cursor::new(source), &DecodeOptions::default())
1067            .expect("decode Part 21 with ignored framing octets");
1068
1069        let with_bom = [b"\xEF\xBB\xBF".as_slice(), source].concat();
1070        assert_eq!(
1071            cadmpeg_test_support::detection::confidence(&codec, &with_bom),
1072            Confidence::No
1073        );
1074        assert!(!starts_with_step_magic(b"/* incomplete ISO-10303-21;"));
1075    }
1076
1077    #[test]
1078    fn inspect_accepts_noncanonical_complex_partial_order_and_reports_a_note() {
1079        let bytes = include_bytes!("../tests/fixtures/noncanonical_solid_angle.p21");
1080        let summary = StepCodec::default()
1081            .inspect(&mut Cursor::new(bytes), &InspectOptions::default())
1082            .expect("inspection describes recoverable source order");
1083
1084        assert!(summary
1085            .notes
1086            .iter()
1087            .any(|note| note.contains("complex partial records are not alphabetical")));
1088    }
1089    #[test]
1090    fn inspection_counts_data_populations_by_source_extent() {
1091        let source = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'4;3');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA('first',('AP242'));#90=FIRST_ITEM();#3=SECOND_ITEM(#90);ENDSEC;DATA('second',('AP242'));#1=THIRD_ITEM();ENDSEC;END-ISO-10303-21;";
1092        let summary = StepCodec::default()
1093            .inspect(&mut Cursor::new(source), &InspectOptions::default())
1094            .expect("named DATA sections retain their own populations");
1095        let first = summary
1096            .entries
1097            .iter()
1098            .find(|entry| entry.name == "DATA[0]")
1099            .expect("first population");
1100        let second = summary
1101            .entries
1102            .iter()
1103            .find(|entry| entry.name == "DATA[1]")
1104            .expect("second population");
1105        assert_eq!(first.attributes["entity_count"], "2");
1106        assert_eq!(
1107            first.attributes["unknown_entities"],
1108            "FIRST_ITEM:1,SECOND_ITEM:1"
1109        );
1110        assert_eq!(second.attributes["entity_count"], "1");
1111        assert_eq!(second.attributes["unknown_entities"], "THIRD_ITEM:1");
1112    }
1113}