1use 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#[derive(Debug, Clone, Default)]
28pub struct StepCodec {
29 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 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
138struct 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
173fn 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 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 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 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 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}