openbim-mmc 0.1.0

Pure-Rust MMC 2.0 archive reading, validation, extraction, and writing.
Documentation
use std::{collections::BTreeMap, io::Cursor};

use quick_xml::{
    events::{attributes::Attribute, BytesDecl, BytesEnd, BytesStart, BytesText, Event},
    Writer,
};
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};

use crate::{
    path::{collision_key, validate_archive_path},
    ApplicationModel, ContainerMetadata, LinkModel, LinkModelReference, MetadataEntry, MmcArchive,
    MmcError, ResourceLocation, LINK_MODEL_NAMESPACE, MMC_NAMESPACE,
};

/// Builder for new deterministic MMC 2.0 archives.
#[derive(Debug)]
pub struct MmcArchiveBuilder {
    metadata: ContainerMetadata,
    models: Vec<ApplicationModel>,
    link_models: Vec<(String, Vec<String>, LinkModel)>,
    payloads: BTreeMap<String, Vec<u8>>,
    collisions: BTreeMap<String, String>,
}

impl MmcArchiveBuilder {
    #[must_use]
    pub fn new(metadata: ContainerMetadata) -> Self {
        let mut collisions = BTreeMap::new();
        collisions.insert(collision_key("MultiModel.xml"), "MultiModel.xml".to_owned());
        Self {
            metadata,
            models: Vec::new(),
            link_models: Vec::new(),
            payloads: BTreeMap::new(),
            collisions,
        }
    }

    pub fn add_application_model(
        &mut self,
        model: ApplicationModel,
    ) -> Result<&mut Self, MmcError> {
        if model.id.is_empty() || model.model_type.is_empty() {
            return Err(MmcError::InvalidBuilder(
                "application models require non-empty id and model_type".to_owned(),
            ));
        }
        self.models.push(model);
        Ok(self)
    }

    pub fn add_embedded_resource(
        &mut self,
        path: impl Into<String>,
        bytes: Vec<u8>,
    ) -> Result<&mut Self, MmcError> {
        let path = path.into();
        self.reserve_path(&path)?;
        self.payloads.insert(path, bytes);
        Ok(self)
    }

    pub fn add_link_model(
        &mut self,
        path: impl Into<String>,
        linked_models: Vec<String>,
        model: LinkModel,
    ) -> Result<&mut Self, MmcError> {
        let path = path.into();
        self.reserve_path(&path)?;
        if linked_models.iter().any(String::is_empty) {
            return Err(MmcError::InvalidBuilder(
                "linked model identifiers must not be empty".to_owned(),
            ));
        }
        self.link_models.push((path, linked_models, model));
        Ok(self)
    }

    pub fn build(self) -> Result<MmcArchive, MmcError> {
        let references = self
            .link_models
            .iter()
            .map(|(path, linked_models, _)| LinkModelReference {
                location: ResourceLocation::Embedded(path.clone()),
                linked_models: linked_models.clone(),
                metadata: Vec::new(),
            })
            .collect::<Vec<_>>();
        let index = serialize_multimodel(&self.metadata, &self.models, &references)?;
        let mut entries = self.payloads;
        entries.insert("MultiModel.xml".to_owned(), index);
        for (path, _, model) in self.link_models {
            entries.insert(path, serialize_link_model(&model)?);
        }

        let bytes = deterministic_zip(&entries)?;
        let archive = MmcArchive::parse(bytes)?;
        let report = archive.validate();
        if !report.is_valid() {
            return Err(MmcError::InvalidBuilder(format!(
                "built archive is not conformant: {:?}",
                report.issues()
            )));
        }
        Ok(archive)
    }

    fn reserve_path(&mut self, path: &str) -> Result<(), MmcError> {
        validate_archive_path(path)?;
        let key = collision_key(path);
        if let Some(first) = self.collisions.insert(key, path.to_owned()) {
            return Err(MmcError::DuplicateArchivePath {
                first,
                second: path.to_owned(),
            });
        }
        Ok(())
    }
}

