1use 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}