use crate::error::{Error, Result};
use crate::model::{Extension, Model, ParserConfig};
use std::collections::HashSet;
pub fn validate_production_extension(model: &Model) -> Result<()> {
let validate_path = |path: &str, context: &str| -> Result<()> {
if !path.starts_with('/') {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must start with / (absolute path required)",
context, path
)));
}
if path.contains("..") {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must not contain .. (parent directory traversal not allowed)",
context, path
)));
}
if path.ends_with('/') {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must not end with / (must reference a file)",
context, path
)));
}
if let Some(filename) = path.rsplit('/').next()
&& filename.starts_with('.')
{
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' references a hidden file (filename cannot start with .)",
context, path
)));
}
if !path.ends_with(".model") {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must reference a .model file",
context, path
)));
}
Ok(())
};
for object in &model.resources.objects {
if let Some(ref prod_info) = object.production {
if let Some(ref path) = prod_info.path {
validate_path(path, &format!("Object {}", object.id))?;
}
}
for (idx, component) in object.components.iter().enumerate() {
if let Some(ref prod_info) = component.production {
if let Some(ref path) = prod_info.path {
validate_path(path, &format!("Object {}, Component {}", object.id, idx))?;
}
}
}
}
for (idx, item) in model.build.items.iter().enumerate() {
if let Some(ref path) = item.production_path {
validate_path(path, &format!("Build Item {}", idx))?;
}
}
Ok(())
}
pub fn validate_production_extension_with_config(
model: &Model,
config: &ParserConfig,
) -> Result<()> {
let has_production = model.required_extensions.contains(&Extension::Production);
let config_supports_production = config.supports(&Extension::Production);
let mut has_production_attrs = false;
let validate_path = |path: &str, context: &str| -> Result<()> {
if !path.starts_with('/') {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must start with / (absolute path required)",
context, path
)));
}
if path.contains("..") {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must not contain .. (parent directory traversal not allowed)",
context, path
)));
}
if path.ends_with('/') {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must not end with / (must reference a file)",
context, path
)));
}
if let Some(filename) = path.rsplit('/').next()
&& filename.starts_with('.')
{
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' references a hidden file (filename cannot start with .)",
context, path
)));
}
if !path.ends_with(".model") {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' must reference a .model file",
context, path
)));
}
Ok(())
};
for object in &model.resources.objects {
if let Some(ref prod_info) = object.production {
has_production_attrs = true;
if let Some(ref path) = prod_info.path {
validate_path(path, &format!("Object {}", object.id))?;
}
}
for (idx, component) in object.components.iter().enumerate() {
if let Some(ref prod_info) = component.production {
has_production_attrs = true;
if prod_info.path.is_some() && prod_info.uuid.is_none() {
return Err(Error::InvalidModel(format!(
"Object {}, Component {}: Component has p:path but missing required p:UUID.\n\
Per 3MF Production Extension spec, components with external references (p:path) \
must have p:UUID to identify the referenced object.\n\
Add p:UUID attribute to the component element.",
object.id, idx
)));
}
if let Some(ref path) = prod_info.path {
validate_path(path, &format!("Object {}, Component {}", object.id, idx))?;
}
}
}
}
for (idx, item) in model.build.items.iter().enumerate() {
if item.production_uuid.is_some() || item.production_path.is_some() {
has_production_attrs = true;
}
if let Some(ref path) = item.production_path {
validate_path(path, &format!("Build Item {}", idx))?;
}
}
if model.build.production_uuid.is_some() {
has_production_attrs = true;
}
if has_production_attrs && !has_production && !config_supports_production {
return Err(Error::InvalidModel(
"Production extension attributes (p:UUID, p:path) are used but production extension \
is not declared in requiredextensions.\n\
Per 3MF Production Extension specification, when using production attributes, \
you must add 'p' to the requiredextensions attribute in the <model> element.\n\
Example: requiredextensions=\"p\" or requiredextensions=\"m p\" for materials and production."
.to_string(),
));
}
Ok(())
}
pub fn validate_production_paths(model: &Model) -> Result<()> {
let validate_not_opc_internal = |path: &str, context: &str| -> Result<()> {
if path.starts_with("/_rels/") || path == "/_rels" {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' references OPC internal relationships directory.\n\
Production paths must not reference package internal files.",
context, path
)));
}
if path == "/[Content_Types].xml" {
return Err(Error::InvalidModel(format!(
"{}: Production path '{}' references OPC content types file.\n\
Production paths must not reference package internal files.",
context, path
)));
}
Ok(())
};
for object in &model.resources.objects {
if let Some(ref prod_info) = object.production
&& let Some(ref path) = prod_info.path
{
validate_not_opc_internal(path, &format!("Object {}", object.id))?;
}
for (idx, component) in object.components.iter().enumerate() {
if let Some(ref prod_info) = component.production
&& let Some(ref path) = prod_info.path
{
validate_not_opc_internal(
path,
&format!("Object {}, Component {}", object.id, idx),
)?;
}
}
}
for (idx, item) in model.build.items.iter().enumerate() {
if let Some(ref path) = item.production_path {
validate_not_opc_internal(path, &format!("Build item {}", idx))?;
}
}
Ok(())
}
pub fn validate_production_uuids_required(model: &Model) -> Result<()> {
let production_required = model.required_extensions.contains(&Extension::Production);
if !production_required {
return Ok(());
}
if !model.build.items.is_empty() && model.build.production_uuid.is_none() {
return Err(Error::InvalidModel(
"Production extension requires build to have p:UUID attribute when build items are present".to_string(),
));
}
for (idx, item) in model.build.items.iter().enumerate() {
if item.production_uuid.is_none() {
return Err(Error::InvalidModel(format!(
"Production extension requires build item {} to have p:UUID attribute",
idx
)));
}
}
for object in &model.resources.objects {
let has_uuid = object
.production
.as_ref()
.and_then(|p| p.uuid.as_ref())
.is_some();
if !has_uuid {
return Err(Error::InvalidModel(format!(
"Production extension requires object {} to have p:UUID attribute",
object.id
)));
}
}
Ok(())
}
pub fn validate_uuid_formats(model: &Model) -> Result<()> {
let validate_uuid = |uuid: &str, context: &str| -> Result<()> {
if uuid.len() != 36 {
return Err(Error::InvalidModel(format!(
"{}: Invalid UUID '{}' - must be 36 characters in format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
context, uuid
)));
}
if uuid.chars().nth(8) != Some('-')
|| uuid.chars().nth(13) != Some('-')
|| uuid.chars().nth(18) != Some('-')
|| uuid.chars().nth(23) != Some('-')
{
return Err(Error::InvalidModel(format!(
"{}: Invalid UUID '{}' - hyphens must be at positions 8, 13, 18, and 23",
context, uuid
)));
}
for (idx, ch) in uuid.chars().enumerate() {
if idx == 8 || idx == 13 || idx == 18 || idx == 23 {
continue; }
if !ch.is_ascii_hexdigit() {
return Err(Error::InvalidModel(format!(
"{}: Invalid UUID '{}' - character '{}' at position {} is not a hexadecimal digit",
context, uuid, ch, idx
)));
}
}
Ok(())
};
if let Some(ref uuid) = model.build.production_uuid {
validate_uuid(uuid, "Build")?;
}
for (idx, item) in model.build.items.iter().enumerate() {
if let Some(ref uuid) = item.production_uuid {
validate_uuid(uuid, &format!("Build item {}", idx))?;
}
}
for object in &model.resources.objects {
if let Some(ref prod_info) = object.production
&& let Some(ref uuid) = prod_info.uuid
{
validate_uuid(uuid, &format!("Object {}", object.id))?;
}
for (idx, component) in object.components.iter().enumerate() {
if let Some(ref prod_info) = component.production
&& let Some(ref uuid) = prod_info.uuid
{
validate_uuid(uuid, &format!("Object {}, Component {}", object.id, idx))?;
}
}
}
Ok(())
}
pub fn validate_duplicate_uuids(model: &Model) -> Result<()> {
let mut uuids = HashSet::new();
if let Some(ref uuid) = model.build.production_uuid
&& !uuids.insert(uuid.clone())
{
return Err(Error::InvalidModel(format!(
"Duplicate UUID '{}' found in build",
uuid
)));
}
for (idx, item) in model.build.items.iter().enumerate() {
if let Some(ref uuid) = item.production_uuid
&& !uuids.insert(uuid.clone())
{
return Err(Error::InvalidModel(format!(
"Duplicate UUID '{}' found in build item {}",
uuid, idx
)));
}
}
for object in &model.resources.objects {
if let Some(ref production) = object.production
&& let Some(ref uuid) = production.uuid
&& !uuids.insert(uuid.clone())
{
return Err(Error::InvalidModel(format!(
"Duplicate UUID '{}' found on object {}",
uuid, object.id
)));
}
for (comp_idx, component) in object.components.iter().enumerate() {
if let Some(ref production) = component.production
&& let Some(ref uuid) = production.uuid
&& !uuids.insert(uuid.clone())
{
return Err(Error::InvalidModel(format!(
"Duplicate UUID '{}' found in object {} component {}",
uuid, object.id, comp_idx
)));
}
}
}
Ok(())
}
pub fn validate_component_chain(_model: &Model) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{BuildItem, Component, Object, ProductionInfo};
#[test]
fn test_validate_empty_model() {
let model = Model::new();
assert!(validate_production_extension(&model).is_ok());
}
#[test]
fn test_validate_path_invalid_no_leading_slash() {
let mut model = Model::new();
let mut item = BuildItem::new(1);
item.production_path = Some("relative/path.3mf".to_string());
model.build.items.push(item);
let result = validate_production_extension(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("start with /"));
}
#[test]
fn test_validate_path_with_parent_dir() {
let mut model = Model::new();
let mut item = BuildItem::new(1);
item.production_path = Some("/../secret.3mf".to_string());
model.build.items.push(item);
let result = validate_production_extension(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains(".."));
}
#[test]
fn test_validate_path_with_trailing_slash() {
let mut model = Model::new();
let mut item = BuildItem::new(1);
item.production_path = Some("/3D/parts/".to_string());
model.build.items.push(item);
let result = validate_production_extension(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("end with /"));
}
#[test]
fn test_validate_valid_path() {
let mut model = Model::new();
let mut item = BuildItem::new(1);
item.production_path = Some("/3D/parts/part.model".to_string()); model.build.items.push(item);
assert!(validate_production_extension(&model).is_ok());
}
#[test]
fn test_validate_path_opc_rels_dir() {
let mut model = Model::new();
let mut item = BuildItem::new(1);
item.production_path = Some("/_rels/somefile".to_string());
model.build.items.push(item);
let result = validate_production_paths(&model);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("OPC internal relationships")
);
}
#[test]
fn test_validate_path_opc_content_types() {
let mut model = Model::new();
let mut item = BuildItem::new(1);
item.production_path = Some("/[Content_Types].xml".to_string());
model.build.items.push(item);
let result = validate_production_paths(&model);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("OPC content types")
);
}
#[test]
fn test_validate_invalid_uuid_format() {
let mut model = Model::new();
let mut obj = Object::new(1);
let mut info = ProductionInfo::default();
info.uuid = Some("not-a-valid-uuid".to_string());
obj.production = Some(info);
model.resources.objects.push(obj);
let result = validate_uuid_formats(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid UUID"));
}
#[test]
fn test_validate_valid_uuid_format() {
let mut model = Model::new();
let mut obj = Object::new(1);
let mut info = ProductionInfo::default();
info.uuid = Some("550e8400-e29b-41d4-a716-446655440000".to_string());
obj.production = Some(info);
model.resources.objects.push(obj);
assert!(validate_uuid_formats(&model).is_ok());
}
#[test]
fn test_validate_duplicate_uuids() {
let mut model = Model::new();
let uuid = "550e8400-e29b-41d4-a716-446655440000".to_string();
let mut obj1 = Object::new(1);
let mut info1 = ProductionInfo::default();
info1.uuid = Some(uuid.clone());
obj1.production = Some(info1);
model.resources.objects.push(obj1);
let mut obj2 = Object::new(2);
let mut info2 = ProductionInfo::default();
info2.uuid = Some(uuid.clone());
obj2.production = Some(info2);
model.resources.objects.push(obj2);
let result = validate_duplicate_uuids(&model);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Duplicate UUID"));
}
#[test]
fn test_validate_component_path_without_uuid() {
let mut model = Model::new();
model.required_extensions.push(Extension::Production);
let mut obj = Object::new(1);
let config = ParserConfig::default();
let mut prod_info = ProductionInfo::default();
prod_info.path = Some("/3D/part.3mf".to_string());
prod_info.uuid = None; let mut component = Component::new(2);
component.production = Some(prod_info);
obj.components.push(component);
model.resources.objects.push(obj);
let result = validate_production_extension_with_config(&model, &config);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("p:UUID"));
}
}