/// Builds an attribute whose value is XML-escaped and additionally numeric-escapes
/// TAB/LF/CR. XML 1.0 attribute-value normalization (§3.3.3) replaces *literal*
/// TAB/LF/CR in attribute values with a single space in every conformant parser;
/// character references are exempt from that normalization. Escaping them here
/// keeps values produced by this crate byte-identical after a parse/write/reparse
/// round trip through any standards-compliant XML consumer.
fn attr(name: &'static str, value: &str) -> Attribute<'static> {
    let escaped = quick_xml::escape::escape(value)
        .replace('\t', "&#x9;")
        .replace('\n', "&#xA;")
        .replace('\r', "&#xD;");
    Attribute {
        key: quick_xml::name::QName(name.as_bytes()),
        value: std::borrow::Cow::Owned(escaped.into_bytes()),
    }
}

/// Builds a text event whose value numeric-escapes carriage returns. XML 1.0
/// line-end normalization (§2.11) rewrites every literal CR (bare or as part
/// of CRLF) in the source into a single LF before an XML processor ever sees
/// character data — the CR is not merely translated, a bare "\r\n" collapses
/// to one "\n". Escaping "\r" here keeps `LinkedModel` text byte-identical
/// after a parse/write/reparse round trip through any standards-compliant
/// XML consumer. `\n` and `\t` are left as literal text: neither is
/// normalized outside of attribute values.
fn text_normalizing_cr(value: &str) -> BytesText<'static> {
    let escaped = quick_xml::escape::escape(value).replace('\r', "&#xD;");
    BytesText::from_escaped(escaped)
}

fn serialize_multimodel(
    metadata: &ContainerMetadata,
    models: &[ApplicationModel],
    link_models: &[LinkModelReference],
) -> Result<Vec<u8>, MmcError> {
    let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
    writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
    let mut root = BytesStart::new("mmc:MultiModel");
    root.push_attribute(("xmlns:mmc", MMC_NAMESPACE));
    let uuid = metadata.uuid.to_string();
    root.push_attribute(attr("uuid", uuid.as_str()));
    root.push_attribute(attr("formatVersion", metadata.format_version.as_str()));
    if let Some(domain) = &metadata.mm_domain {
        root.push_attribute(attr("mmDomain", domain.as_str()));
    }
    writer.write_event(Event::Start(root))?;
    write_metadata(&mut writer, "mmc", &metadata.metadata, false)?;

    writer.write_event(Event::Start(BytesStart::new("mmc:ApplicationModels")))?;
    for model in models {
        let mut element = BytesStart::new("mmc:ApplicationModel");
        element.push_attribute(attr("id", model.id.as_str()));
        element.push_attribute(attr("modelType", model.model_type.as_str()));
        writer.write_event(Event::Start(element))?;
        write_metadata(&mut writer, "mmc", &model.metadata, false)?;
        for representation in &model.representations {
            let mut element = BytesStart::new("mmc:ModelData");
            element.push_attribute(attr("id", representation.id.as_str()));
            element.push_attribute(attr("formatType", representation.format_type.as_str()));
            if let Some(version) = &representation.format_version {
                element.push_attribute(attr("formatVersion", version.as_str()));
            }
            writer.write_event(Event::Start(element))?;
            write_metadata(&mut writer, "mmc", &representation.metadata, false)?;
            for resource in &representation.resources {
                let mut element = BytesStart::new("mmc:DataRessource");
                element.push_attribute(attr("id", resource.id.as_str()));
                match &resource.location {
                    ResourceLocation::Embedded(path) | ResourceLocation::External(path) => {
                        element.push_attribute(attr("location", path.as_str()));
                    }
                }
                if resource.metadata.is_empty() {
                    writer.write_event(Event::Empty(element))?;
                } else {
                    writer.write_event(Event::Start(element))?;
                    write_metadata(&mut writer, "mmc", &resource.metadata, false)?;
                    writer.write_event(Event::End(BytesEnd::new("mmc:DataRessource")))?;
                }
            }
            writer.write_event(Event::End(BytesEnd::new("mmc:ModelData")))?;
        }
        writer.write_event(Event::End(BytesEnd::new("mmc:ApplicationModel")))?;
    }
    writer.write_event(Event::End(BytesEnd::new("mmc:ApplicationModels")))?;

    if !link_models.is_empty() {
        writer.write_event(Event::Start(BytesStart::new("mmc:LinkModels")))?;
        for reference in link_models {
            let mut element = BytesStart::new("mmc:LinkModel");
            match &reference.location {
                ResourceLocation::Embedded(path) | ResourceLocation::External(path) => {
                    element.push_attribute(attr("location", path.as_str()));
                }
            }
            writer.write_event(Event::Start(element))?;
            write_metadata(&mut writer, "mmc", &reference.metadata, false)?;
            for model in &reference.linked_models {
                writer.write_event(Event::Start(BytesStart::new("mmc:LinkedModel")))?;
                writer.write_event(Event::Text(text_normalizing_cr(model)))?;
                writer.write_event(Event::End(BytesEnd::new("mmc:LinkedModel")))?;
            }
            writer.write_event(Event::End(BytesEnd::new("mmc:LinkModel")))?;
        }
        writer.write_event(Event::End(BytesEnd::new("mmc:LinkModels")))?;
    }
    writer.write_event(Event::End(BytesEnd::new("mmc:MultiModel")))?;
    Ok(writer.into_inner())
}

