BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

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