BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

export_set.rs 46.7 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 let render_bytes = read_manifest_file(&root, "render.json")?;
255 let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
256 if matches!(err, ExportError::Incomplete(_)) {
257 ExportError::Incomplete(
258 "complete.json ontbreekt; de exportset is niet volledig".into(),
259 )
260 } else {
261 err
262 }
263 })?;
264 let render: RenderManifest = serde_json::from_slice(&render_bytes)
265 .map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;
266 let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)
267 .map_err(|e| ExportError::Corrupt(format!("complete.json: {e}")))?;
268 validate_render(&render)?;
269 validate_complete(&complete)?;
270 identities_match(&render, &complete)?;
271 if complete.png_sha256.len() as u32 != complete.png_files {
272 return Err(ExportError::Corrupt(
273 "complete.json png_sha256 telt niet gelijk aan png_files".into(),
274 ));
275 }
276 if complete.png_files != render.png_files {
277 return Err(ExportError::Identity(
278 "png_files in complete.json wijkt af van render.json".into(),
279 ));
280 }
281 // Verify every checksum exists as a bounded regular file under the run.
282 for (relative, digest) in &complete.png_sha256 {
283 let path = resolve_inside(&root, relative)?;
284 verify_regular_file(&path, &root)?;
285 let meta = fs::metadata(&path).map_err(|e| ExportError::Io(e.to_string()))?;
286 if meta.len() > MAX_PNG_BYTES {
287 return Err(ExportError::Resource(format!(
288 "PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes"
289 )));
290 }
291 let bytes = fs::read(&path).map_err(|e| ExportError::Io(e.to_string()))?;
292 let actual = digest::sha256_hex(&bytes);
293 if actual != *digest {
294 return Err(ExportError::Corrupt(format!(
295 "checksum mismatch voor '{relative}'"
296 )));
297 }
298 }
299 Ok(Self {
300 root,
301 render,
302 complete,
303 })
304 }
305
306 pub fn map_path(
307 &self,
308 clip_token: &str,
309 frame: u32,
310 pitch: i32,
311 yaw: u32,
312 kind: MapKind,
313 ) -> Result<String, ExportError> {
314 let relative = crate::production::output_path(
315 &self.render.sex,
316 &self.render.set,
317 clip_token,
318 frame,
319 pitch,
320 yaw,
321 kind.token(),
322 )
323 .map_err(ExportError::Identity)?;
324 let key = relative
325 .to_str()
326 .ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?
327 .replace('\\', "/");
328 if !self.complete.png_sha256.contains_key(&key) {
329 return Err(ExportError::Incomplete(format!(
330 "map '{key}' staat niet in complete.json"
331 )));
332 }
333 Ok(key)
334 }
335
336 pub fn load_map(
337 &self,
338 clip_token: &str,
339 frame: u32,
340 pitch: i32,
341 yaw: u32,
342 kind: MapKind,
343 ) -> Result<MapImage, ExportError> {
344 let key = self.map_path(clip_token, frame, pitch, yaw, kind)?;
345 self.load_relative(&key, kind)
346 }
347
348 pub fn load_relative(
349 &self,
350 relative: &str,
351 expected: MapKind,
352 ) -> Result<MapImage, ExportError> {
353 let expected_digest = self
354 .complete
355 .png_sha256
356 .get(relative)
357 .ok_or_else(|| ExportError::Incomplete(format!("onbekende map '{relative}'")))?;
358 let path = resolve_inside(&self.root, relative)?;
359 verify_regular_file(&path, &self.root)?;
360 let bytes = read_bounded_file(&path, MAX_PNG_BYTES)?;
361 let actual = digest::sha256_hex(&bytes);
362 if actual != *expected_digest {
363 return Err(ExportError::Corrupt(format!(
364 "checksum mismatch voor '{relative}'"
365 )));
366 }
367 let token = relative
368 .rsplit('_')
369 .next()
370 .and_then(|s| s.strip_suffix(".png"))
371 .ok_or_else(|| ExportError::Corrupt(format!("geen map-token in '{relative}'")))?;
372 let kind = MapKind::parse(token).ok_or_else(|| {
373 ExportError::Corrupt(format!("onbekend map-token '{token}' in '{relative}'"))
374 })?;
375 if kind != expected {
376 return Err(ExportError::Identity(format!(
377 "verwachtte {} maar pad is {}",
378 expected.token(),
379 kind.token()
380 )));
381 }
382 let [width, height] = self.render.target;
383 decode_map(&bytes, kind, width, height, self.render.depth_format()?)
384 }
385
386 pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {
387 self.complete
388 .png_sha256
389 .iter()
390 .map(|(k, v)| (k.as_str(), v.as_str()))
391 }
392}
393
394impl RenderManifest {
395 pub fn depth_format(&self) -> Result<crate::camera::DepthFormat, ExportError> {
396 let fmt = crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding)
397 .map_err(ExportError::Identity)?;
398 if (self.format_version >= 6) != self.depth_bits.is_some() {
399 return Err(ExportError::Identity(
400 "depth_bits past niet bij render.json-schema".into(),
401 ));
402 }
403 Ok(fmt)
404 }
405}
406
407fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
408 if !(MIN_OUTPUT_META_VERSION..=OUTPUT_META_VERSION).contains(&m.format_version) {
409 return Err(ExportError::Identity(if m.format_version == 4 {
410 "render.json schema 4: oude export met globaal dieptebereik 0,05–40 m; opnieuw renderen"
411 .into()
412 } else {
413 format!(
414 "render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})",
415 m.format_version
416 )
417 }));
418 }
419 if m.status != "incomplete" && m.status != "complete" {
420 // production leaves status=incomplete in render.json even after success
421 return Err(ExportError::Corrupt(format!(
422 "onbekende render.json status '{}'",
423 m.status
424 )));
425 }
426 if !["male", "female"].contains(&m.sex.as_str()) {
427 return Err(ExportError::Identity("ongeldig geslacht".into()));
428 }
429 if m.set != "body" && !crate::equipment::valid_name(&m.set) {
430 return Err(ExportError::Identity("ongeldige setnaam".into()));
431 }
432 if m.target[0] == 0 || m.target[1] == 0 || m.target[0] > MAX_TARGET || m.target[1] > MAX_TARGET
433 {
434 return Err(ExportError::Resource("target buiten 1–256".into()));
435 }
436 if m.fps == 0 || m.fps > 240 {
437 return Err(ExportError::Identity("ongeldige fps".into()));
438 }
439 if m.highres_scale != SCALE {
440 return Err(ExportError::Identity("highres_scale wijkt af van 8".into()));
441 }
442 m.framing.validate().map_err(ExportError::Identity)?;
443 let (near, far) = crate::camera::depth_range(m.framing);
444 if m.near != near || m.far != far || m.depth_near != near || m.depth_far != far {
445 return Err(ExportError::Identity(
446 "near/far wijkt af van het vaste dieptebereik van deze framing".into(),
447 ));
448 }
449 m.depth_format()?;
450 if m.camera_distance != crate::camera::CAMERA_DISTANCE {
451 return Err(ExportError::Identity(
452 "camera_distance wijkt af van contract".into(),
453 ));
454 }
455 if m.pitches != crate::preview::PRODUCTION_PITCHES {
456 return Err(ExportError::Identity(
457 "pitches wijken af van −60,−30,0,+30,+60".into(),
458 ));
459 }
460 if m.yaws != crate::production::production_yaws() {
461 return Err(ExportError::Identity(
462 "yaws wijken af van 0…330 stap 30".into(),
463 ));
464 }
465 if m.pass_order != PASSES {
466 return Err(ExportError::Identity(
467 "pass_order wijkt af van contract".into(),
468 ));
469 }
470 if m.saved_maps != ["diffuse", "depth", "normal", "specular"] {
471 return Err(ExportError::Identity(
472 "saved_maps wijkt af van diffuse/depth/normal/specular".into(),
473 ));
474 }
475 if m.clips.is_empty() || m.clips.len() > MAX_CLIPS {
476 return Err(ExportError::Resource("clip-inventaris ongeldig".into()));
477 }
478 for clip in &m.clips {
479 if clip.token.is_empty()
480 || clip.token.len() > 64
481 || !crate::equipment::valid_name(&clip.token)
482 {
483 return Err(ExportError::Identity(format!(
484 "ongeldige clip-token '{}'",
485 clip.token
486 )));
487 }
488 if clip.name.len() > MAX_CLIP_NAME {
489 return Err(ExportError::Resource("clipnaam te lang".into()));
490 }
491 if clip.frames == 0 {
492 return Err(ExportError::Identity("clip zonder frames".into()));
493 }
494 if !clip.duration.is_finite() || clip.duration < 0. {
495 return Err(ExportError::Identity("ongeldige clipduur".into()));
496 }
497 }
498 if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
499 return Err(ExportError::Resource("png_files buiten limiet".into()));
500 }
501 if !is_sha256_hex(&m.body_model.sha256) {
502 return Err(ExportError::Corrupt(
503 "body_model.sha256 is geen SHA-256 hex".into(),
504 ));
505 }
506 project::portable_relative(&m.body_model.path).map_err(ExportError::Traversal)?;
507 if m.project.id.is_empty() || m.project.id.len() > 128 {
508 return Err(ExportError::Identity("project.id ongeldig".into()));
509 }
510 if m.project.name.len() > 256 {
511 return Err(ExportError::Identity("project.name te lang".into()));
512 }
513 match &m.equipment_preset {
514 None if m.set == "body" => {}
515 Some(p)
516 if m.set == p.name
517 && is_sha256_hex(&p.sha256)
518 && crate::equipment::valid_name(&p.name) => {}
519 _ => {
520 return Err(ExportError::Identity(
521 "equipment_preset past niet bij set of mist geldige sha256".into(),
522 ))
523 }
524 }
525 match m.pivot_source.kind.as_str() {
526 "manual" | "neck" | "fallback" => {}
527 other => {
528 return Err(ExportError::Identity(format!(
529 "onbekende pivot_source.kind '{other}'"
530 )))
531 }
532 }
533 Ok(())
534}
535
536fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {
537 if !(MIN_OUTPUT_META_VERSION..=OUTPUT_META_VERSION).contains(&m.format_version) {
538 return Err(ExportError::Identity(format!(
539 "complete.json format_version {} wordt niet ondersteund",
540 m.format_version
541 )));
542 }
543 if m.status != "complete" {
544 return Err(ExportError::Incomplete(
545 "complete.json status is niet 'complete'".into(),
546 ));
547 }
548 if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
549 return Err(ExportError::Resource("png_files buiten limiet".into()));
550 }
551 if m.png_sha256.len() > MAX_PNG_FILES as usize {
552 return Err(ExportError::Resource("te veel png_sha256-items".into()));
553 }
554 if !is_sha256_hex(&m.identity.body_model_sha256) {
555 return Err(ExportError::Corrupt(
556 "identity.body_model_sha256 ongeldig".into(),
557 ));
558 }
559 if let Some(hash) = &m.identity.equipment_preset_sha256 {
560 if !is_sha256_hex(hash) {
561 return Err(ExportError::Corrupt(
562 "identity.equipment_preset_sha256 ongeldig".into(),
563 ));
564 }
565 }
566 for (path, hash) in &m.png_sha256 {
567 project::portable_relative(path).map_err(ExportError::Traversal)?;
568 if !path.ends_with(".png") {
569 return Err(ExportError::Corrupt(format!(
570 "png_sha256-sleutel '{path}' is geen .png"
571 )));
572 }
573 if !is_sha256_hex(hash) {
574 return Err(ExportError::Corrupt(format!(
575 "png_sha256 voor '{path}' is geen SHA-256 hex"
576 )));
577 }
578 }
579 Ok(())
580}
581
582fn identities_match(
583 render: &RenderManifest,
584 complete: &CompleteManifest,
585) -> Result<(), ExportError> {
586 let id = &complete.identity;
587 let check = |ok: bool, msg: &str| {
588 if ok {
589 Ok(())
590 } else {
591 Err(ExportError::Identity(msg.into()))
592 }
593 };
594 check(id.project_id == render.project.id, "project.id mismatch")?;
595 check(
596 id.project_name == render.project.name,
597 "project.name mismatch",
598 )?;
599 check(id.sex == render.sex, "sex mismatch")?;
600 check(id.set == render.set, "set mismatch")?;
601 check(
602 id.body_model_sha256 == render.body_model.sha256,
603 "body_model sha256 mismatch",
604 )?;
605 check(id.target == render.target, "target mismatch")?;
606 check(id.fps == render.fps, "fps mismatch")?;
607 check(id.framing == render.framing, "framing mismatch")?;
608 check(
609 id.near == render.near && id.far == render.far,
610 "near/far mismatch",
611 )?;
612 check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
613 let atlas = &complete.atlas;
614 check(
615 atlas.ppda == crate::atlas::ppda_name(&render.sex, &render.set)
616 && is_sha256_hex(&atlas.ppda_sha256),
617 "atlas.ppda naam/sha256 ongeldig",
618 )?;
619 check(atlas.maps.len() == 4, "atlas.maps moet vier maps bevatten")?;
620 for kind in crate::atlas::ATLAS_MAPS {
621 let entry = atlas
622 .maps
623 .get(kind.token())
624 .ok_or_else(|| ExportError::Identity(format!("atlas mist {}", kind.token())))?;
625 check(
626 entry.file == crate::atlas::atlas_png_name(&render.sex, &render.set, kind)
627 && is_sha256_hex(&entry.sha256),
628 "atlas-bestandsnaam/sha256 ongeldig",
629 )?;
630 }
631 check(
632 id.clips.len() == render.clips.len(),
633 "clips length mismatch",
634 )?;
635 for (a, b) in id.clips.iter().zip(render.clips.iter()) {
636 if a.token != b.token || a.frames != b.frames || a.name != b.name {
637 return Err(ExportError::Identity("clip inventory mismatch".into()));
638 }
639 }
640 check(
641 id.pivot_source.kind == render.pivot_source.kind,
642 "pivot_source mismatch",
643 )?;
644 let preset_hash = render.equipment_preset.as_ref().map(|p| p.sha256.clone());
645 check(
646 id.equipment_preset_sha256 == preset_hash,
647 "equipment preset sha256 mismatch",
648 )?;
649 Ok(())
650}
651
652pub(crate) fn decode_map(
653 bytes: &[u8],
654 kind: MapKind,
655 expect_w: u32,
656 expect_h: u32,
657 depth: crate::camera::DepthFormat,
658) -> Result<MapImage, ExportError> {
659 decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET, depth)
660}
661
662/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under
663/// the published pixel contract. Atlases use the same rules with a larger cap.
664pub(crate) fn decode_map_sized(
665 bytes: &[u8],
666 kind: MapKind,
667 expect_w: u32,
668 expect_h: u32,
669 max_dim: u32,
670 depth: crate::camera::DepthFormat,
671) -> Result<MapImage, ExportError> {
672 if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
673 return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
674 }
675 let decoded = expect_w as usize * expect_h as usize * 4;
676 let mut decoder = png::Decoder::new_with_limits(
677 std::io::Cursor::new(bytes),
678 png::Limits {
679 bytes: decoded.saturating_mul(2).max(64 * 1024 * 1024),
680 },
681 );
682 decoder.set_transformations(png::Transformations::IDENTITY);
683 let mut reader = decoder
684 .read_info()
685 .map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;
686 let info = reader.info();
687 if info.width != expect_w || info.height != expect_h {
688 return Err(ExportError::Corrupt(format!(
689 "PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",
690 info.width, info.height
691 )));
692 }
693 if info.width > max_dim || info.height > max_dim {
694 return Err(ExportError::Resource("PNG groter dan limiet".into()));
695 }
696 match kind {
697 MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
698 if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
699 return Err(ExportError::Corrupt(
700 "RGBA8-map heeft onverwacht kleurtype/bitdiepte".into(),
701 ));
702 }
703 let size = reader.output_buffer_size().ok_or_else(|| {
704 ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
705 })?;
706 let mut buf = vec![0u8; size];
707 let frame = reader
708 .next_frame(&mut buf)
709 .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
710 if frame.buffer_size() != expect_w as usize * expect_h as usize * 4 {
711 return Err(ExportError::Corrupt("PNG buffergrootte klopt niet".into()));
712 }
713 buf.truncate(frame.buffer_size());
714 if kind == MapKind::Normal {
715 validate_normals(&buf, expect_w, expect_h)?;
716 }
717 if kind == MapKind::Specular {
718 validate_specular_channels(&buf)?;
719 }
720 Ok(MapImage::Rgba8 {
721 width: expect_w,
722 height: expect_h,
723 rgba: buf,
724 })
725 }
726 MapKind::Depth => {
727 drop(reader);
728 let native = decode_depth_native(bytes, expect_w, expect_h, max_dim, depth)?;
729 let samples = canonical_depth(&native, depth)?;
730 Ok(MapImage::Depth16 {
731 width: expect_w,
732 height: expect_h,
733 samples,
734 })
735 }
736 }
737}
738
739/// Decode a depth PNG in `fmt` and return its raw big-endian samples
740/// (validated: format, size, alpha rule, band order).
741pub(crate) fn decode_depth_native(
742 bytes: &[u8],
743 expect_w: u32,
744 expect_h: u32,
745 max_dim: u32,
746 fmt: crate::camera::DepthFormat,
747) -> Result<Vec<u8>, ExportError> {
748 if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
749 return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
750 }
751 let expected = expect_w as usize * expect_h as usize * fmt.bytes_per_pixel();
752 let mut decoder = png::Decoder::new_with_limits(
753 std::io::Cursor::new(bytes),
754 png::Limits {
755 bytes: expected.saturating_mul(2).max(64 * 1024 * 1024),
756 },
757 );
758 decoder.set_transformations(png::Transformations::IDENTITY);
759 let mut reader = decoder
760 .read_info()
761 .map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;
762 let info = reader.info();
763 if info.width != expect_w || info.height != expect_h {
764 return Err(ExportError::Corrupt(format!(
765 "PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",
766 info.width, info.height
767 )));
768 }
769 if info.color_type != fmt.color_type() || info.bit_depth != fmt.bit_depth() {
770 return Err(ExportError::Corrupt(format!(
771 "depth-map moet {} {}-bit zijn",
772 fmt.encoding.token(),
773 fmt.bits
774 )));
775 }
776 let size = reader
777 .output_buffer_size()
778 .ok_or_else(|| ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into()))?;
779 let mut buf = vec![0u8; size];
780 let frame = reader
781 .next_frame(&mut buf)
782 .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
783 if frame.buffer_size() != expected {
784 return Err(ExportError::Corrupt(
785 "depth buffergrootte klopt niet".into(),
786 ));
787 }
788 buf.truncate(expected);
789 canonical_depth(&buf, fmt)?;
790 Ok(buf)
791}
792
793/// Raw depth samples in `fmt` → canonical interleaved 16-bit (depth, alpha).
794pub(crate) fn canonical_depth(
795 native: &[u8],
796 fmt: crate::camera::DepthFormat,
797) -> Result<Vec<u16>, ExportError> {
798 let bpp = fmt.bytes_per_pixel();
799 let mut samples = Vec::with_capacity(native.len() / bpp * 2);
800 for px in native.chunks_exact(bpp) {
801 let (d, a) = fmt.decode_pixel(px).map_err(ExportError::Corrupt)?;
802 if a == 0 && px[..bpp - bpp / fmt.channels()].iter().any(|b| *b != 0) {
803 return Err(ExportError::Corrupt(
804 "transparante depth heeft niet-nul gray".into(),
805 ));
806 }
807 samples.push(d);
808 samples.push(a);
809 }
810 validate_depth_alpha(&samples)?;
811 Ok(samples)
812}
813
814fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {
815 for px in rgba.chunks_exact(4) {
816 let a = px[3];
817 if a == 0 {
818 if px[0] != 0 || px[1] != 0 || px[2] != 0 {
819 return Err(ExportError::Corrupt(
820 "transparante normal heeft niet-nul RGB".into(),
821 ));
822 }
823 continue;
824 }
825 let nx = px[0] as f32 / 255. * 2. - 1.;
826 let ny = px[1] as f32 / 255. * 2. - 1.;
827 let nz = px[2] as f32 / 255. * 2. - 1.;
828 let len = (nx * nx + ny * ny + nz * nz).sqrt();
829 if !(0.85..=1.15).contains(&len) {
830 return Err(ExportError::Corrupt(format!(
831 "normal lengte {len} buiten tolerantie"
832 )));
833 }
834 }
835 Ok(())
836}
837
838fn validate_specular_channels(rgba: &[u8]) -> Result<(), ExportError> {
839 for px in rgba.chunks_exact(4) {
840 if px[0] != px[1] || px[1] != px[2] {
841 return Err(ExportError::Corrupt(
842 "specular RGB-kanalen zijn niet gelijk".into(),
843 ));
844 }
845 }
846 Ok(())
847}
848
849fn validate_depth_alpha(samples: &[u16]) -> Result<(), ExportError> {
850 for pair in samples.chunks_exact(2) {
851 let gray = pair[0];
852 let alpha = pair[1];
853 if alpha == 0 && gray != 0 {
854 return Err(ExportError::Corrupt(
855 "transparante depth heeft niet-nul gray".into(),
856 ));
857 }
858 }
859 Ok(())
860}
861
862fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {
863 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
864 if !meta.is_dir() {
865 return Err(ExportError::Io("runpad is geen map".into()));
866 }
867 path.canonicalize()
868 .map_err(|e| ExportError::Io(e.to_string()))
869}
870
871fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
872 let path = root.join(name);
873 if !path.exists() {
874 return Err(ExportError::Incomplete(format!("{name} ontbreekt")));
875 }
876 verify_regular_file(&path, root)?;
877 read_bounded_file(&path, MAX_MANIFEST_BYTES)
878}
879
880pub(crate) fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
881 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
882 if meta.len() > max {
883 return Err(ExportError::Resource(format!(
884 "{} groter dan {max} bytes",
885 path.display()
886 )));
887 }
888 let file = fs::File::open(path).map_err(|e| ExportError::Io(e.to_string()))?;
889 let mut bytes = Vec::new();
890 file.take(max + 1)
891 .read_to_end(&mut bytes)
892 .map_err(|e| ExportError::Io(e.to_string()))?;
893 if bytes.len() as u64 > max {
894 return Err(ExportError::Resource(format!(
895 "{} groter dan {max} bytes",
896 path.display()
897 )));
898 }
899 Ok(bytes)
900}
901
902fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
903 let clean = project::portable_relative(relative).map_err(ExportError::Traversal)?;
904 let path = root.join(&clean);
905 // Lexical rejection of oddities already handled; still verify canonical containment.
906 Ok(path)
907}
908
909pub(crate) fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
910 let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
911 if meta.file_type().is_symlink() {
912 return Err(ExportError::Traversal(format!(
913 "symlink geweigerd: {}",
914 path.display()
915 )));
916 }
917 if !meta.is_file() {
918 return Err(ExportError::Io(format!(
919 "geen regulier bestand: {}",
920 path.display()
921 )));
922 }
923 let root_real = root
924 .canonicalize()
925 .map_err(|e| ExportError::Io(e.to_string()))?;
926 let path_real = path
927 .canonicalize()
928 .map_err(|e| ExportError::Io(e.to_string()))?;
929 if !path_real.starts_with(&root_real) {
930 return Err(ExportError::Traversal(format!(
931 "pad verlaat runmap: {}",
932 path.display()
933 )));
934 }
935 Ok(())
936}
937
938/// Build the identity object embedded in complete.json (shared with production).
939pub fn complete_identity_json(render: &serde_json::Value) -> Result<serde_json::Value, String> {
940 let project = render.get("project").ok_or("render.json mist project")?;
941 Ok(serde_json::json!({
942 "project_id": project.get("id").ok_or("project.id")?,
943 "project_name": project.get("name").ok_or("project.name")?,
944 "sex": render.get("sex").ok_or("sex")?,
945 "set": render.get("set").ok_or("set")?,
946 "body_model_sha256": render.pointer("/body_model/sha256").ok_or("body sha")?,
947 "equipment_preset_sha256": render.pointer("/equipment_preset/sha256").cloned().unwrap_or(serde_json::Value::Null),
948 "target": render.get("target").ok_or("target")?,
949 "fps": render.get("fps").ok_or("fps")?,
950 "framing": render.get("framing").ok_or("framing")?,
951 "pivot_source": render.get("pivot_source").ok_or("pivot")?,
952 "near": render.get("near").ok_or("near")?,
953 "far": render.get("far").ok_or("far")?,
954 "clips": render.get("clips").ok_or("clips")?,
955 "saved_maps": render.get("saved_maps").ok_or("maps")?,
956 }))
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::production::{write_depth, write_rgba};
963 use crate::render_job::ImageBuf;
964 use std::sync::atomic::{AtomicU64, Ordering};
965
966 struct Temp(PathBuf);
967 impl Temp {
968 fn new() -> Self {
969 static N: AtomicU64 = AtomicU64::new(0);
970 let id = N.fetch_add(1, Ordering::Relaxed);
971 let path = std::env::temp_dir().join(format!(
972 "paperdoll-export-set-{}-{}",
973 std::process::id(),
974 id
975 ));
976 let _ = fs::remove_dir_all(&path);
977 fs::create_dir_all(&path).unwrap();
978 Self(path)
979 }
980 }
981 impl Drop for Temp {
982 fn drop(&mut self) {
983 let _ = fs::remove_dir_all(&self.0);
984 }
985 }
986
987 fn framing() -> CameraFraming {
988 CameraFraming {
989 height_milli: 2600,
990 center_y_milli: 1000,
991 pivot_y_milli: Some(1460),
992 depth_range_milli: None,
993 }
994 }
995
996 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
997 let data = serde_json::to_vec_pretty(value).unwrap();
998 let path = root.join(name);
999 let temp = path.with_extension(format!("part-{}", std::process::id()));
1000 fs::write(&temp, &data).unwrap();
1001 fs::hard_link(&temp, &path).unwrap();
1002 let _ = fs::remove_file(temp);
1003 }
1004
1005 fn atlas_ref() -> serde_json::Value {
1006 let sha = digest::sha256_hex(b"atlas");
1007 let map = |m: &str| serde_json::json!({"file": format!("male-body-{m}-atlas.png"), "sha256": sha});
1008 serde_json::json!({
1009 "ppda": "male-body-atlas.ppda",
1010 "ppda_sha256": sha,
1011 "maps": {"diffuse": map("diffuse"), "normal": map("normal"), "depth": map("depth"), "specular": map("specular")}
1012 })
1013 }
1014
1015 fn base_render(png_files: u32) -> serde_json::Value {
1016 serde_json::json!({
1017 "format_version": OUTPUT_META_VERSION,
1018 "status": "incomplete",
1019 "project": {"id": "demo", "name": "Demo"},
1020 "sex": "male",
1021 "set": "body",
1022 "body_model": {"path": "assets/body/x.glb", "sha256": digest::sha256_hex(b"body")},
1023 "equipment_preset": null,
1024 "target": [1, 1],
1025 "fps": 12,
1026 "highres_scale": 8,
1027 "framing": framing(),
1028 "pivot_source": {"kind": "manual"},
1029 "near": crate::camera::depth_range(framing()).0,
1030 "far": crate::camera::depth_range(framing()).1,
1031 "depth_near": crate::camera::depth_range(framing()).0,
1032 "depth_far": crate::camera::depth_range(framing()).1,
1033 "depth_encoding": "gray",
1034 "depth_bits": 16,
1035 "camera_distance": 10.0,
1036 "yaw_zero": "camera on -Z",
1037 "normal_space": "view (+Y up), PNG rows top-down",
1038 "pitches": crate::preview::PRODUCTION_PITCHES,
1039 "yaws": crate::production::production_yaws(),
1040 "yaw_step": 30,
1041 "sampling": "test",
1042 "alpha": "straight",
1043 "diffuse": "unlit",
1044 "depth": "16-bit",
1045 "specular": "scalar",
1046 "pass_order": PASSES,
1047 "saved_maps": ["diffuse", "depth", "normal", "specular"],
1048 "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
1049 "images": 1,
1050 "passes": 6,
1051 "png_files": png_files
1052 })
1053 }
1054
1055 fn publish_four(root: &Path) -> BTreeMap<String, String> {
1056 let mut map = BTreeMap::new();
1057 let rels = [
1058 (
1059 "male/body/clip000-bind/f000000/p-60_y000_diffuse.png",
1060 "diffuse",
1061 ),
1062 (
1063 "male/body/clip000-bind/f000000/p-60_y000_depth.png",
1064 "depth",
1065 ),
1066 (
1067 "male/body/clip000-bind/f000000/p-60_y000_normal.png",
1068 "normal",
1069 ),
1070 (
1071 "male/body/clip000-bind/f000000/p-60_y000_specular.png",
1072 "specular",
1073 ),
1074 ];
1075 for (rel, kind) in rels {
1076 let path = Path::new(rel);
1077 match kind {
1078 "depth" => write_depth(root, path, 1, 1, &[20000, 65535]).unwrap(),
1079 "normal" => write_rgba(
1080 root,
1081 path,
1082 &ImageBuf {
1083 width: 1,
1084 height: 1,
1085 rgba: vec![128, 128, 255, 255],
1086 },
1087 )
1088 .unwrap(),
1089 "specular" => write_rgba(
1090 root,
1091 path,
1092 &ImageBuf {
1093 width: 1,
1094 height: 1,
1095 rgba: vec![0, 0, 0, 255],
1096 },
1097 )
1098 .unwrap(),
1099 _ => write_rgba(
1100 root,
1101 path,
1102 &ImageBuf {
1103 width: 1,
1104 height: 1,
1105 rgba: vec![10, 20, 30, 255],
1106 },
1107 )
1108 .unwrap(),
1109 };
1110 let bytes = fs::read(root.join(rel)).unwrap();
1111 map.insert(rel.to_string(), digest::sha256_hex(&bytes));
1112 }
1113 map
1114 }
1115
1116 #[test]
1117 fn opens_completed_run_and_reads_maps() {
1118 let tmp = Temp::new();
1119 let checksums = publish_four(&tmp.0);
1120 let render = base_render(4);
1121 write_atomic_json(&tmp.0, "render.json", &render);
1122 let identity = complete_identity_json(&render).unwrap();
1123 write_atomic_json(
1124 &tmp.0,
1125 "complete.json",
1126 &serde_json::json!({
1127 "format_version": OUTPUT_META_VERSION,
1128 "status": "complete",
1129 "identity": identity,
1130 "passes": 6,
1131 "png_files": 4,
1132 "png_sha256": checksums,
1133 "atlas": atlas_ref()
1134 }),
1135 );
1136 let run = ExportRun::open(&tmp.0).unwrap();
1137 let diffuse = run
1138 .load_map("clip000-bind", 0, -60, 0, MapKind::Diffuse)
1139 .unwrap();
1140 match diffuse {
1141 MapImage::Rgba8 {
1142 width,
1143 height,
1144 rgba,
1145 } => {
1146 assert_eq!((width, height), (1, 1));
1147 assert_eq!(rgba, [10, 20, 30, 255]);
1148 }
1149 _ => panic!("expected rgba"),
1150 }
1151 let depth = run
1152 .load_map("clip000-bind", 0, -60, 0, MapKind::Depth)
1153 .unwrap();
1154 match depth {
1155 MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),
1156 _ => panic!("expected depth"),
1157 }
1158 }
1159
1160 #[test]
1161 fn rejects_missing_complete_and_checksum_mismatch() {
1162 let tmp = Temp::new();
1163 let render = base_render(4);
1164 write_atomic_json(&tmp.0, "render.json", &render);
1165 assert!(matches!(
1166 ExportRun::open(&tmp.0),
1167 Err(ExportError::Incomplete(_))
1168 ));
1169 let mut checksums = publish_four(&tmp.0);
1170 *checksums.values_mut().next().unwrap() = digest::sha256_hex(b"tampered");
1171 let identity = complete_identity_json(&render).unwrap();
1172 write_atomic_json(
1173 &tmp.0,
1174 "complete.json",
1175 &serde_json::json!({
1176 "format_version": OUTPUT_META_VERSION,
1177 "status": "complete",
1178 "identity": identity,
1179 "passes": 6,
1180 "png_files": 4,
1181 "png_sha256": checksums,
1182 "atlas": atlas_ref()
1183 }),
1184 );
1185 assert!(matches!(
1186 ExportRun::open(&tmp.0),
1187 Err(ExportError::Corrupt(_))
1188 ));
1189 }
1190
1191 #[test]
1192 fn rejects_path_traversal_and_symlink_escape() {
1193 let tmp = Temp::new();
1194 let mut checksums = publish_four(&tmp.0);
1195 checksums.insert("../escape.png".into(), digest::sha256_hex(b"x"));
1196 let render = base_render(5);
1197 write_atomic_json(&tmp.0, "render.json", &render);
1198 let identity = complete_identity_json(&render).unwrap();
1199 write_atomic_json(
1200 &tmp.0,
1201 "complete.json",
1202 &serde_json::json!({
1203 "format_version": OUTPUT_META_VERSION,
1204 "status": "complete",
1205 "identity": identity,
1206 "passes": 6,
1207 "png_files": 5,
1208 "png_sha256": checksums,
1209 "atlas": atlas_ref()
1210 }),
1211 );
1212 assert!(matches!(
1213 ExportRun::open(&tmp.0),
1214 Err(ExportError::Traversal(_))
1215 ));
1216
1217 #[cfg(unix)]
1218 {
1219 let tmp = Temp::new();
1220 let outside = Temp::new();
1221 let checksums = publish_four(&tmp.0);
1222 let evil = "male/body/clip000-bind/f000000/p-60_y000_diffuse.png";
1223 let _ = fs::remove_file(tmp.0.join(evil));
1224 std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();
1225 fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();
1226 let render = base_render(4);
1227 write_atomic_json(&tmp.0, "render.json", &render);
1228 let identity = complete_identity_json(&render).unwrap();
1229 let mut checksums = checksums;
1230 checksums.insert(evil.into(), digest::sha256_hex(b"not-a-png"));
1231 write_atomic_json(
1232 &tmp.0,
1233 "complete.json",
1234 &serde_json::json!({
1235 "format_version": OUTPUT_META_VERSION,
1236 "status": "complete",
1237 "identity": identity,
1238 "passes": 6,
1239 "png_files": 4,
1240 "png_sha256": checksums,
1241 "atlas": atlas_ref()
1242 }),
1243 );
1244 assert!(matches!(
1245 ExportRun::open(&tmp.0),
1246 Err(ExportError::Traversal(_))
1247 ));
1248 }
1249 }
1250
1251 #[test]
1252 fn depth_range_contract_and_old_global_range_schema() {
1253 let parse = |v: serde_json::Value| -> RenderManifest { serde_json::from_value(v).unwrap() };
1254 validate_render(&parse(base_render(4))).unwrap();
1255 let mut old = base_render(4);
1256 old["format_version"] = 4.into();
1257 let err = validate_render(&parse(old)).unwrap_err();
1258 assert!(matches!(&err, ExportError::Identity(m) if m.contains("globaal dieptebereik")));
1259 for (key, value) in [
1260 ("near", serde_json::json!(0.05)),
1261 ("far", serde_json::json!(40.0)),
1262 ("depth_far", serde_json::json!(12.0)),
1263 ("depth_encoding", serde_json::json!("other")),
1264 ("depth_bits", serde_json::json!(12)),
1265 ("depth_bits", serde_json::Value::Null),
1266 ] {
1267 let mut doc = base_render(4);
1268 doc[key] = value;
1269 assert!(matches!(
1270 validate_render(&parse(doc)),
1271 Err(ExportError::Identity(_))
1272 ));
1273 }
1274 // Schema 5 (before depth_bits) is still read as 16-bit gray.
1275 let mut legacy = base_render(4);
1276 legacy["format_version"] = 5.into();
1277 legacy.as_object_mut().unwrap().remove("depth_bits");
1278 legacy["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1279 let m = parse(legacy.clone());
1280 validate_render(&m).unwrap();
1281 assert_eq!(
1282 m.depth_format().unwrap(),
1283 crate::camera::DepthFormat::GRAY16
1284 );
1285 // ...but schema 5 cannot claim a new encoding.
1286 legacy["depth_bits"] = 8.into();
1287 legacy["depth_encoding"] = "rgb_packed".into();
1288 assert!(validate_render(&parse(legacy)).is_err());
1289 // Every new encoding/bit combination is accepted in schema 6.
1290 for bits in [8, 16] {
1291 for e in ["gray", "rgb_bands", "rgb_packed"] {
1292 let mut doc = base_render(4);
1293 doc["depth_bits"] = bits.into();
1294 doc["depth_encoding"] = e.into();
1295 validate_render(&parse(doc)).unwrap();
1296 }
1297 }
1298 }
1299
1300 #[test]
1301 fn rejects_stale_schema_and_identity_drift() {
1302 let tmp = Temp::new();
1303 let checksums = publish_four(&tmp.0);
1304 let mut render = base_render(4);
1305 render["format_version"] = serde_json::json!(2);
1306 write_atomic_json(&tmp.0, "render.json", &render);
1307 write_atomic_json(
1308 &tmp.0,
1309 "complete.json",
1310 &serde_json::json!({
1311 "format_version": OUTPUT_META_VERSION,
1312 "status": "complete",
1313 "identity": {
1314 "project_id": "demo",
1315 "project_name": "Demo",
1316 "sex": "male",
1317 "set": "body",
1318 "body_model_sha256": digest::sha256_hex(b"body"),
1319 "equipment_preset_sha256": null,
1320 "target": [1,1],
1321 "fps": 12,
1322 "framing": framing(),
1323 "pivot_source": {"kind": "manual"},
1324 "near": crate::camera::depth_range(framing()).0,
1325 "far": crate::camera::depth_range(framing()).1,
1326 "clips": [{"name":"","token":"clip000-bind","frames":1,"duration":0.0}],
1327 "saved_maps": ["diffuse","depth","normal","specular"]
1328 },
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::Identity(_))
1338 ));
1339 }
1340
1341 #[test]
1342 fn rejects_bad_normal_and_wrong_depth_type() {
1343 let tmp = Temp::new();
1344 // Broken normal: transparent with non-zero RGB
1345 write_rgba(
1346 &tmp.0,
1347 Path::new("bad_normal.png"),
1348 &ImageBuf {
1349 width: 1,
1350 height: 1,
1351 rgba: vec![10, 0, 0, 0],
1352 },
1353 )
1354 .unwrap();
1355 let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();
1356 assert!(matches!(
1357 decode_map(
1358 &bytes,
1359 MapKind::Normal,
1360 1,
1361 1,
1362 crate::camera::DepthFormat::GRAY16
1363 ),
1364 Err(ExportError::Corrupt(_))
1365 ));
1366 // RGBA written as depth should fail color type check
1367 write_rgba(
1368 &tmp.0,
1369 Path::new("fake_depth.png"),
1370 &ImageBuf {
1371 width: 1,
1372 height: 1,
1373 rgba: vec![0, 0, 0, 255],
1374 },
1375 )
1376 .unwrap();
1377 let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();
1378 assert!(matches!(
1379 decode_map(
1380 &bytes,
1381 MapKind::Depth,
1382 1,
1383 1,
1384 crate::camera::DepthFormat::GRAY16
1385 ),
1386 Err(ExportError::Corrupt(_))
1387 ));
1388 }
1389}