use super::container::{self, ContainerZip};
use super::error::IcddError;
use super::schema::*;
use super::{index, linkset, vocab};
use std::fs::File;
use std::io::{BufReader, Cursor, Read, Seek};
use std::path::Path;
pub struct IcddContainer<R: Read + Seek> {
zip: ContainerZip<R>,
index: ContainerIndex,
linksets: Vec<LinkSet>,
top_folders: Vec<String>,
}
impl IcddContainer<BufReader<File>> {
pub fn open_path(path: impl AsRef<Path>) -> Result<Self, IcddError> {
let f = File::open(path)?;
Self::open(BufReader::new(f))
}
}
fn safe_relative_payload_path(path: &str) -> Result<std::path::PathBuf, IcddError> {
use std::path::{Component, Path, PathBuf};
let normalized = path.replace('\\', "/");
let mut safe = PathBuf::new();
for component in Path::new(&normalized).components() {
match component {
Component::Normal(segment) => safe.push(segment),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(IcddError::NotConformant(format!(
"unsafe payload path: {path}"
)));
}
}
}
if safe.as_os_str().is_empty() {
return Err(IcddError::NotConformant("empty payload path".into()));
}
Ok(safe)
}
fn open_safe_payload_target(
root: &Path,
relative: &Path,
) -> Result<(File, std::path::PathBuf), IcddError> {
std::fs::create_dir_all(root)?;
let root_metadata = std::fs::symlink_metadata(root)?;
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
return Err(IcddError::NotConformant(format!(
"extraction root is not a real directory: {}",
root.display()
)));
}
let mut current = root.to_path_buf();
if let Some(parent) = relative.parent() {
for component in parent.components() {
current.push(component.as_os_str());
match std::fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
return Err(IcddError::NotConformant(format!(
"payload extraction path crosses a non-directory or symlink: {}",
current.display()
)));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir(¤t)?;
}
Err(error) => return Err(error.into()),
}
}
}
let target = root.join(relative);
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target)?;
Ok((file, target))
}
impl IcddContainer<Cursor<Vec<u8>>> {
pub fn open_bytes(bytes: Vec<u8>) -> Result<Self, IcddError> {
Self::open(Cursor::new(bytes))
}
}
impl<R: Read + Seek> IcddContainer<R> {
pub fn open(reader: R) -> Result<Self, IcddError> {
let mut zip = ContainerZip::open(reader)?;
let top_folders = zip.top_folders();
let index_bytes = zip.index_bytes()?;
let index = index::parse_index(&index_bytes)?;
for document in &index.documents {
if let Some(filename) = document.internal_path() {
zip.validate_payload_reference(filename)?;
}
}
let linkset_paths: Vec<String> = index
.linkset_files
.iter()
.map(|linkset| {
linkset.filename.clone().ok_or_else(|| {
IcddError::NotConformant(format!("linkset {} has no ct:filename", linkset.id))
})
})
.collect::<Result<_, _>>()?;
if linkset_paths.len() > container::MAX_LINKSET_COUNT {
return Err(IcddError::NotConformant(format!(
"container declares {} linksets, exceeding the {}-linkset limit",
linkset_paths.len(),
container::MAX_LINKSET_COUNT
)));
}
let mut linksets = Vec::new();
let mut total_rdf_bytes = index_bytes.len() as u64;
for name in linkset_paths {
let bytes = zip.linkset_bytes(&name)?;
total_rdf_bytes = total_rdf_bytes
.checked_add(bytes.len() as u64)
.ok_or_else(|| IcddError::NotConformant("total RDF size overflow".into()))?;
if total_rdf_bytes > container::MAX_TOTAL_RDF_BYTES {
return Err(IcddError::NotConformant(format!(
"container RDF metadata exceeds the {}-byte total limit",
container::MAX_TOTAL_RDF_BYTES
)));
}
linksets.push(linkset::parse_linkset(&name, &bytes)?);
}
Ok(IcddContainer {
zip,
index,
linksets,
top_folders,
})
}
pub fn index_rdf_bytes(&mut self) -> Result<Vec<u8>, IcddError> {
self.zip.index_bytes()
}
pub fn linkset_rdf_bytes(&mut self, filename: &str) -> Result<Vec<u8>, IcddError> {
self.zip.linkset_bytes(filename)
}
pub fn index(&self) -> &ContainerIndex {
&self.index
}
pub fn linksets(&self) -> &[LinkSet] {
&self.linksets
}
pub fn top_folders(&self) -> &[String] {
&self.top_folders
}
pub(crate) fn contains_internal_payload(&self, filename: &str) -> bool {
self.zip.contains_payload(filename)
}
pub fn ifc_documents(&self) -> Vec<&Document> {
self.index
.documents
.iter()
.filter(|document| {
document.is_ifc()
&& !document.requested
&& document
.internal_path()
.is_some_and(|path| self.zip.contains_payload(path))
})
.collect()
}
pub fn ifc_documents_including_requested(&self) -> Vec<&Document> {
self.index.documents.iter().filter(|d| d.is_ifc()).collect()
}
pub fn payload_bytes(&mut self, doc: &Document) -> Result<Vec<u8>, IcddError> {
let path = doc.internal_path().ok_or_else(|| {
IcddError::NotConformant(format!(
"document {} is not an internal document (no in-container bytes)",
doc.id
))
})?;
self.zip.payload_bytes(path)
}
pub fn copy_payload_to(
&mut self,
doc: &Document,
writer: &mut impl std::io::Write,
) -> Result<u64, IcddError> {
let path = doc.internal_path().ok_or_else(|| {
IcddError::NotConformant(format!(
"document {} is not an internal document (no in-container bytes)",
doc.id
))
})?;
self.zip.copy_payload_to(path, writer)
}
pub fn extract_payloads(
&mut self,
dir: impl AsRef<Path>,
) -> Result<Vec<(String, std::path::PathBuf)>, IcddError> {
let dir = dir.as_ref();
let docs: Vec<(String, String)> = self
.index
.documents
.iter()
.filter_map(|d| d.internal_path().map(|p| (d.id.clone(), p.to_string())))
.collect();
let mut out = Vec::new();
let mut total_extracted = 0_u64;
for (id, rel) in docs {
let relative = safe_relative_payload_path(&rel)?;
let (mut file, target) = open_safe_payload_target(dir, &relative)?;
let copied = match self.zip.copy_payload_to(&rel, &mut file) {
Ok(copied) => copied,
Err(error) => {
drop(file);
let _ = std::fs::remove_file(&target);
return Err(error);
}
};
total_extracted = total_extracted.checked_add(copied).ok_or_else(|| {
IcddError::NotConformant("total extracted payload size overflow".into())
})?;
if total_extracted > container::MAX_TOTAL_EXTRACTED_BYTES {
drop(file);
let _ = std::fs::remove_file(&target);
return Err(IcddError::NotConformant(format!(
"extracted payloads exceed the {}-byte total limit",
container::MAX_TOTAL_EXTRACTED_BYTES
)));
}
out.push((id, target));
}
Ok(out)
}
pub fn conformance_issues(&self) -> Vec<String> {
let mut issues = Vec::new();
for folder in [
container::ONTOLOGY_DIR,
container::PAYLOAD_DOCS_DIR,
container::PAYLOAD_TRIPLES_DIR,
] {
if !self.top_folders.iter().any(|f| f == folder) {
issues.push(format!("missing top-level folder '{folder}'"));
}
}
for document in &self.index.documents {
if document.requested {
continue;
}
if let DocumentKind::Internal { filename } = &document.kind {
if !self.zip.contains_payload(filename) {
issues.push(format!("missing declared payload document: {filename}"));
}
}
}
match &self.index.description.conformance_indicator {
Some(v) if v == vocab::CONFORMANCE_INDICATOR => {}
Some(v) => issues.push(format!(
"ct:conformanceIndicator is '{v}', expected '{}'",
vocab::CONFORMANCE_INDICATOR
)),
None => issues.push("ct:conformanceIndicator is absent".into()),
}
issues
}
}