BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

project.rs 31.9 KiB Download Raw
1//! `.ppd` project manifest: one JSON file in a project directory.
2//!
3//! Version 1 stores paths and render settings. It does not import models and
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
5//! cannot drop them. A newer or older `format_version` is rejected first.
6
7use std::fs::{self, File};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15pub const FORMAT_VERSION: u32 = 1;
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_PROJECT_PRESETS: usize = 64;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
19pub const MAX_DIMENSION: u32 = 4096;
20pub const MAX_FPS: u32 = 240;
21pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
22
23const PROJECT_DIRS: [&str; 5] = [
24 "assets/body",
25 "assets/equipment",
26 "presets",
27 "cache",
28 "renders",
29];
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct RenderSettings {
34 pub target_width: u32,
35 pub target_height: u32,
36 pub fps: u32,
37}
38
39impl Default for RenderSettings {
40 fn default() -> Self {
41 Self {
42 target_width: 128,
43 target_height: 128,
44 fps: 12,
45 }
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct ProjectFile {
52 pub format_version: u32,
53 pub id: String,
54 pub name: String,
55 pub male_model: Option<String>,
56 pub female_model: Option<String>,
57 pub equipment_presets: Vec<String>,
58 pub output_directory: String,
59 pub render: RenderSettings,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ProjectDraft {
64 pub id: String,
65 pub name: String,
66 pub male_model: String,
67 pub female_model: String,
68 pub equipment_presets: Vec<String>,
69 pub output_directory: String,
70 pub target_width: u32,
71 pub target_height: u32,
72 pub fps: u32,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct OpenedProject {
77 pub manifest_path: PathBuf,
78 pub document: ProjectFile,
79 pub disk_issues: Vec<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct DirEntry {
84 pub name: String,
85 pub relative: String,
86 pub is_dir: bool,
87}
88
89#[derive(Debug)]
90pub enum ProjectError {
91 Validation(Vec<String>),
92 UnsupportedVersion { found: u64, supported: u32 },
93 UnknownField(String),
94 Json(String),
95 Io { path: String, message: String },
96}
97
98impl std::fmt::Display for ProjectError {
99 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 Self::Validation(issues) => {
102 write!(formatter, "{}", issues.join("\n"))
103 }
104 Self::UnsupportedVersion { found, supported } => write!(
105 formatter,
106 "dit project gebruikt format_version {found}; deze build leest alleen versie {supported}"
107 ),
108 Self::UnknownField(detail) => write!(
109 formatter,
110 "het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven."
111 ),
112 Self::Json(detail) => write!(formatter, "het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}"),
113 Self::Io { path, message } => write!(formatter, "{message} ({path})"),
114 }
115 }
116}
117
118impl std::error::Error for ProjectError {}
119
120impl ProjectFile {
121 pub fn new_empty(name: impl Into<String>) -> Self {
122 Self {
123 format_version: FORMAT_VERSION,
124 id: new_id(),
125 name: name.into(),
126 male_model: None,
127 female_model: None,
128 equipment_presets: Vec::new(),
129 output_directory: "renders".to_string(),
130 render: RenderSettings::default(),
131 }
132 }
133
134 pub fn validate_shape(&self) -> Result<(), ProjectError> {
135 let mut issues = Vec::new();
136 if self.format_version != FORMAT_VERSION {
137 issues.push(format!(
138 "dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",
139 self.format_version
140 ));
141 }
142 if self.id.trim().is_empty()
143 || !self
144 .id
145 .chars()
146 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
147 || self.id.len() > 64
148 {
149 issues.push(
150 "het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'"
151 .to_string(),
152 );
153 }
154 let name = self.name.trim();
155 if name.is_empty() || name.chars().any(|ch| ch.is_control()) || name.len() > 80 {
156 issues.push("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens".to_string());
157 }
158 check_optional_path(
159 "het mannelijke model",
160 self.male_model.as_deref(),
161 &mut issues,
162 );
163 check_optional_path(
164 "het vrouwelijke model",
165 self.female_model.as_deref(),
166 &mut issues,
167 );
168 let mut seen = std::collections::BTreeSet::new();
169 if self.equipment_presets.len() > MAX_PROJECT_PRESETS {
170 issues.push(format!(
171 "maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project"
172 ));
173 }
174 for preset in &self.equipment_presets {
175 match portable_relative(preset) {
176 Ok(clean) => {
177 if clean != *preset {
178 issues.push(format!("de preset '{preset}' is niet genormaliseerd"));
179 }
180 if !seen.insert(clean.clone()) {
181 issues.push(format!("de preset '{clean}' staat dubbel in het project"));
182 }
183 match preset_stem(&clean) {
184 Ok(stem) if stem == "body" => issues.push(
185 "een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam"
186 .to_string(),
187 ),
188 Ok(_) => {}
189 Err(message) => issues.push(message),
190 }
191 if clean == MANIFEST_NAME {
192 issues.push("character.ppd is het projectbestand, geen preset".to_string());
193 }
194 }
195 Err(message) => issues.push(format!("preset '{preset}': {message}")),
196 }
197 }
198 match portable_relative(&self.output_directory) {
199 Ok(clean) => {
200 if clean != self.output_directory {
201 issues.push("de uitvoermap is niet genormaliseerd".to_string());
202 }
203 if clean == "." {
204 issues.push(
205 "de uitvoermap moet een submap zijn, niet de projectmap zelf".to_string(),
206 );
207 }
208 if clean == MANIFEST_NAME {
209 issues.push("de uitvoermap mag niet het .ppd-bestand zijn".to_string());
210 }
211 for source in self.sources() {
212 if source == clean || source.starts_with(&(clean.clone() + "/")) {
213 issues.push(format!(
214 "de uitvoermap '{clean}' mag de bron '{source}' niet bevatten"
215 ));
216 }
217 }
218 }
219 Err(message) => issues.push(format!("uitvoermap: {message}")),
220 }
221 match memory_estimate(self.render.target_width, self.render.target_height) {
222 Some(bytes) if bytes <= MAX_ESTIMATE_BYTES => {}
223 Some(bytes) => issues.push(format!(
224 "de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}"
225 )),
226 None => issues.push(
227 "breedte en hoogte lopen buiten de geheugenschatting van deze versie".to_string(),
228 ),
229 }
230 if self.render.target_width == 0 || self.render.target_height == 0 {
231 issues.push("breedte en hoogte moeten groter zijn dan 0".to_string());
232 }
233 if self.render.target_width > MAX_DIMENSION || self.render.target_height > MAX_DIMENSION {
234 issues.push(format!(
235 "breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn"
236 ));
237 }
238 if self.render.fps == 0 {
239 issues.push("fps moet groter zijn dan 0".to_string());
240 }
241 if self.render.fps > MAX_FPS {
242 issues.push(format!("fps mag hoogstens {MAX_FPS} zijn"));
243 }
244 if issues.is_empty() {
245 Ok(())
246 } else {
247 Err(ProjectError::Validation(issues))
248 }
249 }
250
251 fn sources(&self) -> Vec<&str> {
252 self.male_model
253 .iter()
254 .chain(self.female_model.iter())
255 .map(String::as_str)
256 .chain(self.equipment_presets.iter().map(String::as_str))
257 .collect()
258 }
259}
260
261pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {
262 let document = ProjectFile {
263 format_version: FORMAT_VERSION,
264 id: draft.id,
265 name: draft.name,
266 male_model: blank_to_none(draft.male_model),
267 female_model: blank_to_none(draft.female_model),
268 equipment_presets: draft.equipment_presets,
269 output_directory: draft.output_directory,
270 render: RenderSettings {
271 target_width: draft.target_width,
272 target_height: draft.target_height,
273 fps: draft.fps,
274 },
275 };
276 document.validate_shape()?;
277 Ok(document)
278}
279
280pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
281 let value: Value =
282 serde_json::from_str(text).map_err(|err| ProjectError::Json(err.to_string()))?;
283 let Some(object) = value.as_object() else {
284 return Err(ProjectError::Json(
285 "de wortel moet een object zijn".to_string(),
286 ));
287 };
288 let Some(version_value) = object.get("format_version") else {
289 return Err(ProjectError::Json("format_version ontbreekt".to_string()));
290 };
291 let Some(version) = version_value.as_u64() else {
292 return Err(ProjectError::Json(
293 "format_version moet een geheel getal zijn".to_string(),
294 ));
295 };
296 if version != u64::from(FORMAT_VERSION) {
297 return Err(ProjectError::UnsupportedVersion {
298 found: version,
299 supported: FORMAT_VERSION,
300 });
301 }
302 serde_json::from_value(value).map_err(|err| {
303 let detail = err.to_string();
304 if detail.contains("unknown field") {
305 ProjectError::UnknownField(detail)
306 } else {
307 ProjectError::Json(detail)
308 }
309 })
310}
311
312pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
313 if root.exists() && !root.is_dir() {
314 return Err(io_error(root, "de projectmap is een bestand"));
315 }
316 let manifest = root.join(MANIFEST_NAME);
317 if manifest.exists() {
318 return Err(io_error(
319 &manifest,
320 "er staat al een character.ppd; open dat project in plaats van het te overschrijven",
321 ));
322 }
323 fs::create_dir_all(root).map_err(|err| io_error(root, &err.to_string()))?;
324 for relative in PROJECT_DIRS {
325 let path = root.join(relative);
326 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
327 }
328 let document = ProjectFile::new_empty(name);
329 document.validate_shape()?;
330 write_manifest(&manifest, &document)?;
331 Ok(OpenedProject {
332 manifest_path: manifest,
333 document,
334 disk_issues: Vec::new(),
335 })
336}
337
338pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {
339 if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
340 return Err(io_error(manifest, "kies een .ppd-bestand"));
341 }
342 use std::io::Read;
343 let metadata = fs::metadata(manifest).map_err(|err| {
344 io_error(
345 manifest,
346 &format!("openen is mislukt: {err}. Er is niets gewijzigd"),
347 )
348 })?;
349 if !metadata.is_file() {
350 return Err(io_error(manifest, "kies een regulier .ppd-bestand"));
351 }
352 let mut bytes = Vec::new();
353 fs::File::open(manifest)
354 .and_then(|file| {
355 file.take((MAX_MANIFEST_BYTES + 1) as u64)
356 .read_to_end(&mut bytes)
357 })
358 .map_err(|err| {
359 io_error(
360 manifest,
361 &format!("openen is mislukt: {err}. Er is niets gewijzigd"),
362 )
363 })?;
364 if bytes.len() > MAX_MANIFEST_BYTES {
365 return Err(io_error(manifest, "projectmanifest groter dan 4 MiB"));
366 }
367 let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?;
368 let document = parse_manifest(text)?;
369 document.validate_shape()?;
370 let root = manifest
371 .parent()
372 .ok_or_else(|| io_error(manifest, "het .ppd-bestand heeft geen projectmap"))?;
373 let disk_issues = disk_issues(root, &document);
374 Ok(OpenedProject {
375 manifest_path: manifest.to_path_buf(),
376 document,
377 disk_issues,
378 })
379}
380
381pub fn save_project(opened: &OpenedProject) -> Result<(), ProjectError> {
382 opened.document.validate_shape()?;
383 let root = opened.manifest_path.parent().ok_or_else(|| {
384 io_error(
385 &opened.manifest_path,
386 "het .ppd-bestand heeft geen projectmap",
387 )
388 })?;
389 let mut issues = Vec::new();
390 for relative in opened.document.sources() {
391 match require_file_inside(root, relative) {
392 Ok(()) => {}
393 Err(ProjectError::Validation(extra)) => issues.extend(extra),
394 Err(other) => return Err(other),
395 }
396 }
397 if !issues.is_empty() {
398 return Err(ProjectError::Validation(issues));
399 }
400 prepare_output_dir(root, &opened.document.output_directory)?;
401 write_manifest(&opened.manifest_path, &opened.document)
402}
403
404pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectError> {
405 let relative = if relative.is_empty() {
406 String::new()
407 } else {
408 portable_relative(relative).map_err(|message| ProjectError::Validation(vec![message]))?
409 };
410 let path = if relative.is_empty() {
411 root.to_path_buf()
412 } else {
413 root.join(&relative)
414 };
415 let read = fs::read_dir(&path).map_err(|err| io_error(&path, &err.to_string()))?;
416 let mut entries = Vec::new();
417 for item in read {
418 let item = item.map_err(|err| io_error(&path, &err.to_string()))?;
419 let name = item.file_name().to_string_lossy().to_string();
420 if name.starts_with('.') {
421 continue;
422 }
423 let child = if relative.is_empty() {
424 name.clone()
425 } else {
426 format!("{relative}/{name}")
427 };
428 let is_dir = item
429 .file_type()
430 .map_err(|err| io_error(&item.path(), &err.to_string()))?
431 .is_dir();
432 entries.push(DirEntry {
433 name,
434 relative: child,
435 is_dir,
436 });
437 }
438 entries.sort_by(|left, right| {
439 right
440 .is_dir
441 .cmp(&left.is_dir)
442 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
443 });
444 Ok(entries)
445}
446
447pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
448 // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
449 u64::from(width)
450 .checked_mul(u64::from(height))?
451 .checked_mul(64)?
452 .checked_mul(36)?
453 .checked_mul(3)?
454 .checked_mul(4)
455}
456
457fn new_id() -> String {
458 let nanos = SystemTime::now()
459 .duration_since(UNIX_EPOCH)
460 .map(|duration| duration.as_nanos())
461 .unwrap_or(0);
462 format!("ppd-{nanos:x}")
463}
464
465fn blank_to_none(value: String) -> Option<String> {
466 let trimmed = value.trim();
467 if trimmed.is_empty() {
468 None
469 } else {
470 Some(trimmed.to_string())
471 }
472}
473
474fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec<String>) {
475 let Some(value) = value else {
476 return;
477 };
478 match portable_relative(value) {
479 Ok(clean) if clean == value && clean != MANIFEST_NAME => {}
480 Ok(_) if value == MANIFEST_NAME => {
481 issues.push(format!("{label} mag niet character.ppd zijn"));
482 }
483 Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")),
484 Err(message) => issues.push(format!("{label}: {message}")),
485 }
486}
487
488pub fn portable_relative(value: &str) -> Result<String, String> {
489 let value = value.trim();
490 if value.is_empty() {
491 return Err("het pad is leeg".to_string());
492 }
493 if value.contains('\\') {
494 return Err("gebruik '/' in projectpaden".to_string());
495 }
496 if value.starts_with('/') || windows_drive(value) {
497 return Err(
498 "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap"
499 .to_string(),
500 );
501 }
502 let mut parts = Vec::new();
503 for part in value.split('/') {
504 if part.is_empty() || part == "." || part == ".." {
505 return Err(
506 "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben"
507 .to_string(),
508 );
509 }
510 if part.contains('\0') {
511 return Err("het pad bevat een leeg teken".to_string());
512 }
513 parts.push(part);
514 }
515 Ok(parts.join("/"))
516}
517
518fn windows_drive(value: &str) -> bool {
519 let mut chars = value.chars();
520 matches!(
521 (chars.next(), chars.next(), chars.next()),
522 (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic()
523 )
524}
525
526fn preset_stem(relative: &str) -> Result<String, String> {
527 let file_name = relative.rsplit('/').next().unwrap_or(relative);
528 let stem = file_name.split('.').next().unwrap_or(file_name);
529 if stem.is_empty()
530 || !stem
531 .chars()
532 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
533 || stem.contains(' ')
534 {
535 return Err(format!(
536 "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties"
537 ));
538 }
539 Ok(stem.to_string())
540}
541
542fn disk_issues(root: &Path, document: &ProjectFile) -> Vec<String> {
543 let mut issues = Vec::new();
544 for relative in document.sources() {
545 if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) {
546 issues.extend(extra);
547 }
548 }
549 let output = root.join(&document.output_directory);
550 if output.exists() && !output.is_dir() {
551 issues.push(format!(
552 "de uitvoermap '{}' is een bestand",
553 document.output_directory
554 ));
555 } else if !output.exists() {
556 issues.push(format!(
557 "de uitvoermap '{}' bestaat nog niet",
558 document.output_directory
559 ));
560 }
561 issues
562}
563
564fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> {
565 let path = root.join(relative);
566 if !path.is_file() {
567 return Err(ProjectError::Validation(vec![format!(
568 "het bestand ontbreekt in de projectmap: {relative}"
569 )]));
570 }
571 ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?;
572 match (root.canonicalize(), path.canonicalize()) {
573 (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()),
574 (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!(
575 "het pad '{relative}' wijst buiten de projectmap"
576 )])),
577 (Err(err), _) => Err(io_error(root, &err.to_string())),
578 (_, Err(err)) => Err(io_error(&path, &err.to_string())),
579 }
580}
581
582pub(crate) fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
583 let path = root.join(relative);
584 if path.exists() && !path.is_dir() {
585 return Err(ProjectError::Validation(vec![format!(
586 "de uitvoermap '{relative}' is een bestand"
587 )]));
588 }
589 if !path.exists() {
590 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
591 }
592 ensure_lexically_inside(root, &path)
593 .map_err(|message| ProjectError::Validation(vec![message]))?;
594 Ok(())
595}
596
597fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> {
598 let root = root.components().collect::<Vec<_>>();
599 let path = path.components().collect::<Vec<_>>();
600 if path.starts_with(&root) {
601 Ok(())
602 } else {
603 Err("het pad wijst buiten de projectmap".to_string())
604 }
605}
606
607fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> {
608 let parent = path
609 .parent()
610 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?;
611 let text = serde_json::to_string_pretty(document)
612 .map_err(|err| ProjectError::Json(err.to_string()))?
613 + "\n";
614 if text.len() > MAX_MANIFEST_BYTES {
615 return Err(io_error(path, "projectmanifest groter dan 4 MiB"));
616 }
617 let mut temporary_name = path
618 .file_name()
619 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
620 .to_os_string();
621 temporary_name.push(".tmp");
622 let temporary = parent.join(temporary_name);
623 let write_result = (|| {
624 let mut file =
625 File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?;
626 file.write_all(text.as_bytes())
627 .map_err(|err| io_error(&temporary, &err.to_string()))?;
628 file.sync_all()
629 .map_err(|err| io_error(&temporary, &err.to_string()))?;
630 Ok(())
631 })();
632 if let Err(err) = write_result {
633 let _ = fs::remove_file(&temporary);
634 return Err(err);
635 }
636 if let Err(err) = fs::rename(&temporary, path) {
637 let _ = fs::remove_file(&temporary);
638 return Err(io_error(
639 path,
640 &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"),
641 ));
642 }
643 Ok(())
644}
645
646fn io_error(path: &Path, message: &str) -> ProjectError {
647 ProjectError::Io {
648 path: path.display().to_string(),
649 message: message.to_string(),
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 fn temp_root(label: &str) -> PathBuf {
658 let path = std::env::temp_dir().join(format!(
659 "paperdoll-{label}-{}-{}",
660 std::process::id(),
661 new_id()
662 ));
663 let _ = fs::remove_dir_all(&path);
664 path
665 }
666
667 fn draft_from(document: &ProjectFile) -> ProjectDraft {
668 ProjectDraft {
669 id: document.id.clone(),
670 name: document.name.clone(),
671 male_model: document.male_model.clone().unwrap_or_default(),
672 female_model: document.female_model.clone().unwrap_or_default(),
673 equipment_presets: document.equipment_presets.clone(),
674 output_directory: document.output_directory.clone(),
675 target_width: document.render.target_width,
676 target_height: document.render.target_height,
677 fps: document.render.fps,
678 }
679 }
680
681 #[test]
682 fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() {
683 let root = temp_root("resource-limits");
684 let mut opened = create_project(&root, "Limits").unwrap();
685 let before = fs::read(&opened.manifest_path).unwrap();
686 opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1);
687 assert!(save_project(&opened).is_err());
688 assert!(write_manifest(&opened.manifest_path, &opened.document)
689 .unwrap_err()
690 .to_string()
691 .contains("4 MiB"));
692 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
693 let oversized = root.join("oversized.ppd");
694 fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap();
695 assert!(open_project(&oversized)
696 .unwrap_err()
697 .to_string()
698 .contains("4 MiB"));
699 opened.document.name = "Limits".into();
700 opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS)
701 .map(|i| format!("presets/item-{i}.ppe"))
702 .collect();
703 assert!(opened
704 .document
705 .validate_shape()
706 .unwrap_err()
707 .to_string()
708 .contains("64"));
709 fs::remove_dir_all(root).unwrap();
710 }
711
712 #[test]
713 fn roundtrip_keeps_null_slots_and_relative_paths() {
714 let root = temp_root("roundtrip");
715 let opened = create_project(&root, "Ridder").unwrap();
716 let text = fs::read_to_string(&opened.manifest_path).unwrap();
717 assert!(text.contains("\"male_model\": null"));
718 assert!(text.contains("\"female_model\": null"));
719 assert!(text.contains("\"output_directory\": \"renders\""));
720 assert!(!text.contains(root.to_string_lossy().as_ref()));
721 let loaded = open_project(&opened.manifest_path).unwrap();
722 assert_eq!(loaded.document, opened.document);
723 assert!(loaded.disk_issues.is_empty());
724 assert!(root.join("assets/body").is_dir());
725 assert!(root.join("presets").is_dir());
726 assert!(root.join("renders").is_dir());
727 let _ = fs::remove_dir_all(&root);
728 }
729
730 #[test]
731 fn save_writes_model_and_preset_paths_and_reloads_them() {
732 let root = temp_root("paths");
733 let mut opened = create_project(&root, "Ridder").unwrap();
734 fs::write(root.join("assets/body/male.glb"), b"glb").unwrap();
735 fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap();
736 opened.document.male_model = Some("assets/body/male.glb".to_string());
737 opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()];
738 save_project(&opened).unwrap();
739 let loaded = open_project(&opened.manifest_path).unwrap();
740 assert_eq!(
741 loaded.document.male_model.as_deref(),
742 Some("assets/body/male.glb")
743 );
744 assert_eq!(loaded.document.female_model, None);
745 assert_eq!(
746 loaded.document.equipment_presets,
747 vec!["presets/zwaard.bin"]
748 );
749 assert!(loaded.disk_issues.is_empty());
750 let _ = fs::remove_dir_all(&root);
751 }
752
753 #[test]
754 fn validation_rejects_escape_absolute_body_and_output_overlap() {
755 let mut document = ProjectFile::new_empty("Ridder");
756 document.id = "ridder-1".to_string();
757 document.male_model = Some("../geheim.glb".to_string());
758 document.female_model = Some("/tmp/vrouw.glb".to_string());
759 document.equipment_presets = vec![
760 "presets/body.bin".to_string(),
761 "presets/Zwaard.bin".to_string(),
762 ];
763 document.output_directory = "presets".to_string();
764 let error = document.validate_shape().unwrap_err();
765 let text = error.to_string();
766 assert!(text.contains("buiten de projectmap"), "{text}");
767 assert!(text.contains("absoluut"), "{text}");
768 assert!(text.contains("body"), "{text}");
769 assert!(text.contains("kleine letters"), "{text}");
770 assert!(text.contains("uitvoermap"), "{text}");
771 }
772
773 #[test]
774 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
775 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}"#;
776 let error = parse_manifest(newer).unwrap_err();
777 assert!(matches!(
778 error,
779 ProjectError::UnsupportedVersion {
780 found: 2,
781 supported: 1
782 }
783 ));
784 assert!(error.to_string().contains("format_version 2"));
785 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}"#;
786 let error = parse_manifest(unknown).unwrap_err();
787 assert!(matches!(error, ProjectError::UnknownField(_)));
788 assert!(error.to_string().contains("niet herschreven"));
789 }
790
791 #[test]
792 fn failed_save_keeps_the_previous_manifest() {
793 let root = temp_root("atomic");
794 let opened = create_project(&root, "Ridder").unwrap();
795 let before = fs::read(&opened.manifest_path).unwrap();
796 let mut broken = opened.clone();
797 broken.document.output_directory = "..".to_string();
798 let error = save_project(&broken).unwrap_err();
799 assert!(error.to_string().contains("buiten de projectmap"));
800 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
801 broken.document.output_directory = "renders".to_string();
802 broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string());
803 let error = save_project(&broken).unwrap_err();
804 assert!(error.to_string().contains("ontbreekt"), "{error}");
805 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
806 let _ = fs::remove_dir_all(&root);
807 }
808
809 #[test]
810 fn missing_manifest_is_a_recoverable_io_error() {
811 let path = temp_root("missing").join(MANIFEST_NAME);
812 let error = open_project(&path).unwrap_err();
813 let text = error.to_string();
814 assert!(text.contains("openen is mislukt"), "{text}");
815 assert!(matches!(error, ProjectError::Io { .. }));
816 }
817
818 #[test]
819 fn create_refuses_to_overwrite_an_existing_project() {
820 let root = temp_root("twice");
821 create_project(&root, "Eerste").unwrap();
822 let error = create_project(&root, "Tweede").unwrap_err();
823 assert!(error.to_string().contains("al een character.ppd"));
824 let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap();
825 assert_eq!(loaded.document.name, "Eerste");
826 let _ = fs::remove_dir_all(&root);
827 }
828
829 #[test]
830 fn render_bounds_and_assemble_roundtrip() {
831 let mut document = ProjectFile::new_empty("Ridder");
832 document.id = "ridder-1".to_string();
833 document.render.target_width = 0;
834 assert!(document
835 .validate_shape()
836 .unwrap_err()
837 .to_string()
838 .contains("groter zijn dan 0"));
839 document.render = RenderSettings {
840 target_width: 4096,
841 target_height: 4096,
842 fps: 12,
843 };
844 assert!(document
845 .validate_shape()
846 .unwrap_err()
847 .to_string()
848 .contains("geschatte renderbuffer"));
849 document.render.fps = 0;
850 document.render.target_width = 128;
851 document.render.target_height = 128;
852 assert!(document
853 .validate_shape()
854 .unwrap_err()
855 .to_string()
856 .contains("fps"));
857 let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap();
858 let text = serde_json::to_string(&assembled).unwrap();
859 let parsed = parse_manifest(&text).unwrap();
860 assert_eq!(parsed.render, RenderSettings::default());
861 assert_eq!(parsed.male_model, None);
862 }
863
864 #[cfg(unix)]
865 #[test]
866 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
867 let root = temp_root("link");
868 let opened = create_project(&root, "Ridder").unwrap();
869 let outside = temp_root("outside-file");
870 fs::create_dir_all(&outside).unwrap();
871 let secret = outside.join("geheim.glb");
872 fs::write(&secret, b"nope").unwrap();
873 std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap();
874 let mut broken = opened.clone();
875 broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string());
876 let error = save_project(&broken).unwrap_err();
877 assert!(
878 error.to_string().contains("buiten de projectmap"),
879 "{error}"
880 );
881 fs::write(root.join("assets/body/male.glb"), b"ok").unwrap();
882 let entries = list_dir(&root, "assets/body").unwrap();
883 assert!(entries
884 .iter()
885 .any(|entry| entry.name == "male.glb" && !entry.is_dir));
886 let error = list_dir(&root, "../").unwrap_err();
887 assert!(error.to_string().contains("buiten de projectmap"));
888 let _ = fs::remove_dir_all(&root);
889 let _ = fs::remove_dir_all(&outside);
890 }
891}