//! `.ppd` project manifest: one JSON file in a project directory. //! //! Version 1 stores paths and render settings. It does not import models and //! it does not render paperdolls. Unknown JSON fields are rejected so a save //! cannot drop them. A newer or older `format_version` is rejected first. use std::fs::{self, File}; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::Value; pub const FORMAT_VERSION: u32 = 1; pub const MANIFEST_NAME: &str = "character.ppd"; pub const MAX_PROJECT_PRESETS: usize = 64; pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024; pub const MAX_DIMENSION: u32 = 4096; pub const MAX_FPS: u32 = 240; pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024; const PROJECT_DIRS: [&str; 5] = [ "assets/body", "assets/equipment", "presets", "cache", "renders", ]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RenderSettings { pub target_width: u32, pub target_height: u32, pub fps: u32, } impl Default for RenderSettings { fn default() -> Self { Self { target_width: 128, target_height: 128, fps: 12, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProjectFile { pub format_version: u32, pub id: String, pub name: String, pub male_model: Option, pub female_model: Option, pub equipment_presets: Vec, pub output_directory: String, pub render: RenderSettings, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectDraft { pub id: String, pub name: String, pub male_model: String, pub female_model: String, pub equipment_presets: Vec, pub output_directory: String, pub target_width: u32, pub target_height: u32, pub fps: u32, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenedProject { pub manifest_path: PathBuf, pub document: ProjectFile, pub disk_issues: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirEntry { pub name: String, pub relative: String, pub is_dir: bool, } #[derive(Debug)] pub enum ProjectError { Validation(Vec), UnsupportedVersion { found: u64, supported: u32 }, UnknownField(String), Json(String), Io { path: String, message: String }, } impl std::fmt::Display for ProjectError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Validation(issues) => { write!(formatter, "{}", issues.join("\n")) } Self::UnsupportedVersion { found, supported } => write!( formatter, "dit project gebruikt format_version {found}; deze build leest alleen versie {supported}" ), Self::UnknownField(detail) => write!( formatter, "het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven." ), Self::Json(detail) => write!(formatter, "het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}"), Self::Io { path, message } => write!(formatter, "{message} ({path})"), } } } impl std::error::Error for ProjectError {} impl ProjectFile { pub fn new_empty(name: impl Into) -> Self { Self { format_version: FORMAT_VERSION, id: new_id(), name: name.into(), male_model: None, female_model: None, equipment_presets: Vec::new(), output_directory: "renders".to_string(), render: RenderSettings::default(), } } pub fn validate_shape(&self) -> Result<(), ProjectError> { let mut issues = Vec::new(); if self.format_version != FORMAT_VERSION { issues.push(format!( "dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}", self.format_version )); } if self.id.trim().is_empty() || !self .id .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') || self.id.len() > 64 { issues.push( "het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'" .to_string(), ); } let name = self.name.trim(); if name.is_empty() || name.chars().any(|ch| ch.is_control()) || name.len() > 80 { issues.push("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens".to_string()); } check_optional_path( "het mannelijke model", self.male_model.as_deref(), &mut issues, ); check_optional_path( "het vrouwelijke model", self.female_model.as_deref(), &mut issues, ); let mut seen = std::collections::BTreeSet::new(); if self.equipment_presets.len() > MAX_PROJECT_PRESETS { issues.push(format!( "maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project" )); } for preset in &self.equipment_presets { match portable_relative(preset) { Ok(clean) => { if clean != *preset { issues.push(format!("de preset '{preset}' is niet genormaliseerd")); } if !seen.insert(clean.clone()) { issues.push(format!("de preset '{clean}' staat dubbel in het project")); } match preset_stem(&clean) { Ok(stem) if stem == "body" => issues.push( "een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam" .to_string(), ), Ok(_) => {} Err(message) => issues.push(message), } if clean == MANIFEST_NAME { issues.push("character.ppd is het projectbestand, geen preset".to_string()); } } Err(message) => issues.push(format!("preset '{preset}': {message}")), } } match portable_relative(&self.output_directory) { Ok(clean) => { if clean != self.output_directory { issues.push("de uitvoermap is niet genormaliseerd".to_string()); } if clean == "." { issues.push( "de uitvoermap moet een submap zijn, niet de projectmap zelf".to_string(), ); } if clean == MANIFEST_NAME { issues.push("de uitvoermap mag niet het .ppd-bestand zijn".to_string()); } for source in self.sources() { if source == clean || source.starts_with(&(clean.clone() + "/")) { issues.push(format!( "de uitvoermap '{clean}' mag de bron '{source}' niet bevatten" )); } } } Err(message) => issues.push(format!("uitvoermap: {message}")), } match memory_estimate(self.render.target_width, self.render.target_height) { Some(bytes) if bytes <= MAX_ESTIMATE_BYTES => {} Some(bytes) => issues.push(format!( "de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}" )), None => issues.push( "breedte en hoogte lopen buiten de geheugenschatting van deze versie".to_string(), ), } if self.render.target_width == 0 || self.render.target_height == 0 { issues.push("breedte en hoogte moeten groter zijn dan 0".to_string()); } if self.render.target_width > MAX_DIMENSION || self.render.target_height > MAX_DIMENSION { issues.push(format!( "breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn" )); } if self.render.fps == 0 { issues.push("fps moet groter zijn dan 0".to_string()); } if self.render.fps > MAX_FPS { issues.push(format!("fps mag hoogstens {MAX_FPS} zijn")); } if issues.is_empty() { Ok(()) } else { Err(ProjectError::Validation(issues)) } } fn sources(&self) -> Vec<&str> { self.male_model .iter() .chain(self.female_model.iter()) .map(String::as_str) .chain(self.equipment_presets.iter().map(String::as_str)) .collect() } } pub fn assemble(draft: ProjectDraft) -> Result { let document = ProjectFile { format_version: FORMAT_VERSION, id: draft.id, name: draft.name, male_model: blank_to_none(draft.male_model), female_model: blank_to_none(draft.female_model), equipment_presets: draft.equipment_presets, output_directory: draft.output_directory, render: RenderSettings { target_width: draft.target_width, target_height: draft.target_height, fps: draft.fps, }, }; document.validate_shape()?; Ok(document) } pub fn parse_manifest(text: &str) -> Result { let value: Value = serde_json::from_str(text).map_err(|err| ProjectError::Json(err.to_string()))?; let Some(object) = value.as_object() else { return Err(ProjectError::Json( "de wortel moet een object zijn".to_string(), )); }; let Some(version_value) = object.get("format_version") else { return Err(ProjectError::Json("format_version ontbreekt".to_string())); }; let Some(version) = version_value.as_u64() else { return Err(ProjectError::Json( "format_version moet een geheel getal zijn".to_string(), )); }; if version != u64::from(FORMAT_VERSION) { return Err(ProjectError::UnsupportedVersion { found: version, supported: FORMAT_VERSION, }); } serde_json::from_value(value).map_err(|err| { let detail = err.to_string(); if detail.contains("unknown field") { ProjectError::UnknownField(detail) } else { ProjectError::Json(detail) } }) } pub fn create_project(root: &Path, name: &str) -> Result { if root.exists() && !root.is_dir() { return Err(io_error(root, "de projectmap is een bestand")); } let manifest = root.join(MANIFEST_NAME); if manifest.exists() { return Err(io_error( &manifest, "er staat al een character.ppd; open dat project in plaats van het te overschrijven", )); } fs::create_dir_all(root).map_err(|err| io_error(root, &err.to_string()))?; for relative in PROJECT_DIRS { let path = root.join(relative); fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?; } let document = ProjectFile::new_empty(name); document.validate_shape()?; write_manifest(&manifest, &document)?; Ok(OpenedProject { manifest_path: manifest, document, disk_issues: Vec::new(), }) } pub fn open_project(manifest: &Path) -> Result { if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") { return Err(io_error(manifest, "kies een .ppd-bestand")); } use std::io::Read; let metadata = fs::metadata(manifest).map_err(|err| { io_error( manifest, &format!("openen is mislukt: {err}. Er is niets gewijzigd"), ) })?; if !metadata.is_file() { return Err(io_error(manifest, "kies een regulier .ppd-bestand")); } let mut bytes = Vec::new(); fs::File::open(manifest) .and_then(|file| { file.take((MAX_MANIFEST_BYTES + 1) as u64) .read_to_end(&mut bytes) }) .map_err(|err| { io_error( manifest, &format!("openen is mislukt: {err}. Er is niets gewijzigd"), ) })?; if bytes.len() > MAX_MANIFEST_BYTES { return Err(io_error(manifest, "projectmanifest groter dan 4 MiB")); } let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?; let document = parse_manifest(text)?; document.validate_shape()?; let root = manifest .parent() .ok_or_else(|| io_error(manifest, "het .ppd-bestand heeft geen projectmap"))?; let disk_issues = disk_issues(root, &document); Ok(OpenedProject { manifest_path: manifest.to_path_buf(), document, disk_issues, }) } pub fn save_project(opened: &OpenedProject) -> Result<(), ProjectError> { opened.document.validate_shape()?; let root = opened.manifest_path.parent().ok_or_else(|| { io_error( &opened.manifest_path, "het .ppd-bestand heeft geen projectmap", ) })?; let mut issues = Vec::new(); for relative in opened.document.sources() { match require_file_inside(root, relative) { Ok(()) => {} Err(ProjectError::Validation(extra)) => issues.extend(extra), Err(other) => return Err(other), } } if !issues.is_empty() { return Err(ProjectError::Validation(issues)); } prepare_output_dir(root, &opened.document.output_directory)?; write_manifest(&opened.manifest_path, &opened.document) } pub fn list_dir(root: &Path, relative: &str) -> Result, ProjectError> { let relative = if relative.is_empty() { String::new() } else { portable_relative(relative).map_err(|message| ProjectError::Validation(vec![message]))? }; let path = if relative.is_empty() { root.to_path_buf() } else { root.join(&relative) }; let read = fs::read_dir(&path).map_err(|err| io_error(&path, &err.to_string()))?; let mut entries = Vec::new(); for item in read { let item = item.map_err(|err| io_error(&path, &err.to_string()))?; let name = item.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } let child = if relative.is_empty() { name.clone() } else { format!("{relative}/{name}") }; let is_dir = item .file_type() .map_err(|err| io_error(&item.path(), &err.to_string()))? .is_dir(); entries.push(DirEntry { name, relative: child, is_dir, }); } entries.sort_by(|left, right| { right .is_dir .cmp(&left.is_dir) .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) }); Ok(entries) } pub fn memory_estimate(width: u32, height: u32) -> Option { // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel. u64::from(width) .checked_mul(u64::from(height))? .checked_mul(64)? .checked_mul(36)? .checked_mul(3)? .checked_mul(4) } fn new_id() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or(0); format!("ppd-{nanos:x}") } fn blank_to_none(value: String) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec) { let Some(value) = value else { return; }; match portable_relative(value) { Ok(clean) if clean == value && clean != MANIFEST_NAME => {} Ok(_) if value == MANIFEST_NAME => { issues.push(format!("{label} mag niet character.ppd zijn")); } Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")), Err(message) => issues.push(format!("{label}: {message}")), } } pub fn portable_relative(value: &str) -> Result { let value = value.trim(); if value.is_empty() { return Err("het pad is leeg".to_string()); } if value.contains('\\') { return Err("gebruik '/' in projectpaden".to_string()); } if value.starts_with('/') || windows_drive(value) { return Err( "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap" .to_string(), ); } let mut parts = Vec::new(); for part in value.split('/') { if part.is_empty() || part == "." || part == ".." { return Err( "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben" .to_string(), ); } if part.contains('\0') { return Err("het pad bevat een leeg teken".to_string()); } parts.push(part); } Ok(parts.join("/")) } fn windows_drive(value: &str) -> bool { let mut chars = value.chars(); matches!( (chars.next(), chars.next(), chars.next()), (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic() ) } fn preset_stem(relative: &str) -> Result { let file_name = relative.rsplit('/').next().unwrap_or(relative); let stem = file_name.split('.').next().unwrap_or(file_name); if stem.is_empty() || !stem .chars() .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_') || stem.contains(' ') { return Err(format!( "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties" )); } Ok(stem.to_string()) } fn disk_issues(root: &Path, document: &ProjectFile) -> Vec { let mut issues = Vec::new(); for relative in document.sources() { if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) { issues.extend(extra); } } let output = root.join(&document.output_directory); if output.exists() && !output.is_dir() { issues.push(format!( "de uitvoermap '{}' is een bestand", document.output_directory )); } else if !output.exists() { issues.push(format!( "de uitvoermap '{}' bestaat nog niet", document.output_directory )); } issues } fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> { let path = root.join(relative); if !path.is_file() { return Err(ProjectError::Validation(vec![format!( "het bestand ontbreekt in de projectmap: {relative}" )])); } ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?; match (root.canonicalize(), path.canonicalize()) { (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()), (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!( "het pad '{relative}' wijst buiten de projectmap" )])), (Err(err), _) => Err(io_error(root, &err.to_string())), (_, Err(err)) => Err(io_error(&path, &err.to_string())), } } pub(crate) fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> { let path = root.join(relative); if path.exists() && !path.is_dir() { return Err(ProjectError::Validation(vec![format!( "de uitvoermap '{relative}' is een bestand" )])); } if !path.exists() { fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?; } ensure_lexically_inside(root, &path) .map_err(|message| ProjectError::Validation(vec![message]))?; Ok(()) } fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> { let root = root.components().collect::>(); let path = path.components().collect::>(); if path.starts_with(&root) { Ok(()) } else { Err("het pad wijst buiten de projectmap".to_string()) } } fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> { let parent = path .parent() .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?; let text = serde_json::to_string_pretty(document) .map_err(|err| ProjectError::Json(err.to_string()))? + "\n"; if text.len() > MAX_MANIFEST_BYTES { return Err(io_error(path, "projectmanifest groter dan 4 MiB")); } let mut temporary_name = path .file_name() .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))? .to_os_string(); temporary_name.push(".tmp"); let temporary = parent.join(temporary_name); let write_result = (|| { let mut file = File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?; file.write_all(text.as_bytes()) .map_err(|err| io_error(&temporary, &err.to_string()))?; file.sync_all() .map_err(|err| io_error(&temporary, &err.to_string()))?; Ok(()) })(); if let Err(err) = write_result { let _ = fs::remove_file(&temporary); return Err(err); } if let Err(err) = fs::rename(&temporary, path) { let _ = fs::remove_file(&temporary); return Err(io_error( path, &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"), )); } Ok(()) } fn io_error(path: &Path, message: &str) -> ProjectError { ProjectError::Io { path: path.display().to_string(), message: message.to_string(), } } #[cfg(test)] mod tests { use super::*; fn temp_root(label: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( "paperdoll-{label}-{}-{}", std::process::id(), new_id() )); let _ = fs::remove_dir_all(&path); path } fn draft_from(document: &ProjectFile) -> ProjectDraft { ProjectDraft { id: document.id.clone(), name: document.name.clone(), male_model: document.male_model.clone().unwrap_or_default(), female_model: document.female_model.clone().unwrap_or_default(), equipment_presets: document.equipment_presets.clone(), output_directory: document.output_directory.clone(), target_width: document.render.target_width, target_height: document.render.target_height, fps: document.render.fps, } } #[test] fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() { let root = temp_root("resource-limits"); let mut opened = create_project(&root, "Limits").unwrap(); let before = fs::read(&opened.manifest_path).unwrap(); opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1); assert!(save_project(&opened).is_err()); assert!(write_manifest(&opened.manifest_path, &opened.document) .unwrap_err() .to_string() .contains("4 MiB")); assert_eq!(fs::read(&opened.manifest_path).unwrap(), before); let oversized = root.join("oversized.ppd"); fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap(); assert!(open_project(&oversized) .unwrap_err() .to_string() .contains("4 MiB")); opened.document.name = "Limits".into(); opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS) .map(|i| format!("presets/item-{i}.ppe")) .collect(); assert!(opened .document .validate_shape() .unwrap_err() .to_string() .contains("64")); fs::remove_dir_all(root).unwrap(); } #[test] fn roundtrip_keeps_null_slots_and_relative_paths() { let root = temp_root("roundtrip"); let opened = create_project(&root, "Ridder").unwrap(); let text = fs::read_to_string(&opened.manifest_path).unwrap(); assert!(text.contains("\"male_model\": null")); assert!(text.contains("\"female_model\": null")); assert!(text.contains("\"output_directory\": \"renders\"")); assert!(!text.contains(root.to_string_lossy().as_ref())); let loaded = open_project(&opened.manifest_path).unwrap(); assert_eq!(loaded.document, opened.document); assert!(loaded.disk_issues.is_empty()); assert!(root.join("assets/body").is_dir()); assert!(root.join("presets").is_dir()); assert!(root.join("renders").is_dir()); let _ = fs::remove_dir_all(&root); } #[test] fn save_writes_model_and_preset_paths_and_reloads_them() { let root = temp_root("paths"); let mut opened = create_project(&root, "Ridder").unwrap(); fs::write(root.join("assets/body/male.glb"), b"glb").unwrap(); fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap(); opened.document.male_model = Some("assets/body/male.glb".to_string()); opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()]; save_project(&opened).unwrap(); let loaded = open_project(&opened.manifest_path).unwrap(); assert_eq!( loaded.document.male_model.as_deref(), Some("assets/body/male.glb") ); assert_eq!(loaded.document.female_model, None); assert_eq!( loaded.document.equipment_presets, vec!["presets/zwaard.bin"] ); assert!(loaded.disk_issues.is_empty()); let _ = fs::remove_dir_all(&root); } #[test] fn validation_rejects_escape_absolute_body_and_output_overlap() { let mut document = ProjectFile::new_empty("Ridder"); document.id = "ridder-1".to_string(); document.male_model = Some("../geheim.glb".to_string()); document.female_model = Some("/tmp/vrouw.glb".to_string()); document.equipment_presets = vec![ "presets/body.bin".to_string(), "presets/Zwaard.bin".to_string(), ]; document.output_directory = "presets".to_string(); let error = document.validate_shape().unwrap_err(); let text = error.to_string(); assert!(text.contains("buiten de projectmap"), "{text}"); assert!(text.contains("absoluut"), "{text}"); assert!(text.contains("body"), "{text}"); assert!(text.contains("kleine letters"), "{text}"); assert!(text.contains("uitvoermap"), "{text}"); } #[test] fn newer_version_and_unknown_field_are_recoverable_parse_errors() { let newer = r#"{"format_version":2,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#; let error = parse_manifest(newer).unwrap_err(); assert!(matches!( error, ProjectError::UnsupportedVersion { found: 2, supported: 1 } )); assert!(error.to_string().contains("format_version 2")); let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#; let error = parse_manifest(unknown).unwrap_err(); assert!(matches!(error, ProjectError::UnknownField(_))); assert!(error.to_string().contains("niet herschreven")); } #[test] fn failed_save_keeps_the_previous_manifest() { let root = temp_root("atomic"); let opened = create_project(&root, "Ridder").unwrap(); let before = fs::read(&opened.manifest_path).unwrap(); let mut broken = opened.clone(); broken.document.output_directory = "..".to_string(); let error = save_project(&broken).unwrap_err(); assert!(error.to_string().contains("buiten de projectmap")); assert_eq!(fs::read(&opened.manifest_path).unwrap(), before); broken.document.output_directory = "renders".to_string(); broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string()); let error = save_project(&broken).unwrap_err(); assert!(error.to_string().contains("ontbreekt"), "{error}"); assert_eq!(fs::read(&opened.manifest_path).unwrap(), before); let _ = fs::remove_dir_all(&root); } #[test] fn missing_manifest_is_a_recoverable_io_error() { let path = temp_root("missing").join(MANIFEST_NAME); let error = open_project(&path).unwrap_err(); let text = error.to_string(); assert!(text.contains("openen is mislukt"), "{text}"); assert!(matches!(error, ProjectError::Io { .. })); } #[test] fn create_refuses_to_overwrite_an_existing_project() { let root = temp_root("twice"); create_project(&root, "Eerste").unwrap(); let error = create_project(&root, "Tweede").unwrap_err(); assert!(error.to_string().contains("al een character.ppd")); let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap(); assert_eq!(loaded.document.name, "Eerste"); let _ = fs::remove_dir_all(&root); } #[test] fn render_bounds_and_assemble_roundtrip() { let mut document = ProjectFile::new_empty("Ridder"); document.id = "ridder-1".to_string(); document.render.target_width = 0; assert!(document .validate_shape() .unwrap_err() .to_string() .contains("groter zijn dan 0")); document.render = RenderSettings { target_width: 4096, target_height: 4096, fps: 12, }; assert!(document .validate_shape() .unwrap_err() .to_string() .contains("geschatte renderbuffer")); document.render.fps = 0; document.render.target_width = 128; document.render.target_height = 128; assert!(document .validate_shape() .unwrap_err() .to_string() .contains("fps")); let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap(); let text = serde_json::to_string(&assembled).unwrap(); let parsed = parse_manifest(&text).unwrap(); assert_eq!(parsed.render, RenderSettings::default()); assert_eq!(parsed.male_model, None); } #[cfg(unix)] #[test] fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() { let root = temp_root("link"); let opened = create_project(&root, "Ridder").unwrap(); let outside = temp_root("outside-file"); fs::create_dir_all(&outside).unwrap(); let secret = outside.join("geheim.glb"); fs::write(&secret, b"nope").unwrap(); std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap(); let mut broken = opened.clone(); broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string()); let error = save_project(&broken).unwrap_err(); assert!( error.to_string().contains("buiten de projectmap"), "{error}" ); fs::write(root.join("assets/body/male.glb"), b"ok").unwrap(); let entries = list_dir(&root, "assets/body").unwrap(); assert!(entries .iter() .any(|entry| entry.name == "male.glb" && !entry.is_dir)); let error = list_dir(&root, "../").unwrap_err(); assert!(error.to_string().contains("buiten de projectmap")); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&outside); } }