use cadmpeg_core::container::ContainerRole;
use std::fmt::Write;
use std::path::Path;
use cadmpeg_container::ArchiveSnapshot;
use cadmpeg_core::decode::{u64_from_index, DecodeContext, View};
use cadmpeg_core::CodecError;
pub(crate) mod recovery;
pub(crate) const ROOT_NAME: &str = "ISO-10303.p21";
#[derive(Debug, Clone, PartialEq, Eq)]
enum ReferenceTarget<'a> {
Internal {
member: String,
query: Option<&'a str>,
fragment: Option<&'a str>,
},
External,
}
pub(crate) fn has_zip_magic(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04")
}
pub(crate) fn has_root_marker(
ctx: &DecodeContext<'_>,
prefix: View<'_>,
) -> Result<bool, CodecError> {
ctx.charge_work(
u64_from_index(prefix.window().len().min(4)),
"STEP ZIP detection magic",
)?;
if !has_zip_magic(prefix.window()) && !ArchiveSnapshot::has_footer(ctx, prefix.window())? {
return Ok(false);
}
match ArchiveSnapshot::contains_matching_name(ctx, prefix, |name| name == ROOT_NAME) {
Ok(found) => Ok(found),
Err(error @ CodecError::ResourceLimit(_)) => Err(error),
Err(_) => Ok(false),
}
}
pub(crate) struct OpenedRoot<'a> {
pub(crate) archive: ArchiveSnapshot<'a>,
pub(crate) view: View<'a>,
pub(crate) data_start: u64,
}
pub(crate) fn open_root<'a>(
ctx: &DecodeContext<'a>,
root: View<'a>,
) -> Result<OpenedRoot<'a>, CodecError> {
let archive = ArchiveSnapshot::new(ctx, root)?;
for entry in archive.entries() {
validate_entry_name(&entry.name)?;
}
let root_entry = archive.entry(ROOT_NAME).ok_or_else(|| {
CodecError::WrongFormat(format!("STEP ZIP has no required root {ROOT_NAME}"))
})?;
let data_start = root_entry
.data_start
.ok_or_else(|| CodecError::malformed("STEP ZIP root has no readable local frame"))?;
let root_view = archive.open(ctx, &root_entry.name)?;
Ok(OpenedRoot {
archive,
view: root_view,
data_start,
})
}
fn resolve_uri<'a>(
ctx: &DecodeContext<'_>,
member_bytes: &mut cadmpeg_core::decode::ScopedReservation<'_>,
base_member: &'a str,
uri: &'a str,
) -> Result<ReferenceTarget<'a>, CodecError> {
if has_uri_scheme(uri) || uri.starts_with("//") {
return Ok(ReferenceTarget::External);
}
let (uri, fragment) = uri
.split_once('#')
.map_or((uri, None), |(uri, fragment)| (uri, Some(fragment)));
if fragment.is_some_and(|fragment| fragment.contains('#')) {
return Err(CodecError::malformed(format_args!(
"invalid STEP ZIP URI fragment {uri:?}"
)));
}
let (path, query) = uri
.split_once('?')
.map_or((uri, None), |(path, query)| (path, Some(query)));
if path.starts_with('/') {
return Err(CodecError::malformed(format_args!(
"STEP ZIP URI escapes the archive root: {uri:?}"
)));
}
let mut components = Vec::new();
let mut component_bytes = ctx.reserve_scoped(0, "step_zip_uri_components_temp")?;
if let Some((directory, _)) = base_member.rsplit_once('/') {
for component in directory.split('/') {
ctx.push_scoped_vec(
&mut component_bytes,
&mut components,
component,
"step_zip_uri_components",
)?;
}
}
if path.is_empty() {
let member = ctx.copy_scoped_text(base_member, member_bytes, "step_zip_uri_member")?;
return Ok(ReferenceTarget::Internal {
member,
query,
fragment,
});
}
for component in path.split('/') {
match component {
"" => {
return Err(CodecError::malformed(format_args!(
"invalid empty path component in STEP ZIP URI {uri:?}"
)));
}
"." => {}
".." => {
if components.pop().is_none() {
return Err(CodecError::malformed(format_args!(
"STEP ZIP URI escapes the archive root: {uri:?}"
)));
}
}
component => ctx.push_scoped_vec(
&mut component_bytes,
&mut components,
component,
"step_zip_uri_components",
)?,
}
}
if components.is_empty() {
return Err(CodecError::malformed(format_args!(
"STEP ZIP URI resolves to no member: {uri:?}"
)));
}
let member_len = components
.iter()
.try_fold(0_usize, |total, component| {
total.checked_add(component.len())
})
.and_then(|total| total.checked_add(components.len() - 1))
.ok_or_else(|| ctx.refuse_codec_limit("step_zip_uri_member", 0, 1))?;
let mut member = String::new();
ctx.reserve_scoped_string(member_bytes, &mut member, member_len, "step_zip_uri_member")?;
for (index, component) in components.iter().enumerate() {
if index != 0 {
member.push('/');
}
member.push_str(component);
}
Ok(ReferenceTarget::Internal {
member,
query,
fragment,
})
}
pub(crate) fn root_reference_notes(
ctx: &DecodeContext<'_>,
archive: &ArchiveSnapshot<'_>,
exchange: &crate::parse::Exchange,
) -> Result<Vec<String>, CodecError> {
let mut notes = Vec::new();
for reference in exchange.references() {
let name = reference.name;
let uri = forwarded_reference_uri(exchange, &reference.uri, ctx)?;
let mut member_bytes = ctx.reserve_scoped(0, "step_zip_uri_member_temp")?;
match resolve_uri(ctx, &mut member_bytes, ROOT_NAME, uri)? {
ReferenceTarget::Internal {
member,
query,
fragment,
} => {
if archive.entry(&member).is_none() {
return Err(CodecError::malformed(format_args!(
"STEP ZIP resource {uri:?} for {name} has no archive member {member:?}"
)));
}
push_reference_note(
ctx,
&mut notes,
"internal resource ",
name,
&member,
query,
fragment,
)?;
}
ReferenceTarget::External => {
push_reference_note(ctx, &mut notes, "external resource ", name, uri, None, None)?;
}
}
}
Ok(notes)
}
fn push_reference_note(
ctx: &DecodeContext<'_>,
notes: &mut Vec<String>,
prefix: &str,
name: crate::parse::ReferenceName,
target: &str,
query: Option<&str>,
fragment: Option<&str>,
) -> Result<(), CodecError> {
let (marker, mut id) = match name {
crate::parse::ReferenceName::Entity(id) => ('#', id),
crate::parse::ReferenceName::Value(id) => ('@', id),
};
let mut digits = 1_usize;
while id >= 10 {
id /= 10;
digits += 1;
}
let query_len = query
.map_or(Some(0), |text| text.len().checked_add(1))
.ok_or_else(|| ctx.refuse_codec_limit("step_zip_reference_note", 0, 1))?;
let fragment_len = fragment
.map_or(Some(0), |text| text.len().checked_add(1))
.ok_or_else(|| ctx.refuse_codec_limit("step_zip_reference_note", 0, 1))?;
let len = prefix
.len()
.checked_add(1 + digits + " -> ".len())
.and_then(|len| len.checked_add(target.len()))
.and_then(|len| len.checked_add(query_len))
.and_then(|len| len.checked_add(fragment_len))
.ok_or_else(|| ctx.refuse_codec_limit("step_zip_reference_note", 0, 1))?;
ctx.reserve_vec(notes, 1, "step_zip_reference_notes")?;
let mut note = ctx.retained_string(len, "step_zip_reference_note")?;
note.push_str(prefix);
note.push(marker);
let id = match name {
crate::parse::ReferenceName::Entity(id) | crate::parse::ReferenceName::Value(id) => id,
};
write!(&mut note, "{id}")
.map_err(|_| ctx.refuse_codec_limit("step_zip_reference_note", 0, 1))?;
note.push_str(" -> ");
note.push_str(target);
if let Some(query) = query {
note.push('?');
note.push_str(query);
}
if let Some(fragment) = fragment {
note.push('#');
note.push_str(fragment);
}
notes.push(note);
Ok(())
}
fn forwarded_reference_uri<'a>(
exchange: &'a crate::parse::Exchange,
uri: &'a str,
ctx: &DecodeContext<'_>,
) -> Result<&'a str, CodecError> {
let Some(fragment) = uri.strip_prefix('#') else {
return Ok(uri);
};
for anchor in exchange.anchors() {
ctx.charge_work(1, "step_zip_anchor_lookup")?;
if anchor.name.len() != fragment.len() {
continue;
}
ctx.charge_work(u64_from_index(fragment.len()), "step_zip_anchor_lookup")?;
if anchor.name == fragment {
return Ok(match &anchor.value {
crate::parse::Value::Resource(target) => target.as_str(),
_ => uri,
});
}
}
Ok(uri)
}
fn has_uri_scheme(uri: &str) -> bool {
let Some(colon) = uri.find(':') else {
return false;
};
let scheme = &uri[..colon];
!scheme.is_empty()
&& scheme.as_bytes()[0].is_ascii_alphabetic()
&& scheme
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
}
pub(crate) fn classify_entry(name: &str) -> ContainerRole {
match name {
ROOT_NAME => ContainerRole::RootExchange,
_ if name.ends_with('/') => ContainerRole::Directory,
_ if extension_is(name, "p21")
|| extension_is(name, "step")
|| extension_is(name, "stp") =>
{
ContainerRole::SubsidiaryExchange
}
_ if extension_is(name, "zip") => ContainerRole::NestedArchive,
_ => ContainerRole::Ancillary,
}
}
fn extension_is(name: &str, expected: &str) -> bool {
Path::new(name)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
}
fn validate_entry_name(name: &str) -> Result<(), CodecError> {
if name.is_empty()
|| name.starts_with('/')
|| name.contains('\\')
|| name.contains('\0')
|| name.split('/').enumerate().any(|(index, component)| {
component.is_empty() && !(index == name.split('/').count() - 1 && name.ends_with('/'))
|| component == "."
|| component == ".."
})
{
return Err(CodecError::malformed(format_args!(
"unsafe STEP ZIP entry path {name:?}"
)));
}
Ok(())
}
#[cfg(test)]
pub(crate) mod tests;