Skip to main content

document_svg/document/
bcf.rs

1//! Bounded BIM Collaboration Format (BCFZIP/BCF-XML) issue previews.
2//!
3//! BCF packages exchange coordination topics, markups and viewpoints alongside
4//! IFC models. This adapter reads only small XML metadata entries; snapshots,
5//! referenced models, document URLs and collaboration actions remain inert.
6
7use std::fs::File;
8use std::io::{Cursor, Read};
9use std::path::Path;
10
11use zip::ZipArchive;
12
13use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
14use crate::document::html::{HtmlBlock, render_blocks_to_pages};
15use crate::error::{Error, Result};
16use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
17use crate::table::{TableAlign, TableData};
18
19const MAX_BCF_BYTES: u64 = 128 * 1024 * 1024;
20const MAX_BCF_EXPANDED_BYTES: u64 = 128 * 1024 * 1024;
21const MAX_BCF_ENTRY_BYTES: u64 = 16 * 1024 * 1024;
22const MAX_BCF_ENTRIES: usize = 100_000;
23const MAX_BCF_XML_EVENTS: usize = 500_000;
24const MAX_BCF_XML_NODES: usize = 300_000;
25const MAX_BCF_XML_DEPTH: usize = 96;
26const MAX_BCF_TEXT_BYTES: usize = 24 * 1024 * 1024;
27const MAX_BCF_ROWS: usize = 100_000;
28const MAX_BCF_DISPLAY_BYTES: usize = 512;
29
30pub(crate) fn looks_like_archive(path: &Path) -> bool {
31    let Ok(metadata) = std::fs::metadata(path) else {
32        return false;
33    };
34    if metadata.len() > MAX_BCF_BYTES {
35        return false;
36    }
37    let Ok(mut file) = File::open(path) else {
38        return false;
39    };
40    let mut signature = [0_u8; 4];
41    if file.read_exact(&mut signature).is_err() || signature != [0x50, 0x4b, 0x03, 0x04] {
42        return false;
43    }
44    let Ok(bytes) = read_limited_file(path, MAX_BCF_BYTES, "BCFZIP sniff") else {
45        return false;
46    };
47    let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
48        return false;
49    };
50    if archive.len() > MAX_BCF_ENTRIES {
51        return false;
52    }
53    let mut has_version = false;
54    let mut has_markup = false;
55    for index in 0..archive.len() {
56        let Ok(entry) = archive.by_index(index) else {
57            return false;
58        };
59        let name = entry.name().to_ascii_lowercase();
60        has_version |= name == "bcf.version";
61        has_markup |= name.ends_with("markup.bcf");
62        if has_version && has_markup {
63            return true;
64        }
65    }
66    false
67}
68
69struct BcfPageSink<'a> {
70    inner: &'a mut dyn PageConsumer,
71    warnings: &'a [String],
72}
73impl PageConsumer for BcfPageSink<'_> {
74    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
75        page.source_format = "bcfzip".into();
76        if page.title.is_empty() {
77            page.title = "BCF issue package".into();
78        }
79        page.description = "BCF topics and markup metadata are rendered as a bounded inert summary; snapshots, models and external references are not opened".into();
80        for warning in self.warnings {
81            page.warn(warning.clone());
82        }
83        self.inner.consume(page)
84    }
85}
86
87#[derive(Default)]
88struct Summary {
89    version: String,
90    project: String,
91    topics: usize,
92    comments: usize,
93    viewpoints: usize,
94    document_refs: usize,
95    components: usize,
96    snapshots: usize,
97    rows: Vec<Vec<String>>,
98}
99
100pub(crate) fn convert(
101    path: &Path,
102    options: &ConvertOptions,
103    sink: &mut dyn PageConsumer,
104) -> Result<Vec<String>> {
105    let bytes = read_limited_file(
106        path,
107        options.max_input_bytes.min(MAX_BCF_BYTES),
108        "BCFZIP input",
109    )?;
110    let mut archive = ZipArchive::new(Cursor::new(bytes))
111        .map_err(|error| Error::InvalidInput(format!("invalid BCFZIP archive: {error}")))?;
112    if archive.len() > MAX_BCF_ENTRIES {
113        return Err(Error::LimitExceeded(format!(
114            "BCFZIP entries exceed {MAX_BCF_ENTRIES}"
115        )));
116    }
117    let mut summary = Summary::default();
118    let mut markups = Vec::new();
119    let mut has_version = false;
120    let mut expanded_bytes = 0_u64;
121    for index in 0..archive.len() {
122        let mut entry = archive.by_index(index).map_err(|error| {
123            Error::InvalidInput(format!("invalid BCFZIP entry {index}: {error}"))
124        })?;
125        let name = entry.name().to_owned();
126        let lower = name.to_ascii_lowercase();
127        if lower == "bcf.version" {
128            has_version = true;
129            if entry.size() > MAX_BCF_ENTRY_BYTES {
130                return Err(Error::LimitExceeded(
131                    "BCF.version exceeds entry limit".into(),
132                ));
133            }
134            reserve_expanded(&mut expanded_bytes, entry.size())?;
135            let mut data = Vec::new();
136            entry
137                .take(MAX_BCF_ENTRY_BYTES.saturating_add(1))
138                .read_to_end(&mut data)?;
139            if data.len() as u64 > MAX_BCF_ENTRY_BYTES {
140                return Err(Error::LimitExceeded(
141                    "BCF.version exceeds entry limit".into(),
142                ));
143            }
144            summary.version = truncate(&String::from_utf8_lossy(&data));
145        } else if lower.ends_with("project.bcfp") {
146            reserve_expanded(&mut expanded_bytes, entry.size())?;
147            let data = read_entry(&mut entry, &name)?;
148            if let Ok(root) = parse_bcf_xml(&data, options) {
149                summary.project = first_text(&root, "Name");
150            }
151        } else if lower.ends_with("markup.bcf") {
152            reserve_expanded(&mut expanded_bytes, entry.size())?;
153            let data = read_entry(&mut entry, &name)?;
154            markups.push((name, data));
155        } else if lower.ends_with("snapshot.png")
156            || lower.ends_with("snapshot.jpg")
157            || lower.ends_with("snapshot.jpeg")
158        {
159            summary.snapshots = summary.snapshots.saturating_add(1);
160        }
161    }
162    if !has_version {
163        return Err(Error::InvalidInput(
164            "BCFZIP is missing the root bcf.version entry".into(),
165        ));
166    }
167    for (name, data) in markups {
168        let root = parse_bcf_xml(&data, options).map_err(|error| {
169            Error::InvalidInput(format!("BCF markup {name} is invalid: {error}"))
170        })?;
171        if !root.name.eq_ignore_ascii_case("Markup") {
172            return Err(Error::InvalidInput(format!(
173                "BCF markup {name} must have a Markup root"
174            )));
175        }
176        summary.topics = summary.topics.saturating_add(count_named(&root, "Topic"));
177        summary.comments = summary
178            .comments
179            .saturating_add(count_named_with_attr(&root, "Comment", "Guid"));
180        summary.viewpoints = summary
181            .viewpoints
182            .saturating_add(count_named(&root, "Viewpoint"));
183        summary.document_refs = summary
184            .document_refs
185            .saturating_add(count_named(&root, "DocumentReference"));
186        summary.components = summary
187            .components
188            .saturating_add(count_named(&root, "Component"));
189        let topic = descendants_named(&root, "Topic").first().copied();
190        let title = topic
191            .map(|node| first_text(node, "Title"))
192            .unwrap_or_default();
193        let topic_type = topic
194            .and_then(|node| attr_local(node, "TopicType"))
195            .map(str::to_owned)
196            .unwrap_or_default();
197        let status = topic
198            .and_then(|node| attr_local(node, "TopicStatus"))
199            .map(str::to_owned)
200            .unwrap_or_default();
201        let priority = topic
202            .and_then(|node| attr_local(node, "Priority"))
203            .map(str::to_owned)
204            .unwrap_or_default();
205        push_row(
206            &mut summary.rows,
207            "Topic",
208            &title,
209            &format!(
210                "type={} status={} priority={}",
211                display_or_dash(&topic_type),
212                display_or_dash(&status),
213                display_or_dash(&priority)
214            ),
215        )?;
216    }
217    if summary.topics == 0 {
218        return Err(Error::InvalidInput(
219            "BCFZIP contains no markup topics".into(),
220        ));
221    }
222    push_row(
223        &mut summary.rows,
224        "Package",
225        "BCFZIP",
226        &format!(
227            "version={} project={}",
228            display_or_dash(&summary.version),
229            display_or_dash(&summary.project)
230        ),
231    )?;
232    push_row(
233        &mut summary.rows,
234        "Topics",
235        &summary.topics.to_string(),
236        &format!(
237            "comments={} viewpoints={}",
238            summary.comments, summary.viewpoints
239        ),
240    )?;
241    push_row(
242        &mut summary.rows,
243        "References",
244        &summary.document_refs.to_string(),
245        &format!(
246            "components={} snapshots={}",
247            summary.components, summary.snapshots
248        ),
249    )?;
250    let metadata = format!(
251        "BCF version: {}\nProject: {}\nTopics: {}\nComments: {}\nViewpoints: {}\nDocument references: {}\nComponents: {}\nSnapshots skipped: {}",
252        display_or_dash(&summary.version),
253        display_or_dash(&summary.project),
254        summary.topics,
255        summary.comments,
256        summary.viewpoints,
257        summary.document_refs,
258        summary.components,
259        summary.snapshots
260    );
261    let blocks = vec![
262        HtmlBlock::Heading {
263            level: 1,
264            text: "BCF issue package".into(),
265        },
266        HtmlBlock::Paragraph { text: metadata },
267        HtmlBlock::Table(TableData {
268            headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
269            rows: summary.rows,
270            alignments: vec![TableAlign::Left; 3],
271            raw_source: String::new(),
272        }),
273    ];
274    let warnings = vec![
275        "BCF version/project/topic status/title/priority and comment/viewpoint/reference/component counts are shown; snapshot images, IFC/model references, document URLs, GUID payloads and markup extensions are omitted or redacted".into(),
276        "BCFZIP entry and XML traversal are bounded; external models, URI dereferencing, collaboration API calls, scripts and issue mutations never run".into(),
277    ];
278    let mut page_sink = BcfPageSink {
279        inner: sink,
280        warnings: &warnings,
281    };
282    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
283    Ok(warnings)
284}
285
286fn read_entry<R: Read>(entry: &mut zip::read::ZipFile<'_, R>, name: &str) -> Result<Vec<u8>> {
287    if entry.size() > MAX_BCF_ENTRY_BYTES {
288        return Err(Error::LimitExceeded(format!(
289            "BCF entry {name} exceeds {MAX_BCF_ENTRY_BYTES} bytes"
290        )));
291    }
292    let mut data = Vec::new();
293    entry
294        .take(MAX_BCF_ENTRY_BYTES.saturating_add(1))
295        .read_to_end(&mut data)?;
296    if data.len() as u64 > MAX_BCF_ENTRY_BYTES {
297        return Err(Error::LimitExceeded(format!(
298            "BCF entry {name} exceeds {MAX_BCF_ENTRY_BYTES} bytes"
299        )));
300    }
301    Ok(data)
302}
303fn reserve_expanded(total: &mut u64, entry_size: u64) -> Result<()> {
304    let next = total
305        .checked_add(entry_size)
306        .ok_or_else(|| Error::LimitExceeded("BCFZIP expanded entry size overflow".into()))?;
307    if next > MAX_BCF_EXPANDED_BYTES {
308        return Err(Error::LimitExceeded(format!(
309            "BCFZIP expanded entries exceed {MAX_BCF_EXPANDED_BYTES} bytes"
310        )));
311    }
312    *total = next;
313    Ok(())
314}
315fn parse_bcf_xml(bytes: &[u8], options: &ConvertOptions) -> Result<XmlElement> {
316    parse_xml_tree(
317        bytes,
318        &XmlLimits {
319            max_events: options.max_xml_events.min(MAX_BCF_XML_EVENTS),
320            max_nodes: MAX_BCF_XML_NODES,
321            max_depth: MAX_BCF_XML_DEPTH,
322            max_text_bytes: MAX_BCF_TEXT_BYTES,
323        },
324        "BCF",
325    )
326}
327fn attr_local<'a>(element: &'a XmlElement, name: &str) -> Option<&'a str> {
328    element.attributes.iter().find_map(|(key, value)| {
329        key.rsplit(':')
330            .next()
331            .filter(|local| local.eq_ignore_ascii_case(name))
332            .map(|_| value.as_str())
333    })
334}
335fn first_text(element: &XmlElement, name: &str) -> String {
336    descendants_named(element, name)
337        .first()
338        .map(|node| truncate(&text_content(node)))
339        .unwrap_or_default()
340}
341fn count_named(element: &XmlElement, name: &str) -> usize {
342    element
343        .children
344        .iter()
345        .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
346        .sum()
347}
348fn count_named_with_attr(element: &XmlElement, name: &str, attr: &str) -> usize {
349    element
350        .children
351        .iter()
352        .map(|child| {
353            usize::from(child.name.eq_ignore_ascii_case(name) && attr_local(child, attr).is_some())
354                + count_named_with_attr(child, name, attr)
355        })
356        .sum()
357}
358fn descendants_named<'a>(element: &'a XmlElement, name: &str) -> Vec<&'a XmlElement> {
359    let mut result = Vec::new();
360    for child in &element.children {
361        if child.name.eq_ignore_ascii_case(name) {
362            result.push(child);
363        }
364        result.extend(descendants_named(child, name));
365    }
366    result
367}
368fn text_content(element: &XmlElement) -> String {
369    let mut parts = Vec::new();
370    if !element.text.trim().is_empty() {
371        parts.push(element.text.trim().to_owned());
372    }
373    for child in &element.children {
374        let value = text_content(child);
375        if !value.is_empty() {
376            parts.push(value);
377        }
378    }
379    parts.join(" ")
380}
381fn display_or_dash(value: &str) -> String {
382    if value.is_empty() {
383        "-".into()
384    } else {
385        truncate(value)
386    }
387}
388fn truncate(value: &str) -> String {
389    if value.len() <= MAX_BCF_DISPLAY_BYTES {
390        value.to_owned()
391    } else {
392        let mut end = MAX_BCF_DISPLAY_BYTES;
393        while !value.is_char_boundary(end) {
394            end -= 1;
395        }
396        format!("{}…", &value[..end])
397    }
398}
399fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
400    if rows.len() >= MAX_BCF_ROWS {
401        return Err(Error::LimitExceeded(format!(
402            "BCF rows exceed {MAX_BCF_ROWS}"
403        )));
404    }
405    rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
406    Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411    use super::looks_like_archive;
412    #[test]
413    fn missing_archive_is_not_bcf() {
414        assert!(!looks_like_archive(std::path::Path::new(
415            "does-not-exist.bcfzip"
416        )));
417    }
418}