use crate::diagnostic::{Diagnostic, Tolerance};
use crate::error::BcfError;
use crate::markup::{self, Markup};
use crate::version::BcfVersion;
use crate::xml;
use openbim_core::Detected;
use std::collections::HashMap;
use std::io::{Read, Seek};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub max_total_uncompressed: u64,
pub max_entry_uncompressed: u64,
pub max_entries: u64,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_total_uncompressed: 512 * 1024 * 1024,
max_entry_uncompressed: 32 * 1024 * 1024,
max_entries: 100_000,
}
}
}
#[derive(Debug, Clone)]
pub struct BcfArchive {
version: Detected<BcfVersion>,
markups: Vec<Markup>,
entries: Vec<String>,
diagnostics: Vec<Diagnostic>,
}
impl BcfArchive {
#[must_use]
pub fn version(&self) -> &Detected<BcfVersion> {
&self.version
}
pub fn topics(&self) -> impl Iterator<Item = &Markup> {
self.markups.iter()
}
#[must_use]
pub fn topic_count(&self) -> usize {
self.markups.len()
}
#[must_use]
pub fn entries(&self) -> &[String] {
&self.entries
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
pub(crate) fn from_zip<R: Read + Seek>(
mut zip: zip::ZipArchive<R>,
limits: Limits,
) -> Result<Self, BcfError> {
let mut diagnostics = Vec::new();
let scan = scan_entries(&mut zip, limits, &mut diagnostics)?;
let declared = read_declared_version(&mut zip, &scan, limits, &mut diagnostics);
let (mut markups, observed) = read_markups(&mut zip, &scan, limits, &mut diagnostics);
if markups.is_empty() {
return Err(BcfError::NoTopics);
}
let version = BcfVersion::reconcile(declared, observed);
if let Detected::Conflict { declared, observed } = version {
diagnostics.push(Diagnostic::in_archive(Tolerance::VersionConflict {
declared,
observed,
}));
}
diagnostics.extend(dangling_references(&markups, &scan.entries));
read_visualizations(
&mut zip,
&scan.entries,
limits,
&mut markups,
&mut diagnostics,
);
Ok(Self {
version,
markups,
entries: scan.entries,
diagnostics,
})
}
}
struct Scan {
entries: Vec<String>,
markups: Vec<(usize, String)>,
version: Option<(usize, String)>,
}
fn scan_entries<R: Read + Seek>(
zip: &mut zip::ZipArchive<R>,
limits: Limits,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<Scan, BcfError> {
let count = zip.len() as u64;
if count > limits.max_entries {
return Err(BcfError::LimitExceeded {
limit: "entry count",
allowed: limits.max_entries,
requested: count,
});
}
let mut scan = Scan {
entries: Vec::with_capacity(zip.len()),
markups: Vec::new(),
version: None,
};
let mut declared_total: u64 = 0;
for i in 0..zip.len() {
let file = zip.by_index_raw(i).map_err(zip_err)?;
let raw = file.name().to_string();
if raw.contains('\\') {
diagnostics.push(Diagnostic::in_archive(Tolerance::BackslashSeparator {
name: raw.clone(),
}));
}
if file.enclosed_name().is_none() {
return Err(BcfError::UnsafeEntry { name: raw });
}
let size = file.size();
drop(file);
let name = raw.replace('\\', "/");
declared_total = declared_total.saturating_add(size);
if declared_total > limits.max_total_uncompressed {
return Err(BcfError::LimitExceeded {
limit: "uncompressed size",
allowed: limits.max_total_uncompressed,
requested: declared_total,
});
}
let lower = name.to_ascii_lowercase();
#[allow(clippy::case_sensitive_file_extension_comparisons)]
if lower.ends_with(".bcf") {
scan.markups.push((i, name.clone()));
} else if lower.ends_with("bcf.version") {
scan.version = Some((i, name.clone()));
}
scan.entries.push(name);
}
Ok(scan)
}
fn read_declared_version<R: Read + Seek>(
zip: &mut zip::ZipArchive<R>,
scan: &Scan,
limits: Limits,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<BcfVersion> {
let Some((index, name)) = &scan.version else {
diagnostics.push(Diagnostic::in_archive(Tolerance::MissingVersionEntry));
return None;
};
let parsed = read_entry(zip, *index, limits)
.ok()
.and_then(|bytes| xml::parse(&bytes).ok());
let Some(node) = parsed else {
diagnostics.push(Diagnostic::in_archive(Tolerance::MissingVersionEntry));
return None;
};
let raw = node.attr("VersionId").unwrap_or_default().to_string();
if let Some(v) = BcfVersion::from_version_id(&raw) {
Some(v)
} else {
diagnostics.push(Diagnostic::in_entry(
name.clone(),
Tolerance::UnknownVersionId { raw },
));
None
}
}
fn read_markups<R: Read + Seek>(
zip: &mut zip::ZipArchive<R>,
scan: &Scan,
limits: Limits,
diagnostics: &mut Vec<Diagnostic>,
) -> (Vec<Markup>, Option<BcfVersion>) {
let mut markups = Vec::new();
let mut observed: Option<BcfVersion> = None;
for (index, name) in &scan.markups {
let bytes = match read_entry(zip, *index, limits) {
Ok(b) => b,
Err(e) => {
diagnostics.push(Diagnostic::in_entry(
name.clone(),
Tolerance::UnreadableMarkup {
detail: e.to_string(),
},
));
continue;
}
};
let tree = match xml::parse(&bytes) {
Ok(t) => t,
Err(detail) => {
diagnostics.push(Diagnostic::in_entry(
name.clone(),
Tolerance::UnreadableMarkup { detail },
));
continue;
}
};
if let Some(v) = markup::observe_version(&tree) {
observed = Some(observed.map_or(v, |prev| prev.min(v)));
}
if let Some(m) = markup::interpret(name, &tree, diagnostics) {
markups.push(m);
}
}
(markups, observed)
}
fn dangling_references(markups: &[Markup], entries: &[String]) -> Vec<Diagnostic> {
let known: HashMap<&str, usize> = entry_index(entries);
let mut out = Vec::new();
for m in markups {
for vp in &m.viewpoints {
for target in [vp.viewpoint.as_deref(), vp.snapshot.as_deref()]
.into_iter()
.flatten()
{
if resolve(&known, &m.entry, target).is_none() {
out.push(Diagnostic::in_entry(
m.entry.clone(),
Tolerance::DanglingReference {
target: joined(&m.entry, target),
},
));
}
}
}
}
out
}
fn read_visualizations<R: Read + Seek>(
zip: &mut zip::ZipArchive<R>,
entries: &[String],
limits: Limits,
markups: &mut [Markup],
diagnostics: &mut Vec<Diagnostic>,
) {
let known = entry_index(entries);
for m in markups.iter_mut() {
for vp in &mut m.viewpoints {
let Some(target) = vp.viewpoint.as_deref() else {
continue;
};
let Some((index, name)) = resolve(&known, &m.entry, target) else {
continue;
};
let parsed = read_entry(zip, index, limits)
.map_err(|e| e.to_string())
.and_then(|bytes| xml::parse(&bytes))
.and_then(|tree| markup::interpret_visualization(&tree));
match parsed {
Ok(v) => vp.visualization = Some(v),
Err(detail) => diagnostics.push(Diagnostic::in_entry(
m.entry.clone(),
Tolerance::UnreadableViewpoint {
target: name.to_string(),
detail,
},
)),
}
}
}
}
fn entry_index(entries: &[String]) -> HashMap<&str, usize> {
entries
.iter()
.enumerate()
.map(|(i, e)| (e.as_str(), i))
.collect()
}
fn joined(markup_entry: &str, target: &str) -> String {
match markup_entry.rsplit_once('/') {
Some((dir, _)) if !dir.is_empty() => format!("{dir}/{target}"),
_ => target.to_string(),
}
}
fn resolve<'a>(
known: &HashMap<&'a str, usize>,
markup_entry: &str,
target: &str,
) -> Option<(usize, &'a str)> {
let relative = joined(markup_entry, target);
known
.get_key_value(relative.as_str())
.or_else(|| known.get_key_value(target))
.map(|(name, index)| (*index, *name))
}
fn read_entry<R: Read + Seek>(
zip: &mut zip::ZipArchive<R>,
index: usize,
limits: Limits,
) -> Result<Vec<u8>, BcfError> {
let mut file = zip.by_index(index).map_err(zip_err)?;
if file.size() > limits.max_entry_uncompressed {
return Err(BcfError::LimitExceeded {
limit: "entry size",
allowed: limits.max_entry_uncompressed,
requested: file.size(),
});
}
let cap = limits.max_entry_uncompressed;
let mut buf = Vec::with_capacity(file.size().min(1 << 20) as usize);
let read = (&mut file).take(cap + 1).read_to_end(&mut buf)?;
if read as u64 > cap {
return Err(BcfError::LimitExceeded {
limit: "entry size",
allowed: cap,
requested: read as u64,
});
}
Ok(buf)
}
fn zip_err(e: zip::result::ZipError) -> BcfError {
match e {
zip::result::ZipError::Io(source) => BcfError::Io { path: None, source },
other => BcfError::NotAnArchive {
detail: other.to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_limits_are_bounded_and_ordered() {
let l = Limits::default();
assert!(l.max_entry_uncompressed < l.max_total_uncompressed);
assert!(l.max_entries > 0);
}
}