BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

export_set.rs 42.8 KiB Download Raw
1//! Safe reader/index for completed production export runs.
2//!
3//! A run directory is accepted only when both `render.json` (schema 3) and
4//! `complete.json` are present, their identity contracts match, every listed
5//! PNG checksum verifies, and each map decodes under the published pixel
6//! contract. Filenames alone never prove compatibility.
7use crate::{
8 digest::{self, is_sha256_hex},
9 production::{MAX_HIGHRES, PASSES, SCALE},
10 project::{self, CameraFraming},
11};
12use serde::Deserialize;
13use std::{
14 collections::BTreeMap,
15 fs,
16 io::Read,
17 path::{Path, PathBuf},
18};
19
20/// Output metadata schema written by production and required by this reader.
21/// 5: per-sex narrow depth range (depth_near/depth_far/depth_encoding, t964u);
22/// schema-4 runs used the global 0.05–40 m range and are not mixed in.
23pub const OUTPUT_META_VERSION: u32 = 5;
24pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
25/// Hard ceiling for a single published PNG on disk (encoded).
26pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
27/// Same batch file ceiling as production planning.
28pub const MAX_PNG_FILES: u32 = 500_000;
29const MAX_MANIFEST_BYTES: u64 = 32 * 1024 * 1024;
30const MAX_CLIP_NAME: usize = 256;
31const MAX_CLIPS: usize = 4096;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ExportError {
35 Incomplete(String),
36 Identity(String),
37 Io(String),
38 Corrupt(String),
39 Resource(String),
40 Traversal(String),
41}
42
43impl std::fmt::Display for ExportError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::Incomplete(m)
47 | Self::Identity(m)
48 | Self::Io(m)
49 | Self::Corrupt(m)
50 | Self::Resource(m)
51 | Self::Traversal(m) => write!(f, "{m}"),
52 }
53 }
54}
55
56impl std::error::Error for ExportError {}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum MapKind {
60 Diffuse,
61 Normal,
62 Specular,
63 Depth,
64}
65
66impl MapKind {
67 pub fn token(self) -> &'static str {
68 match self {
69 Self::Diffuse => "diffuse",
70 Self::Normal => "normal",
71 Self::Specular => "specular",
72 Self::Depth => "depth",
73 }
74 }
75
76 pub fn parse(token: &str) -> Option<Self> {
77 match token {
78 "diffuse" => Some(Self::Diffuse),
79 "normal" => Some(Self::Normal),
80 "specular" => Some(Self::Specular),
81 "depth" => Some(Self::Depth),
82 _ => None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum MapImage {
89 Rgba8 {
90 width: u32,
91 height: u32,
92 rgba: Vec<u8>,
93 },
94 Depth16 {
95 width: u32,
96 height: u32,
97 /// Interleaved gray, alpha samples (length = width*height*2).
98 samples: Vec<u16>,
99 },
100}
101
102impl MapImage {
103 pub fn width(&self) -> u32 {
104 match self {
105 Self::Rgba8 { width, .. } | Self::Depth16 { width, .. } => *width,
106 }
107 }
108 pub fn height(&self) -> u32 {
109 match self {
110 Self::Rgba8 { height, .. } | Self::Depth16 { height, .. } => *height,
111 }
112 }
113}
114
115#[derive(Debug, Clone, Deserialize)]
116pub struct SourceRef {
117 pub path: String,
118 pub sha256: String,
119}
120
121#[derive(Debug, Clone, Deserialize)]
122pub struct PresetRef {
123 pub name: String,
124 pub sha256: String,
125}
126
127#[derive(Debug, Clone, Deserialize)]
128pub struct ClipRecord {
129 pub name: String,
130 pub token: String,
131 pub frames: u32,
132 pub duration: f32,
133}
134
135#[derive(Debug, Clone, Deserialize)]
136pub struct PivotSourceJson {
137 pub kind: String,
138 #[serde(default)]
139 pub joint_index: Option<usize>,
140 #[serde(default)]
141 pub joint_name: Option<String>,
142 #[serde(default)]
143 pub reason: Option<String>,
144}
145
146#[derive(Debug, Clone, Deserialize)]
147pub struct RenderManifest {
148 pub format_version: u32,
149 pub status: String,
150 pub project: ProjectRef,
151 pub sex: String,
152 pub set: String,
153 pub body_model: SourceRef,
154 #[serde(default)]
155 pub equipment_preset: Option<PresetRef>,
156 pub target: [u32; 2],
157 pub fps: u32,
158 pub highres_scale: u32,
159 pub framing: CameraFraming,
160 pub pivot_source: PivotSourceJson,
161 pub near: f32,
162 pub far: f32,
163 pub depth_near: f32,
164 pub depth_far: f32,
165 pub depth_encoding: String,
166 pub camera_distance: f32,
167 pub pitches: Vec<i32>,
168 pub yaws: Vec<u32>,
169 pub pass_order: Vec<String>,
170 pub saved_maps: Vec<String>,
171 pub clips: Vec<ClipRecord>,
172 pub images: u32,
173 pub passes: u32,
174 pub png_files: u32,
175}
176
177#[derive(Debug, Clone, Deserialize)]
178pub struct ProjectRef {
179 pub id: String,
180 pub name: String,
181}
182
183#[derive(Debug, Clone, Deserialize)]
184pub struct CompleteIdentity {
185 pub project_id: String,
186 pub project_name: String,
187 pub sex: String,
188 pub set: String,
189 pub body_model_sha256: String,
190 #[serde(default)]
191 pub equipment_preset_sha256: Option<String>,
192 pub target: [u32; 2],
193 pub fps: u32,
194 pub framing: CameraFraming,
195 pub pivot_source: PivotSourceJson,
196 pub near: f32,
197 pub far: f32,
198 pub clips: Vec<ClipRecord>,
199 pub saved_maps: Vec<String>,
200}
201
202#[derive(Debug, Clone, Deserialize)]
203pub struct CompleteManifest {
204 pub format_version: u32,
205 pub status: String,
206 pub identity: CompleteIdentity,
207 pub passes: u32,
208 pub png_files: u32,
209 pub png_sha256: BTreeMap<String, String>,
210 /// Per-map atlases + `.ppda` written before this marker (t955u).
211 pub atlas: AtlasRef,
212}
213
214#[derive(Debug, Clone, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct AtlasRef {
217 pub ppda: String,
218 pub ppda_sha256: String,
219 pub maps: BTreeMap<String, AtlasMapRef>,
220}
221
222#[derive(Debug, Clone, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct AtlasMapRef {
225 pub file: String,
226 pub sha256: String,
227}
228
229#[derive(Debug, Clone)]
230pub struct ExportRun {
231 root: PathBuf,
232 pub render: RenderManifest,
233 pub complete: CompleteManifest,
234}
235
236impl ExportRun {
237 pub fn root(&self) -> &Path {
238 &self.root
239 }
240
241 pub fn open(run_dir: &Path) -> Result<Self, ExportError> {
242 let root = canonicalize_dir(run_dir)?;
243 if root.join("cancelled.json").exists() || root.join("failed.json").exists() {
244 return Err(ExportError::Incomplete(
245 "run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(),
246 ));
247 }
248 let render_bytes = read_manifest_file(&root, "render.json")?;
249 let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
250 if matches!(err, ExportError::Incomplete(_)) {
251 ExportError::Incomplete(
252 "complete.json ontbreekt; de exportset is niet volledig".into(),
253 )
254 } else {
255 err
256 }
257 })?;
258 let render: RenderManifest = serde_json::from_slice(&render_bytes)
259 .map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;
260 let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)
261 .map_err(|e| ExportError::Corrupt(format!("complete.json: {e}")))?;
262 validate_render(&render)?;
263 validate_complete(&complete)?;
264 identities_match(&render, &complete)?;
265 if complete.png_sha256.len() as u32 != complete.png_files {
266 return Err(ExportError::Corrupt(
267 "complete.json png_sha256 telt niet gelijk aan png_files".into(),
268 ));
269 }
270 if complete.png_files != render.png_files {
271 return Err(ExportError::Identity(
272 "png_files in complete.json wijkt af van render.json".into(),
273 ));
274 }
275 // Verify every checksum exists as a bounded regular file under the run.
276 for (relative, digest) in &complete.png_sha256 {
277 let path = resolve_inside(&root, relative)?;
278 verify_regular_file(&path, &root)?;
279 let meta = fs::metadata(&path).map_err(|e| ExportError::Io(e.to_string()))?;
280 if meta.len() > MAX_PNG_BYTES {
281 return Err(ExportError::Resource(format!(
282 "PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes"
283 )));
284 }
285 let bytes = fs::read(&path).map_err(|e| ExportError::Io(e.to_string()))?;
286 let actual = digest::sha256_hex(&bytes);
287 if actual != *digest {
288 return Err(ExportError::Corrupt(format!(
289 "checksum mismatch voor '{relative}'"
290 )));
291 }
292 }
293 Ok(Self {
294 root,
295 render,
296 complete,
297 })
298 }
299
300 pub fn map_path(
301 &self,
302 clip_token: &str,
303 frame: u32,
304 pitch: i32,
305 yaw: u32,
306 kind: MapKind,
307 ) -> Result<String, ExportError> {
308 let relative = crate::production::output_path(
309 &self.render.sex,
310 &self.render.set,
311 clip_token,
312 frame,
313 pitch,
314 yaw,
315 kind.token(),
316 )
317 .map_err(ExportError::Identity)?;
318 let key = relative
319 .to_str()
320 .ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?
321 .replace('\\', "/");
322 if !self.complete.png_sha256.contains_key(&key) {
323 return Err(ExportError::Incomplete(format!(
324 "map '{key}' staat niet in complete.json"
325 )));
326 }
327 Ok(key)
328 }
329
330 pub fn load_map(
331 &self,
332 clip_token: &str,
333 frame: u32,
334 pitch: i32,
335 yaw: u32,
336 kind: MapKind,
337 ) -> Result<MapImage, ExportError> {
338 let key = self.map_path(clip_token, frame, pitch, yaw, kind)?;
339 self.load_relative(&key, kind)
340 }
341
342 pub fn load_relative(
343 &self,
344 relative: &str,
345 expected: MapKind,
346 ) -> Result<MapImage, ExportError> {
347 let expected_digest = self
348 .complete
349 .png_sha256
350 .get(relative)
351 .ok_or_else(|| ExportError::Incomplete(format!("onbekende map '{relative}'")))?;
352 let path = resolve_inside(&self.root, relative)?;
353 verify_regular_file(&path, &self.root)?;
354 let bytes = read_bounded_file(&path, MAX_PNG_BYTES)?;
355 let actual = digest::sha256_hex(&bytes);
356 if actual != *expected_digest {
357 return Err(ExportError::Corrupt(format!(
358 "checksum mismatch voor '{relative}'"
359 )));
360 }
361 let token = relative
362 .rsplit('_')
363 .next()
364 .and_then(|s| s.strip_suffix(".png"))
365 .ok_or_else(|| ExportError::Corrupt(format!("geen map-token in '{relative}'")))?;
366 let kind = MapKind::parse(token).ok_or_else(|| {
367 ExportError::Corrupt(format!("onbekend map-token '{token}' in '{relative}'"))
368 })?;
369 if kind != expected {
370 return Err(ExportError::Identity(format!(
371 "verwachtte {} maar pad is {}",
372 expected.token(),
373 kind.token()
374 )));
375 }
376 let [width, height] = self.render.target;
377 decode_map(&bytes, kind, width, height)
378 }
379
380 pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {
381 self.complete
382 .png_sha256
383 .iter()
384 .map(|(k, v)| (k.as_str(), v.as_str()))
385 }
386}
387
388fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
389 if m.format_version != OUTPUT_META_VERSION {
390 return Err(ExportError::Identity(if m.format_version == 4 {
391 "render.json schema 4: oude export met globaal dieptebereik 0,05–40 m; opnieuw renderen"
392 .into()
393 } else {
394 format!(
395 "render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})",
396 m.format_version
397 )
398 }));
399 }
400 if m.status != "incomplete" && m.status != "complete" {
401 // production leaves status=incomplete in render.json even after success
402 return Err(ExportError::Corrupt(format!(
403 "onbekende render.json status '{}'",
404 m.status
405 )));
406 }
407 if !["male", "female"].contains(&m.sex.as_str()) {
408 return Err(ExportError::Identity("ongeldig geslacht".into()));
409 }
410 if m.set != "body" && !crate::equipment::valid_name(&m.set) {
411 return Err(ExportError::Identity("ongeldige setnaam".into()));
412 }
413 if m.target[0] == 0 || m.target[1] == 0 || m.target[0] > MAX_TARGET || m.target[1] > MAX_TARGET
414 {
415 return Err(ExportError::Resource("target buiten 1–256".into()));
416 }
417 if m.fps == 0 || m.fps > 240 {
418 return Err(ExportError::Identity("ongeldige fps".into()));
419 }
420 if m.highres_scale != SCALE {
421 return Err(ExportError::Identity("highres_scale wijkt af van 8".into()));
422 }
423 m.framing.validate().map_err(ExportError::Identity)?;
424 let (near, far) = crate::camera::depth_range(m.framing);
425 if m.near != near || m.far != far || m.depth_near != near || m.depth_far != far {
426 return Err(ExportError::Identity(
427 "near/far wijkt af van het vaste dieptebereik van deze framing".into(),
428 ));
429 }
430 if m.depth_encoding != crate::camera::DEPTH_ENCODING {
431 return Err(ExportError::Identity("depth_encoding onbekend".into()));
432 }
433 if m.camera_distance != crate::camera::CAMERA_DISTANCE {
434 return Err(ExportError::Identity(
435 "camera_distance wijkt af van contract".into(),
436 ));
437 }
438 if m.pitches != crate::preview::PRODUCTION_PITCHES {
439 return Err(ExportError::Identity(
440 "pitches wijken af van −60,−30,0,+30,+60".into(),
441 ));
442 }
443 if m.yaws != crate::production::production_yaws() {
444 return Err(ExportError::Identity(
445 "yaws wijken af van 0…330 stap 30".into(),
446 ));
447 }
448 if m.pass_order != PASSES {
449 return Err(ExportError::Identity(
450 "pass_order wijkt af van contract".into(),
451 ));
452 }
453 if m.saved_maps != ["diffuse", "depth", "normal", "specular"] {
454 return Err(ExportError::Identity(
455 "saved_maps wijkt af van diffuse/depth/normal/specular".into(),
456 ));
457 }
458 if m.clips.is_empty() || m.clips.len() > MAX_CLIPS {
459 return Err(ExportError::Resource("clip-inventaris ongeldig".into()));
460 }
461 for clip in &m.clips {
462 if clip.token.is_empty()
463 || clip.token.len() > 64
464 || !crate::equipment::valid_name(&clip.token)
465 {
466 return Err(ExportError::Identity(format!(
467 "ongeldige clip-token '{}'",
468 clip.token
469 )));
470 }
471 if clip.name.len() > MAX_CLIP_NAME {
472 return Err(ExportError::Resource("clipnaam te lang".into()));
473 }
474 if clip.frames == 0 {
475 return Err(ExportError::Identity("clip zonder frames".into()));
476 }
477 if !clip.duration.is_finite() || clip.duration < 0. {
478 return Err(ExportError::Identity("ongeldige clipduur".into()));
479 }
480 }
481 if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
482 return Err(ExportError::Resource("png_files buiten limiet".into()));
483 }
484 if !is_sha256_hex(&m.body_model.sha256) {
485 return Err(ExportError::Corrupt(
486 "body_model.sha256 is geen SHA-256 hex".into(),
487 ));
488 }
489 project::portable_relative(&m.body_model.path).map_err(ExportError::Traversal)?;
490 if m.project.id.is_empty() || m.project.id.len() > 128 {
491 return Err(ExportError::Identity("project.id ongeldig".into()));
492 }
493 if m.project.name.len() > 256 {
494 return Err(ExportError::Identity("project.name te lang".into()));
495 }
496 match &m.equipment_preset {
497 None if m.set == "body" => {}
498 Some(p)
499 if m.set == p.name
500 && is_sha256_hex(&p.sha256)
501 && crate::equipment::valid_name(&p.name) => {}
502 _ => {
503 return Err(ExportError::Identity(
504 "equipment_preset past niet bij set of mist geldige sha256".into(),
505 ))
506 }
507 }
508 match m.pivot_source.kind.as_str() {
509 "manual" | "neck" | "fallback" => {}
510 other => {
511 return Err(ExportError::Identity(format!(
512 "onbekende pivot_source.kind '{other}'"
513 )))
514 }
515 }
516 Ok(())
517}
518
519fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {
520 if m.format_version != OUTPUT_META_VERSION {
521 return Err(ExportError::Identity(format!(
522 "complete.json format_version {} wordt niet ondersteund",
523 m.format_version
524 )));
525 }
526 if m.status != "complete" {
527 return Err(ExportError::Incomplete(
528 "complete.json status is niet 'complete'".into(),
529 ));
530 }
531 if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
532 return Err(ExportError::Resource("png_files buiten limiet".into()));
533 }
534 if m.png_sha256.len() > MAX_PNG_FILES as usize {
535 return Err(ExportError::Resource("te veel png_sha256-items".into()));
536 }
537 if !is_sha256_hex(&m.identity.body_model_sha256) {
538 return Err(ExportError::Corrupt(
539 "identity.body_model_sha256 ongeldig".into(),
540 ));
541 }
542 if let Some(hash) = &m.identity.equipment_preset_sha256 {
543 if !is_sha256_hex(hash) {
544 return Err(ExportError::Corrupt(
545 "identity.equipment_preset_sha256 ongeldig".into(),
546 ));
547 }
548 }
549 for (path, hash) in &m.png_sha256 {
550 project::portable_relative(path).map_err(ExportError::Traversal)?;
551 if !path.ends_with(".png") {
552 return Err(ExportError::Corrupt(format!(
553 "png_sha256-sleutel '{path}' is geen .png"
554 )));
555 }
556 if !is_sha256_hex(hash) {
557 return Err(ExportError::Corrupt(format!(
558 "png_sha256 voor '{path}' is geen SHA-256 hex"
559 )));
560 }
561 }
562 Ok(())
563}
564
565fn identities_match(
566 render: &RenderManifest,
567 complete: &CompleteManifest,
568) -> Result<(), ExportError> {
569 let id = &complete.identity;
570 let check = |ok: bool, msg: &str| {
571 if ok {
572 Ok(())
573 } else {
574 Err(ExportError::Identity(msg.into()))
575 }
576 };
577 check(id.project_id == render.project.id, "project.id mismatch")?;
578 check(
579 id.project_name == render.project.name,
580 "project.name mismatch",
581 )?;
582 check(id.sex == render.sex, "sex mismatch")?;
583 check(id.set == render.set, "set mismatch")?;
584 check(
585 id.body_model_sha256 == render.body_model.sha256,
586 "body_model sha256 mismatch",
587 )?;
588 check(id.target == render.target, "target mismatch")?;
589 check(id.fps == render.fps, "fps mismatch")?;
590 check(id.framing == render.framing, "framing mismatch")?;
591 check(
592 id.near == render.near && id.far == render.far,
593 "near/far mismatch",
594 )?;
595 check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
596 let atlas = &complete.atlas;
597 check(
598 atlas.ppda == crate::atlas::ppda_name(&render.sex, &render.set)
599 && is_sha256_hex(&atlas.ppda_sha256),
600 "atlas.ppda naam/sha256 ongeldig",
601 )?;
602 check(atlas.maps.len() == 4, "atlas.maps moet vier maps bevatten")?;
603 for kind in crate::atlas::ATLAS_MAPS {
604 let entry = atlas
605 .maps
606 .get(kind.token())
607 .ok_or_else(|| ExportError::Identity(format!("atlas mist {}", kind.token())))?;
608 check(
609 entry.file == crate::atlas::atlas_png_name(&render.sex, &render.set, kind)
610 && is_sha256_hex(&entry.sha256),
611 "atlas-bestandsnaam/sha256 ongeldig",
612 )?;
613 }
614 check(
615 id.clips.len() == render.clips.len(),
616 "clips length mismatch",
617 )?;
618 for (a, b) in id.clips.iter().zip(render.clips.iter()) {
619 if a.token != b.token || a.frames != b.frames || a.name != b.name {
620 return Err(ExportError::Identity("clip inventory mismatch".into()));
621 }
622 }
623 check(
624 id.pivot_source.kind == render.pivot_source.kind,
625 "pivot_source mismatch",
626 )?;
627 let preset_hash = render.equipment_preset.as_ref().map(|p| p.sha256.clone());
628 check(
629 id.equipment_preset_sha256 == preset_hash,
630 "equipment preset sha256 mismatch",
631 )?;
632 Ok(())
633}
634
635pub(crate) fn decode_map(
636 bytes: &[u8],
637 kind: MapKind,
638 expect_w: u32,
639 expect_h: u32,
640) -> Result<MapImage, ExportError> {
641 decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET)
642}
643
644/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under
645/// the published pixel contract. Atlases use the same rules with a larger cap.
646pub(crate) fn decode_map_sized(
647 bytes: &[u8],
648 kind: MapKind,
649 expect_w: u32,
650 expect_h: u32,
651 max_dim: u32,
652) -> Result<MapImage, ExportError> {
653 if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
654 return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
655 }
656 let decoded = expect_w as usize * expect_h as usize * 4;
657 let mut decoder = png::Decoder::new_with_limits(
658 std::io::Cursor::new(bytes),
659 png::Limits {
660 bytes: decoded.saturating_mul(2).max(64 * 1024 * 1024),
661 },
662 );
663 decoder.set_transformations(png::Transformations::IDENTITY);
664 let mut reader = decoder
665 .read_info()
666 .map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;
667 let info = reader.info();
668 if info.width != expect_w || info.height != expect_h {
669 return Err(ExportError::Corrupt(format!(
670 "PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",
671 info.width, info.height
672 )));
673 }
674 if info.width > max_dim || info.height > max_dim {
675 return Err(ExportError::Resource("PNG groter dan limiet".into()));
676 }
677 match kind {
678 MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
679 if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
680 return Err(ExportError::Corrupt(
681 "RGBA8-map heeft onverwacht kleurtype/bitdiepte".into(),
682 ));
683 }
684 let size = reader.output_buffer_size().ok_or_else(|| {
685 ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
686 })?;
687 let mut buf = vec![0u8; size];
688 let frame = reader
689 .next_frame(&mut buf)
690 .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
691 if frame.buffer_size() != expect_w as usize * expect_h as usize * 4 {
692 return Err(ExportError::Corrupt("PNG buffergrootte klopt niet".into()));
693 }
694 buf.truncate(frame.buffer_size());
695 if kind == MapKind::Normal {
696 validate_normals(&buf, expect_w, expect_h)?;
697 }
698 if kind == MapKind::Specular {
699 validate_specular_channels(&buf)?;
700 }
701 Ok(MapImage::Rgba8 {
702 width: expect_w,
703 height: expect_h,
704 rgba: buf,
705 })
706 }
707 MapKind::Depth => {
708 if info.color_type != png::ColorType::GrayscaleAlpha
709 || info.bit_depth != png::BitDepth::Sixteen
710 {
711 return Err(ExportError::Corrupt(
712 "depth-map moet gray-alpha 16-bit zijn".into(),
713 ));
714 }
715 let size = reader.output_buffer_size().ok_or_else(|| {
716 ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
717 })?;
718 let mut buf = vec![0u8; size];
719 let frame = reader
720 .next_frame(&mut buf)
721 .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
722 let expected = expect_w as usize * expect_h as usize * 4; // 2 channels * 2 bytes
723 if frame.buffer_size() != expected {
724 return Err(ExportError::Corrupt(
725 "depth buffergrootte klopt niet".into(),
726 ));
727 }
728 buf.truncate(frame.buffer_size());
729 let mut samples = Vec::with_capacity(expect_w as usize * expect_h as usize * 2);
730 for chunk in buf.chunks_exact(2) {
731 samples.push(u16::from_be_bytes([chunk[0], chunk[1]]));
732 }
733 validate_depth_alpha(&samples)?;
734 Ok(MapImage::Depth16 {
735 width: expect_w,
736 height: expect_h,
737 samples,
738 })
739 }
740 }
741}
742
743fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {
744 for px in rgba.chunks_exact(4) {
745 let a = px[3];
746 if a == 0 {
747 if px[0] != 0 || px[1] != 0 || px[2] != 0 {
748 return Err(ExportError::Corrupt(
749 "transparante normal heeft niet-nul RGB".into(),
750 ));
751 }
752 continue;
753 }
754 let nx = px[0] as f32 / 255. * 2. - 1.;
755 let ny = px[1] as f32 / 255. * 2. - 1.;
756 let nz = px[2] as f32 / 255. * 2. - 1.;
757 let len = (nx * nx + ny * ny + nz * nz).sqrt();
758 if !(0.85..=1.15).contains(&len) {
759 return Err(ExportError::Corrupt(format!(
760 "normal lengte {len} buiten tolerantie"
761 )));
762 }
763 }
764 Ok(())
765}
766
767fn validate_specular_channels(rgba: &[u8]) -> Result<(), ExportError> {
768 for px in rgba.chunks_exact(4) {
769 if px[0] != px[1] || px[1] != px[2] {
770 return Err(ExportError::Corrupt(
771 "specular RGB-kanalen zijn niet gelijk".into(),
772 ));
773 }
774 }
775 Ok(())
776}
777
778fn validate_depth_alpha(samples: &[u16]) -> Result<(), ExportError> {
779 for pair in samples.chunks_exact(2) {
780 let gray = pair[0];
781 let alpha = pair[1];
782 if alpha == 0 && gray != 0 {
783 return Err(ExportError::Corrupt(
784 "transparante depth heeft niet-nul gray".into(),
785 ));
786 }
787 }
788 Ok(())
789}
790
791fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {
792 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
793 if !meta.is_dir() {
794 return Err(ExportError::Io("runpad is geen map".into()));
795 }
796 path.canonicalize()
797 .map_err(|e| ExportError::Io(e.to_string()))
798}
799
800fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
801 let path = root.join(name);
802 if !path.exists() {
803 return Err(ExportError::Incomplete(format!("{name} ontbreekt")));
804 }
805 verify_regular_file(&path, root)?;
806 read_bounded_file(&path, MAX_MANIFEST_BYTES)
807}
808
809pub(crate) fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
810 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
811 if meta.len() > max {
812 return Err(ExportError::Resource(format!(
813 "{} groter dan {max} bytes",
814 path.display()
815 )));
816 }
817 let file = fs::File::open(path).map_err(|e| ExportError::Io(e.to_string()))?;
818 let mut bytes = Vec::new();
819 file.take(max + 1)
820 .read_to_end(&mut bytes)
821 .map_err(|e| ExportError::Io(e.to_string()))?;
822 if bytes.len() as u64 > max {
823 return Err(ExportError::Resource(format!(
824 "{} groter dan {max} bytes",
825 path.display()
826 )));
827 }
828 Ok(bytes)
829}
830
831fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
832 let clean = project::portable_relative(relative).map_err(ExportError::Traversal)?;
833 let path = root.join(&clean);
834 // Lexical rejection of oddities already handled; still verify canonical containment.
835 Ok(path)
836}
837
838pub(crate) fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
839 let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
840 if meta.file_type().is_symlink() {
841 return Err(ExportError::Traversal(format!(
842 "symlink geweigerd: {}",
843 path.display()
844 )));
845 }
846 if !meta.is_file() {
847 return Err(ExportError::Io(format!(
848 "geen regulier bestand: {}",
849 path.display()
850 )));
851 }
852 let root_real = root
853 .canonicalize()
854 .map_err(|e| ExportError::Io(e.to_string()))?;
855 let path_real = path
856 .canonicalize()
857 .map_err(|e| ExportError::Io(e.to_string()))?;
858 if !path_real.starts_with(&root_real) {
859 return Err(ExportError::Traversal(format!(
860 "pad verlaat runmap: {}",
861 path.display()
862 )));
863 }
864 Ok(())
865}
866
867/// Build the identity object embedded in complete.json (shared with production).
868pub fn complete_identity_json(render: &serde_json::Value) -> Result<serde_json::Value, String> {
869 let project = render.get("project").ok_or("render.json mist project")?;
870 Ok(serde_json::json!({
871 "project_id": project.get("id").ok_or("project.id")?,
872 "project_name": project.get("name").ok_or("project.name")?,
873 "sex": render.get("sex").ok_or("sex")?,
874 "set": render.get("set").ok_or("set")?,
875 "body_model_sha256": render.pointer("/body_model/sha256").ok_or("body sha")?,
876 "equipment_preset_sha256": render.pointer("/equipment_preset/sha256").cloned().unwrap_or(serde_json::Value::Null),
877 "target": render.get("target").ok_or("target")?,
878 "fps": render.get("fps").ok_or("fps")?,
879 "framing": render.get("framing").ok_or("framing")?,
880 "pivot_source": render.get("pivot_source").ok_or("pivot")?,
881 "near": render.get("near").ok_or("near")?,
882 "far": render.get("far").ok_or("far")?,
883 "clips": render.get("clips").ok_or("clips")?,
884 "saved_maps": render.get("saved_maps").ok_or("maps")?,
885 }))
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use crate::production::{write_depth, write_rgba};
892 use crate::render_job::ImageBuf;
893 use std::sync::atomic::{AtomicU64, Ordering};
894
895 struct Temp(PathBuf);
896 impl Temp {
897 fn new() -> Self {
898 static N: AtomicU64 = AtomicU64::new(0);
899 let id = N.fetch_add(1, Ordering::Relaxed);
900 let path = std::env::temp_dir().join(format!(
901 "paperdoll-export-set-{}-{}",
902 std::process::id(),
903 id
904 ));
905 let _ = fs::remove_dir_all(&path);
906 fs::create_dir_all(&path).unwrap();
907 Self(path)
908 }
909 }
910 impl Drop for Temp {
911 fn drop(&mut self) {
912 let _ = fs::remove_dir_all(&self.0);
913 }
914 }
915
916 fn framing() -> CameraFraming {
917 CameraFraming {
918 height_milli: 2600,
919 center_y_milli: 1000,
920 pivot_y_milli: Some(1460),
921 }
922 }
923
924 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
925 let data = serde_json::to_vec_pretty(value).unwrap();
926 let path = root.join(name);
927 let temp = path.with_extension(format!("part-{}", std::process::id()));
928 fs::write(&temp, &data).unwrap();
929 fs::hard_link(&temp, &path).unwrap();
930 let _ = fs::remove_file(temp);
931 }
932
933 fn atlas_ref() -> serde_json::Value {
934 let sha = digest::sha256_hex(b"atlas");
935 let map = |m: &str| serde_json::json!({"file": format!("male-body-{m}-atlas.png"), "sha256": sha});
936 serde_json::json!({
937 "ppda": "male-body-atlas.ppda",
938 "ppda_sha256": sha,
939 "maps": {"diffuse": map("diffuse"), "normal": map("normal"), "depth": map("depth"), "specular": map("specular")}
940 })
941 }
942
943 fn base_render(png_files: u32) -> serde_json::Value {
944 serde_json::json!({
945 "format_version": OUTPUT_META_VERSION,
946 "status": "incomplete",
947 "project": {"id": "demo", "name": "Demo"},
948 "sex": "male",
949 "set": "body",
950 "body_model": {"path": "assets/body/x.glb", "sha256": digest::sha256_hex(b"body")},
951 "equipment_preset": null,
952 "target": [1, 1],
953 "fps": 12,
954 "highres_scale": 8,
955 "framing": framing(),
956 "pivot_source": {"kind": "manual"},
957 "near": crate::camera::depth_range(framing()).0,
958 "far": crate::camera::depth_range(framing()).1,
959 "depth_near": crate::camera::depth_range(framing()).0,
960 "depth_far": crate::camera::depth_range(framing()).1,
961 "depth_encoding": crate::camera::DEPTH_ENCODING,
962 "camera_distance": 10.0,
963 "yaw_zero": "camera on -Z",
964 "normal_space": "view (+Y up), PNG rows top-down",
965 "pitches": crate::preview::PRODUCTION_PITCHES,
966 "yaws": crate::production::production_yaws(),
967 "yaw_step": 30,
968 "sampling": "test",
969 "alpha": "straight",
970 "diffuse": "unlit",
971 "depth": "16-bit",
972 "specular": "scalar",
973 "pass_order": PASSES,
974 "saved_maps": ["diffuse", "depth", "normal", "specular"],
975 "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
976 "images": 1,
977 "passes": 6,
978 "png_files": png_files
979 })
980 }
981
982 fn publish_four(root: &Path) -> BTreeMap<String, String> {
983 let mut map = BTreeMap::new();
984 let rels = [
985 (
986 "male/body/clip000-bind/f000000/p-60_y000_diffuse.png",
987 "diffuse",
988 ),
989 (
990 "male/body/clip000-bind/f000000/p-60_y000_depth.png",
991 "depth",
992 ),
993 (
994 "male/body/clip000-bind/f000000/p-60_y000_normal.png",
995 "normal",
996 ),
997 (
998 "male/body/clip000-bind/f000000/p-60_y000_specular.png",
999 "specular",
1000 ),
1001 ];
1002 for (rel, kind) in rels {
1003 let path = Path::new(rel);
1004 match kind {
1005 "depth" => write_depth(root, path, 1, 1, &[20000, 65535]).unwrap(),
1006 "normal" => write_rgba(
1007 root,
1008 path,
1009 &ImageBuf {
1010 width: 1,
1011 height: 1,
1012 rgba: vec![128, 128, 255, 255],
1013 },
1014 )
1015 .unwrap(),
1016 "specular" => write_rgba(
1017 root,
1018 path,
1019 &ImageBuf {
1020 width: 1,
1021 height: 1,
1022 rgba: vec![0, 0, 0, 255],
1023 },
1024 )
1025 .unwrap(),
1026 _ => write_rgba(
1027 root,
1028 path,
1029 &ImageBuf {
1030 width: 1,
1031 height: 1,
1032 rgba: vec![10, 20, 30, 255],
1033 },
1034 )
1035 .unwrap(),
1036 };
1037 let bytes = fs::read(root.join(rel)).unwrap();
1038 map.insert(rel.to_string(), digest::sha256_hex(&bytes));
1039 }
1040 map
1041 }
1042
1043 #[test]
1044 fn opens_completed_run_and_reads_maps() {
1045 let tmp = Temp::new();
1046 let checksums = publish_four(&tmp.0);
1047 let render = base_render(4);
1048 write_atomic_json(&tmp.0, "render.json", &render);
1049 let identity = complete_identity_json(&render).unwrap();
1050 write_atomic_json(
1051 &tmp.0,
1052 "complete.json",
1053 &serde_json::json!({
1054 "format_version": OUTPUT_META_VERSION,
1055 "status": "complete",
1056 "identity": identity,
1057 "passes": 6,
1058 "png_files": 4,
1059 "png_sha256": checksums,
1060 "atlas": atlas_ref()
1061 }),
1062 );
1063 let run = ExportRun::open(&tmp.0).unwrap();
1064 let diffuse = run
1065 .load_map("clip000-bind", 0, -60, 0, MapKind::Diffuse)
1066 .unwrap();
1067 match diffuse {
1068 MapImage::Rgba8 {
1069 width,
1070 height,
1071 rgba,
1072 } => {
1073 assert_eq!((width, height), (1, 1));
1074 assert_eq!(rgba, [10, 20, 30, 255]);
1075 }
1076 _ => panic!("expected rgba"),
1077 }
1078 let depth = run
1079 .load_map("clip000-bind", 0, -60, 0, MapKind::Depth)
1080 .unwrap();
1081 match depth {
1082 MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),
1083 _ => panic!("expected depth"),
1084 }
1085 }
1086
1087 #[test]
1088 fn rejects_missing_complete_and_checksum_mismatch() {
1089 let tmp = Temp::new();
1090 let render = base_render(4);
1091 write_atomic_json(&tmp.0, "render.json", &render);
1092 assert!(matches!(
1093 ExportRun::open(&tmp.0),
1094 Err(ExportError::Incomplete(_))
1095 ));
1096 let mut checksums = publish_four(&tmp.0);
1097 *checksums.values_mut().next().unwrap() = digest::sha256_hex(b"tampered");
1098 let identity = complete_identity_json(&render).unwrap();
1099 write_atomic_json(
1100 &tmp.0,
1101 "complete.json",
1102 &serde_json::json!({
1103 "format_version": OUTPUT_META_VERSION,
1104 "status": "complete",
1105 "identity": identity,
1106 "passes": 6,
1107 "png_files": 4,
1108 "png_sha256": checksums,
1109 "atlas": atlas_ref()
1110 }),
1111 );
1112 assert!(matches!(
1113 ExportRun::open(&tmp.0),
1114 Err(ExportError::Corrupt(_))
1115 ));
1116 }
1117
1118 #[test]
1119 fn rejects_path_traversal_and_symlink_escape() {
1120 let tmp = Temp::new();
1121 let mut checksums = publish_four(&tmp.0);
1122 checksums.insert("../escape.png".into(), digest::sha256_hex(b"x"));
1123 let render = base_render(5);
1124 write_atomic_json(&tmp.0, "render.json", &render);
1125 let identity = complete_identity_json(&render).unwrap();
1126 write_atomic_json(
1127 &tmp.0,
1128 "complete.json",
1129 &serde_json::json!({
1130 "format_version": OUTPUT_META_VERSION,
1131 "status": "complete",
1132 "identity": identity,
1133 "passes": 6,
1134 "png_files": 5,
1135 "png_sha256": checksums,
1136 "atlas": atlas_ref()
1137 }),
1138 );
1139 assert!(matches!(
1140 ExportRun::open(&tmp.0),
1141 Err(ExportError::Traversal(_))
1142 ));
1143
1144 #[cfg(unix)]
1145 {
1146 let tmp = Temp::new();
1147 let outside = Temp::new();
1148 let checksums = publish_four(&tmp.0);
1149 let evil = "male/body/clip000-bind/f000000/p-60_y000_diffuse.png";
1150 let _ = fs::remove_file(tmp.0.join(evil));
1151 std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();
1152 fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();
1153 let render = base_render(4);
1154 write_atomic_json(&tmp.0, "render.json", &render);
1155 let identity = complete_identity_json(&render).unwrap();
1156 let mut checksums = checksums;
1157 checksums.insert(evil.into(), digest::sha256_hex(b"not-a-png"));
1158 write_atomic_json(
1159 &tmp.0,
1160 "complete.json",
1161 &serde_json::json!({
1162 "format_version": OUTPUT_META_VERSION,
1163 "status": "complete",
1164 "identity": identity,
1165 "passes": 6,
1166 "png_files": 4,
1167 "png_sha256": checksums,
1168 "atlas": atlas_ref()
1169 }),
1170 );
1171 assert!(matches!(
1172 ExportRun::open(&tmp.0),
1173 Err(ExportError::Traversal(_))
1174 ));
1175 }
1176 }
1177
1178 #[test]
1179 fn depth_range_contract_and_old_global_range_schema() {
1180 let parse = |v: serde_json::Value| -> RenderManifest { serde_json::from_value(v).unwrap() };
1181 validate_render(&parse(base_render(4))).unwrap();
1182 let mut old = base_render(4);
1183 old["format_version"] = 4.into();
1184 let err = validate_render(&parse(old)).unwrap_err();
1185 assert!(matches!(&err, ExportError::Identity(m) if m.contains("globaal dieptebereik")));
1186 for (key, value) in [
1187 ("near", serde_json::json!(0.05)),
1188 ("far", serde_json::json!(40.0)),
1189 ("depth_far", serde_json::json!(12.0)),
1190 ("depth_encoding", serde_json::json!("other")),
1191 ] {
1192 let mut doc = base_render(4);
1193 doc[key] = value;
1194 assert!(matches!(
1195 validate_render(&parse(doc)),
1196 Err(ExportError::Identity(_))
1197 ));
1198 }
1199 }
1200
1201 #[test]
1202 fn rejects_stale_schema_and_identity_drift() {
1203 let tmp = Temp::new();
1204 let checksums = publish_four(&tmp.0);
1205 let mut render = base_render(4);
1206 render["format_version"] = serde_json::json!(2);
1207 write_atomic_json(&tmp.0, "render.json", &render);
1208 write_atomic_json(
1209 &tmp.0,
1210 "complete.json",
1211 &serde_json::json!({
1212 "format_version": OUTPUT_META_VERSION,
1213 "status": "complete",
1214 "identity": {
1215 "project_id": "demo",
1216 "project_name": "Demo",
1217 "sex": "male",
1218 "set": "body",
1219 "body_model_sha256": digest::sha256_hex(b"body"),
1220 "equipment_preset_sha256": null,
1221 "target": [1,1],
1222 "fps": 12,
1223 "framing": framing(),
1224 "pivot_source": {"kind": "manual"},
1225 "near": crate::camera::depth_range(framing()).0,
1226 "far": crate::camera::depth_range(framing()).1,
1227 "clips": [{"name":"","token":"clip000-bind","frames":1,"duration":0.0}],
1228 "saved_maps": ["diffuse","depth","normal","specular"]
1229 },
1230 "passes": 6,
1231 "png_files": 4,
1232 "png_sha256": checksums,
1233 "atlas": atlas_ref()
1234 }),
1235 );
1236 assert!(matches!(
1237 ExportRun::open(&tmp.0),
1238 Err(ExportError::Identity(_))
1239 ));
1240 }
1241
1242 #[test]
1243 fn rejects_bad_normal_and_wrong_depth_type() {
1244 let tmp = Temp::new();
1245 // Broken normal: transparent with non-zero RGB
1246 write_rgba(
1247 &tmp.0,
1248 Path::new("bad_normal.png"),
1249 &ImageBuf {
1250 width: 1,
1251 height: 1,
1252 rgba: vec![10, 0, 0, 0],
1253 },
1254 )
1255 .unwrap();
1256 let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();
1257 assert!(matches!(
1258 decode_map(&bytes, MapKind::Normal, 1, 1),
1259 Err(ExportError::Corrupt(_))
1260 ));
1261 // RGBA written as depth should fail color type check
1262 write_rgba(
1263 &tmp.0,
1264 Path::new("fake_depth.png"),
1265 &ImageBuf {
1266 width: 1,
1267 height: 1,
1268 rgba: vec![0, 0, 0, 255],
1269 },
1270 )
1271 .unwrap();
1272 let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();
1273 assert!(matches!(
1274 decode_map(&bytes, MapKind::Depth, 1, 1),
1275 Err(ExportError::Corrupt(_))
1276 ));
1277 }
1278}