Skip to main content

ifc_lite_core/decoder/
styled_items.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Source-bound inverse StyledItem.Item lookup. No presentation interpretation.
6use super::{EntityDecoder, EntityScanner, Error, Result};
7use rustc_hash::{FxHashMap, FxHashSet};
8
9impl EntityDecoder<'_> {
10    /// Return file-ordered IfcStyledItem ids attached to a representation item.
11    /// One lazy index belongs to the shared source columnar index (or this
12    /// decoder without one), surviving per-element/batch decoder recreation; failures are
13    /// cached too. Refuses sources over 256 MiB, over one million styled-item
14    /// reference edges, malformed records, or over 64 styles on one item.
15    /// A refusal is never interpreted as an unstyled item.
16    pub fn styled_item_ids(&mut self, item_id: u32) -> Result<&[u32]> {
17        if self.content.len() <= 256 * 1024 * 1024 {
18            self.build_index();
19        }
20        let cache = match self.entity_index.as_ref() {
21            Some(crate::columnar_index::EntityIndexStore::Columnar(index)) => {
22                &index.styled_item_index
23            }
24            _ => &self.styled_item_index,
25        };
26        match cache.get_or_init(|| self.build_styled_item_index()) {
27            Ok(index) => Ok(index.get(&item_id).map(Vec::as_slice).unwrap_or(&[])),
28            Err(message) => Err(Error::parse(0, message.clone())),
29        }
30    }
31
32    fn build_styled_item_index(&self) -> std::result::Result<FxHashMap<u32, Vec<u32>>, String> {
33        if self.content.len() > 256 * 1024 * 1024 {
34            return Err("StyledItem inverse lookup source-byte budget exceeded".into());
35        }
36        let mut index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
37        let mut scanner = EntityScanner::new(self.content);
38        let mut edges = 0usize;
39        let mut seen_ids = FxHashSet::default();
40        while let Some((id, type_name, start, end)) = scanner.next_entity() {
41            // The scanner hands back the RAW keyword slice, and 10303-21
42            // keywords are case-insensitive: `IfcStyledItem(` is the same
43            // record, and a case-sensitive test here built an EMPTY index
44            // from such a file, so every item read as unstyled, which is the
45            // one answer this module's contract says it must never give.
46            // Same rule as the `IFCPROJECT` scans in `decoder.rs` (#4497)
47            // and `EntityScanner::find_by_type`.
48            if !type_name.eq_ignore_ascii_case("IFCSTYLEDITEM") {
49                continue;
50            }
51            if end - start > 16 * 1024 {
52                return Err("StyledItem record-byte budget exceeded".into());
53            }
54            if self
55                .entity_index
56                .as_ref()
57                .and_then(|index| index.lookup(id))
58                != Some((start, end))
59            {
60                return Err("StyledItem inverse lookup overwritten entity id".into());
61            }
62            if !seen_ids.insert(id) {
63                return Err("StyledItem inverse lookup duplicate entity id".into());
64            }
65            edges += 1;
66            if edges > 1_000_000 {
67                return Err("StyledItem inverse lookup edge budget exceeded".into());
68            }
69            let styled = self
70                .decode_at_uncached(start, end)
71                .map_err(|e| e.to_string())?;
72            let Some(item) = styled.get(0) else {
73                return Err("StyledItem missing Item".into());
74            };
75            if item.is_null() {
76                continue;
77            }
78            let item_id = item
79                .as_entity_ref()
80                .ok_or("StyledItem has invalid Item reference")?;
81            let ids = index.entry(item_id).or_default();
82            if ids.len() >= 64 {
83                return Err("StyledItem inverse lookup per-item budget exceeded".into());
84            }
85            ids.push(id);
86        }
87        if scanner.skipped_oversized_ids() > 0 || scanner.malformed_record_start().is_some() {
88            return Err("StyledItem inverse lookup encountered malformed source records".into());
89        }
90        Ok(index)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use crate::{ColumnarEntityIndex, EntityDecoder};
97    use std::sync::Arc;
98
99    #[test]
100    fn styled_lookup_4406_survives_decoder_recreation_and_source_replacement() {
101        let source = "#1=IFCSTYLEDITEM(#10,(#20),$);#2=IFCSTYLEDITEM(#11,(#21),$);";
102        let index = Arc::new(ColumnarEntityIndex::from_scan(source.as_bytes()));
103        let mut first = EntityDecoder::with_arc_columnar_index(source, index.clone());
104        let ids = first.styled_item_ids(10).unwrap();
105        assert_eq!(ids, &[1]);
106        let pointer = ids.as_ptr();
107        drop(first);
108        let mut second = EntityDecoder::with_arc_columnar_index(source, index);
109        assert_eq!(
110            second.styled_item_ids(10).unwrap().as_ptr(),
111            pointer,
112            "source-owned lookup storage is reused across jobs"
113        );
114        assert_eq!(second.styled_item_ids(11).unwrap(), &[2]);
115        assert!(second.styled_item_ids(12).unwrap().is_empty());
116        let replacement = "#3=IFCSTYLEDITEM(#10,(#22),$);";
117        let replacement_index = Arc::new(ColumnarEntityIndex::from_scan(replacement.as_bytes()));
118        let mut replaced = EntityDecoder::with_arc_columnar_index(replacement, replacement_index);
119        assert_eq!(replaced.styled_item_ids(10).unwrap(), &[3]);
120    }
121
122    #[test]
123    fn styled_lookup_4406_budget_is_cached_failure_not_empty_style() {
124        let source = (1..=65)
125            .map(|id| format!("#{id}=IFCSTYLEDITEM(#100,(#200),$);"))
126            .collect::<String>();
127        let index = Arc::new(ColumnarEntityIndex::from_scan(source.as_bytes()));
128        for _ in 0..2 {
129            let mut decoder = EntityDecoder::with_arc_columnar_index(&source, index.clone());
130            assert!(decoder
131                .styled_item_ids(100)
132                .unwrap_err()
133                .to_string()
134                .contains("per-item budget"));
135            assert!(
136                decoder.styled_item_ids(101).is_err(),
137                "failed index must never report unstyled success"
138            );
139        }
140    }
141    #[test]
142    fn styled_lookup_4406_duplicate_ids_never_alias_another_attachment() {
143        let source = "#1=IFCSTYLEDITEM(#10,(#20),$);#1=IFCSTYLEDITEM(#11,(#21),$);";
144        let index = Arc::new(ColumnarEntityIndex::from_scan(source.as_bytes()));
145        let mut decoder = EntityDecoder::with_arc_columnar_index(source, index);
146        for item in [10, 11] {
147            assert!(decoder
148                .styled_item_ids(item)
149                .unwrap_err()
150                .to_string()
151                .contains("entity id"));
152        }
153    }
154
155    /// ISO 10303-21 keywords are case-insensitive, and the scanner hands back
156    /// the raw slice. A case-sensitive `!= "IFCSTYLEDITEM"` skipped every
157    /// record of a lower- or mixed-case file, so the index came back EMPTY and
158    /// `styled_item_ids` reported every item unstyled: a success, not the
159    /// refusal the module header promises. The uppercase file is the control.
160    /// Regression for #4577.
161    #[test]
162    fn styled_lookup_keyword_case_is_not_significant() {
163        for keyword in ["IfcStyledItem", "ifcstyleditem", "IFCSTYLEDITEM"] {
164            let source = format!("#1={keyword}(#10,(#20),$);#2={keyword}(#11,(#21),$);");
165            let index = Arc::new(ColumnarEntityIndex::from_scan(source.as_bytes()));
166            let mut decoder = EntityDecoder::with_arc_columnar_index(&source, index);
167            assert_eq!(decoder.styled_item_ids(10).unwrap(), &[1], "keyword {keyword}");
168            assert_eq!(decoder.styled_item_ids(11).unwrap(), &[2], "keyword {keyword}");
169            assert!(decoder.styled_item_ids(12).unwrap().is_empty(), "keyword {keyword}");
170        }
171    }
172
173    #[test]
174    fn styled_lookup_4406_overwritten_by_different_entity_type_refuses() {
175        let source = "#1=IFCSTYLEDITEM(#10,(#20),$);#1=IFCCOLOURRGB($,0.2,0.6,0.8);";
176        let mut decoder = EntityDecoder::new(source);
177        assert!(decoder
178            .styled_item_ids(10)
179            .unwrap_err()
180            .to_string()
181            .contains("overwritten entity id"));
182    }
183}