fn serialize_link_model(model: &LinkModel) -> Result<Vec<u8>, MmcError> {
    let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
    writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
    let mut root = BytesStart::new("link:LinkModel");
    root.push_attribute(("xmlns:link", LINK_MODEL_NAMESPACE));
    root.push_attribute(attr("formatVersion", model.format_version.as_str()));
    writer.write_event(Event::Start(root))?;
    write_metadata(&mut writer, "link", &model.metadata, true)?;
    for link in &model.links {
        writer.write_event(Event::Start(BytesStart::new("link:Link")))?;
        write_metadata(&mut writer, "link", &link.metadata, true)?;
        for relatum in &link.relata {
            let mut element = BytesStart::new("link:Relatum");
            element.push_attribute(attr("id", relatum.element_id.as_str()));
            element.push_attribute(attr("m", relatum.model_id.as_str()));
            if let Some(format_id) = &relatum.format_id {
                element.push_attribute(attr("f", format_id.as_str()));
            }
            if let Some(resource_id) = &relatum.resource_id {
                element.push_attribute(attr("r", resource_id.as_str()));
            }
            if relatum.metadata.is_empty() && relatum.rates.is_empty() {
                writer.write_event(Event::Empty(element))?;
            } else {
                writer.write_event(Event::Start(element))?;
                write_metadata(&mut writer, "link", &relatum.metadata, true)?;
                for rate in &relatum.rates {
                    let mut rate_element = BytesStart::new("link:Rate");
                    rate_element.push_attribute(attr("t", rate.rate_type.as_str()));
                    rate_element.push_attribute(attr("v", rate.value.as_str()));
                    rate_element.push_attribute(attr("m", rate.target_model.as_str()));
                    writer.write_event(Event::Empty(rate_element))?;
                }
                writer.write_event(Event::End(BytesEnd::new("link:Relatum")))?;
            }
        }
        writer.write_event(Event::End(BytesEnd::new("link:Link")))?;
    }
    writer.write_event(Event::End(BytesEnd::new("link:LinkModel")))?;
    Ok(writer.into_inner())
}

fn write_metadata(
    writer: &mut Writer<Vec<u8>>,
    prefix: &str,
    metadata: &[MetadataEntry],
    compact_attributes: bool,
) -> Result<(), MmcError> {
    if metadata.is_empty() {
        return Ok(());
    }
    let collection = format!("{prefix}:MetaData");
    let item_name = format!("{prefix}:Meta");
    writer.write_event(Event::Start(BytesStart::new(collection.as_str())))?;
    for item in metadata {
        let mut element = BytesStart::new(item_name.as_str());
        let (key, value, value_type, category) = if compact_attributes {
            ("k", "v", "t", "c")
        } else {
            ("key", "value", "type", "category")
        };
        element.push_attribute(attr(key, item.key.as_str()));
        element.push_attribute(attr(value, item.value.as_str()));
        if let Some(kind) = &item.value_type {
            element.push_attribute(attr(value_type, kind.as_str()));
        }
        if let Some(group) = &item.category {
            element.push_attribute(attr(category, group.as_str()));
        }
        writer.write_event(Event::Empty(element))?;
    }
    writer.write_event(Event::End(BytesEnd::new(collection.as_str())))?;
    Ok(())
}

fn deterministic_zip(entries: &BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>, MmcError> {
    let mut output = Cursor::new(Vec::new());
    {
        let mut writer = ZipWriter::new(&mut output);
        let options = SimpleFileOptions::default()
            .compression_method(CompressionMethod::Deflated)
            .unix_permissions(0o100644);
        for (path, bytes) in entries {
            writer.start_file(path, options)?;
            std::io::Write::write_all(&mut writer, bytes)?;
        }
        writer.finish()?;
    }
    Ok(output.into_inner())
}