BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

export_set.rs 47.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.
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/// Stored depth pixels in `fmt` → RGBA8 for display, as stored (Tim t992u): no
794/// decoding of rgb_bands / rgb_packed. Gray shows as gray, RGB channels show as
795/// their own RGB, alpha stays alpha; 16-bit samples are reduced to 8-bit display
796/// precision by rounding (`v·255/65535`).
797pub(crate) fn depth_display_rgba(native: &[u8], fmt: crate::camera::DepthFormat) -> Vec<u8> {
798 let wide = fmt.bits == 16;
799 let sample = |px: &[u8], c: usize| -> u8 {
800 if wide {
801 let v = u32::from(u16::from_be_bytes([px[c * 2], px[c * 2 + 1]]));
802 ((v * 255 + 32767) / 65535) as u8
803 } else {
804 px[c]
805 }
806 };
807 let bpp = fmt.bytes_per_pixel();
808 let mut out = Vec::with_capacity(native.len() / bpp * 4);
809 for px in native.chunks_exact(bpp) {
810 if fmt.channels() == 2 {
811 let g = sample(px, 0);
812 out.extend_from_slice(&[g, g, g, sample(px, 1)]);
813 } else {
814 out.extend_from_slice(&[sample(px, 0), sample(px, 1), sample(px, 2), sample(px, 3)]);
815 }
816 }
817 out
818}
819
820/// Raw depth samples in `fmt` → canonical interleaved 16-bit (depth, alpha).
821pub(crate) fn canonical_depth(
822 native: &[u8],
823 fmt: crate::camera::DepthFormat,
824) -> Result<Vec<u16>, ExportError> {
825 let bpp = fmt.bytes_per_pixel();
826 let mut samples = Vec::with_capacity(native.len() / bpp * 2);
827 for px in native.chunks_exact(bpp) {
828 let (d, a) = fmt.decode_pixel(px).map_err(ExportError::Corrupt)?;
829 if a == 0 && px[..bpp - bpp / fmt.channels()].iter().any(|b| *b != 0) {
830 return Err(ExportError::Corrupt(
831 "transparante depth heeft niet-nul gray".into(),
832 ));
833 }
834 samples.push(d);
835 samples.push(a);
836 }
837 validate_depth_alpha(&samples)?;
838 Ok(samples)
839}
840
841fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {
842 for px in rgba.chunks_exact(4) {
843 let a = px[3];
844 if a == 0 {
845 if px[0] != 0 || px[1] != 0 || px[2] != 0 {
846 return Err(ExportError::Corrupt(
847 "transparante normal heeft niet-nul RGB".into(),
848 ));
849 }
850 continue;
851 }
852 let nx = px[0] as f32 / 255. * 2. - 1.;
853 let ny = px[1] as f32 / 255. * 2. - 1.;
854 let nz = px[2] as f32 / 255. * 2. - 1.;
855 let len = (nx * nx + ny * ny + nz * nz).sqrt();
856 if !(0.85..=1.15).contains(&len) {
857 return Err(ExportError::Corrupt(format!(
858 "normal lengte {len} buiten tolerantie"
859 )));
860 }
861 }
862 Ok(())
863}
864
865fn validate_specular_channels(rgba: &[u8]) -> Result<(), ExportError> {
866 for px in rgba.chunks_exact(4) {
867 if px[0] != px[1] || px[1] != px[2] {
868 return Err(ExportError::Corrupt(
869 "specular RGB-kanalen zijn niet gelijk".into(),
870 ));
871 }
872 }
873 Ok(())
874}
875
876fn validate_depth_alpha(samples: &[u16]) -> Result<(), ExportError> {
877 for pair in samples.chunks_exact(2) {
878 let gray = pair[0];
879 let alpha = pair[1];
880 if alpha == 0 && gray != 0 {
881 return Err(ExportError::Corrupt(
882 "transparante depth heeft niet-nul gray".into(),
883 ));
884 }
885 }
886 Ok(())
887}
888
889fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {
890 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
891 if !meta.is_dir() {
892 return Err(ExportError::Io("runpad is geen map".into()));
893 }
894 path.canonicalize()
895 .map_err(|e| ExportError::Io(e.to_string()))
896}
897
898fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
899 let path = root.join(name);
900 if !path.exists() {
901 return Err(ExportError::Incomplete(format!("{name} ontbreekt")));
902 }
903 verify_regular_file(&path, root)?;
904 read_bounded_file(&path, MAX_MANIFEST_BYTES)
905}
906
907pub(crate) fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
908 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
909 if meta.len() > max {
910 return Err(ExportError::Resource(format!(
911 "{} groter dan {max} bytes",
912 path.display()
913 )));
914 }
915 let file = fs::File::open(path).map_err(|e| ExportError::Io(e.to_string()))?;
916 let mut bytes = Vec::new();
917 file.take(max + 1)
918 .read_to_end(&mut bytes)
919 .map_err(|e| ExportError::Io(e.to_string()))?;
920 if bytes.len() as u64 > max {
921 return Err(ExportError::Resource(format!(
922 "{} groter dan {max} bytes",
923 path.display()
924 )));
925 }
926 Ok(bytes)
927}
928
929fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
930 let clean = project::portable_relative(relative).map_err(ExportError::Traversal)?;
931 let path = root.join(&clean);
932 // Lexical rejection of oddities already handled; still verify canonical containment.
933 Ok(path)
934}
935
936pub(crate) fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
937 let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
938 if meta.file_type().is_symlink() {
939 return Err(ExportError::Traversal(format!(
940 "symlink geweigerd: {}",
941 path.display()
942 )));
943 }
944 if !meta.is_file() {
945 return Err(ExportError::Io(format!(
946 "geen regulier bestand: {}",
947 path.display()
948 )));
949 }
950 let root_real = root
951 .canonicalize()
952 .map_err(|e| ExportError::Io(e.to_string()))?;
953 let path_real = path
954 .canonicalize()
955 .map_err(|e| ExportError::Io(e.to_string()))?;
956 if !path_real.starts_with(&root_real) {
957 return Err(ExportError::Traversal(format!(
958 "pad verlaat runmap: {}",
959 path.display()
960 )));
961 }
962 Ok(())
963}
964
965/// Build the identity object embedded in complete.json (shared with production).
966pub fn complete_identity_json(render: &serde_json::Value) -> Result<serde_json::Value, String> {
967 let project = render.get("project").ok_or("render.json mist project")?;
968 Ok(serde_json::json!({
969 "project_id": project.get("id").ok_or("project.id")?,
970 "project_name": project.get("name").ok_or("project.name")?,
971 "sex": render.get("sex").ok_or("sex")?,
972 "set": render.get("set").ok_or("set")?,
973 "body_model_sha256": render.pointer("/body_model/sha256").ok_or("body sha")?,
974 "equipment_preset_sha256": render.pointer("/equipment_preset/sha256").cloned().unwrap_or(serde_json::Value::Null),
975 "target": render.get("target").ok_or("target")?,
976 "fps": render.get("fps").ok_or("fps")?,
977 "framing": render.get("framing").ok_or("framing")?,
978 "pivot_source": render.get("pivot_source").ok_or("pivot")?,
979 "near": render.get("near").ok_or("near")?,
980 "far": render.get("far").ok_or("far")?,
981 "clips": render.get("clips").ok_or("clips")?,
982 "saved_maps": render.get("saved_maps").ok_or("maps")?,
983 }))
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989 use crate::production::{write_depth, write_rgba};
990 use crate::render_job::ImageBuf;
991 use std::sync::atomic::{AtomicU64, Ordering};
992
993 struct Temp(PathBuf);
994 impl Temp {
995 fn new() -> Self {
996 static N: AtomicU64 = AtomicU64::new(0);
997 let id = N.fetch_add(1, Ordering::Relaxed);
998 let path = std::env::temp_dir().join(format!(
999 "paperdoll-export-set-{}-{}",
1000 std::process::id(),
1001 id
1002 ));
1003 let _ = fs::remove_dir_all(&path);
1004 fs::create_dir_all(&path).unwrap();
1005 Self(path)
1006 }
1007 }
1008 impl Drop for Temp {
1009 fn drop(&mut self) {
1010 let _ = fs::remove_dir_all(&self.0);
1011 }
1012 }
1013
1014 fn framing() -> CameraFraming {
1015 CameraFraming {
1016 height_milli: 2600,
1017 center_y_milli: 1000,
1018 pivot_y_milli: Some(1460),
1019 depth_range_milli: None,
1020 }
1021 }
1022
1023 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
1024 let data = serde_json::to_vec_pretty(value).unwrap();
1025 let path = root.join(name);
1026 let temp = path.with_extension(format!("part-{}", std::process::id()));
1027 fs::write(&temp, &data).unwrap();
1028 fs::hard_link(&temp, &path).unwrap();
1029 let _ = fs::remove_file(temp);
1030 }
1031
1032 fn atlas_ref() -> serde_json::Value {
1033 let sha = digest::sha256_hex(b"atlas");
1034 let map = |m: &str| serde_json::json!({"file": format!("male-body-{m}-atlas.png"), "sha256": sha});
1035 serde_json::json!({
1036 "ppda": "male-body-atlas.ppda",
1037 "ppda_sha256": sha,
1038 "maps": {"diffuse": map("diffuse"), "normal": map("normal"), "depth": map("depth"), "specular": map("specular")}
1039 })
1040 }
1041
1042 fn base_render(png_files: u32) -> serde_json::Value {
1043 serde_json::json!({
1044 "format_version": OUTPUT_META_VERSION,
1045 "status": "incomplete",
1046 "project": {"id": "demo", "name": "Demo"},
1047 "sex": "male",
1048 "set": "body",
1049 "body_model": {"path": "assets/body/x.glb", "sha256": digest::sha256_hex(b"body")},
1050 "equipment_preset": null,
1051 "target": [1, 1],
1052 "fps": 12,
1053 "highres_scale": 8,
1054 "framing": framing(),
1055 "pivot_source": {"kind": "manual"},
1056 "near": crate::camera::depth_range(framing()).0,
1057 "far": crate::camera::depth_range(framing()).1,
1058 "depth_near": crate::camera::depth_range(framing()).0,
1059 "depth_far": crate::camera::depth_range(framing()).1,
1060 "depth_encoding": "gray",
1061 "depth_bits": 16,
1062 "camera_distance": 10.0,
1063 "yaw_zero": "camera on +Z",
1064 "normal_space": "view (+Y up), PNG rows top-down",
1065 "pitches": crate::preview::PRODUCTION_PITCHES,
1066 "yaws": crate::production::production_yaws(),
1067 "yaw_step": 30,
1068 "sampling": "test",
1069 "alpha": "straight",
1070 "diffuse": "unlit",
1071 "depth": "16-bit",
1072 "specular": "scalar",
1073 "pass_order": PASSES,
1074 "saved_maps": ["diffuse", "depth", "normal", "specular"],
1075 "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
1076 "images": 1,
1077 "passes": 6,
1078 "png_files": png_files
1079 })
1080 }
1081
1082 fn publish_four(root: &Path) -> BTreeMap<String, String> {
1083 let mut map = BTreeMap::new();
1084 let rels = [
1085 (
1086 "male/body/clip000-bind/f000000/p-60_y000_diffuse.png",
1087 "diffuse",
1088 ),
1089 (
1090 "male/body/clip000-bind/f000000/p-60_y000_depth.png",
1091 "depth",
1092 ),
1093 (
1094 "male/body/clip000-bind/f000000/p-60_y000_normal.png",
1095 "normal",
1096 ),
1097 (
1098 "male/body/clip000-bind/f000000/p-60_y000_specular.png",
1099 "specular",
1100 ),
1101 ];
1102 for (rel, kind) in rels {
1103 let path = Path::new(rel);
1104 match kind {
1105 "depth" => write_depth(root, path, 1, 1, &[20000, 65535]).unwrap(),
1106 "normal" => write_rgba(
1107 root,
1108 path,
1109 &ImageBuf {
1110 width: 1,
1111 height: 1,
1112 rgba: vec![128, 128, 255, 255],
1113 },
1114 )
1115 .unwrap(),
1116 "specular" => write_rgba(
1117 root,
1118 path,
1119 &ImageBuf {
1120 width: 1,
1121 height: 1,
1122 rgba: vec![0, 0, 0, 255],
1123 },
1124 )
1125 .unwrap(),
1126 _ => write_rgba(
1127 root,
1128 path,
1129 &ImageBuf {
1130 width: 1,
1131 height: 1,
1132 rgba: vec![10, 20, 30, 255],
1133 },
1134 )
1135 .unwrap(),
1136 };
1137 let bytes = fs::read(root.join(rel)).unwrap();
1138 map.insert(rel.to_string(), digest::sha256_hex(&bytes));
1139 }
1140 map
1141 }
1142
1143 #[test]
1144 fn opens_completed_run_and_reads_maps() {
1145 let tmp = Temp::new();
1146 let checksums = publish_four(&tmp.0);
1147 let render = base_render(4);
1148 write_atomic_json(&tmp.0, "render.json", &render);
1149 let identity = complete_identity_json(&render).unwrap();
1150 write_atomic_json(
1151 &tmp.0,
1152 "complete.json",
1153 &serde_json::json!({
1154 "format_version": OUTPUT_META_VERSION,
1155 "status": "complete",
1156 "identity": identity,
1157 "passes": 6,
1158 "png_files": 4,
1159 "png_sha256": checksums,
1160 "atlas": atlas_ref()
1161 }),
1162 );
1163 let run = ExportRun::open(&tmp.0).unwrap();
1164 let diffuse = run
1165 .load_map("clip000-bind", 0, -60, 0, MapKind::Diffuse)
1166 .unwrap();
1167 match diffuse {
1168 MapImage::Rgba8 {
1169 width,
1170 height,
1171 rgba,
1172 } => {
1173 assert_eq!((width, height), (1, 1));
1174 assert_eq!(rgba, [10, 20, 30, 255]);
1175 }
1176 _ => panic!("expected rgba"),
1177 }
1178 let depth = run
1179 .load_map("clip000-bind", 0, -60, 0, MapKind::Depth)
1180 .unwrap();
1181 match depth {
1182 MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),
1183 _ => panic!("expected depth"),
1184 }
1185 }
1186
1187 #[test]
1188 fn rejects_missing_complete_and_checksum_mismatch() {
1189 let tmp = Temp::new();
1190 let render = base_render(4);
1191 write_atomic_json(&tmp.0, "render.json", &render);
1192 assert!(matches!(
1193 ExportRun::open(&tmp.0),
1194 Err(ExportError::Incomplete(_))
1195 ));
1196 let mut checksums = publish_four(&tmp.0);
1197 *checksums.values_mut().next().unwrap() = digest::sha256_hex(b"tampered");
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": 4,
1208 "png_sha256": checksums,
1209 "atlas": atlas_ref()
1210 }),
1211 );
1212 assert!(matches!(
1213 ExportRun::open(&tmp.0),
1214 Err(ExportError::Corrupt(_))
1215 ));
1216 }
1217
1218 #[test]
1219 fn rejects_path_traversal_and_symlink_escape() {
1220 let tmp = Temp::new();
1221 let mut checksums = publish_four(&tmp.0);
1222 checksums.insert("../escape.png".into(), digest::sha256_hex(b"x"));
1223 let render = base_render(5);
1224 write_atomic_json(&tmp.0, "render.json", &render);
1225 let identity = complete_identity_json(&render).unwrap();
1226 write_atomic_json(
1227 &tmp.0,
1228 "complete.json",
1229 &serde_json::json!({
1230 "format_version": OUTPUT_META_VERSION,
1231 "status": "complete",
1232 "identity": identity,
1233 "passes": 6,
1234 "png_files": 5,
1235 "png_sha256": checksums,
1236 "atlas": atlas_ref()
1237 }),
1238 );
1239 assert!(matches!(
1240 ExportRun::open(&tmp.0),
1241 Err(ExportError::Traversal(_))
1242 ));
1243
1244 #[cfg(unix)]
1245 {
1246 let tmp = Temp::new();
1247 let outside = Temp::new();
1248 let checksums = publish_four(&tmp.0);
1249 let evil = "male/body/clip000-bind/f000000/p-60_y000_diffuse.png";
1250 let _ = fs::remove_file(tmp.0.join(evil));
1251 std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();
1252 fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();
1253 let render = base_render(4);
1254 write_atomic_json(&tmp.0, "render.json", &render);
1255 let identity = complete_identity_json(&render).unwrap();
1256 let mut checksums = checksums;
1257 checksums.insert(evil.into(), digest::sha256_hex(b"not-a-png"));
1258 write_atomic_json(
1259 &tmp.0,
1260 "complete.json",
1261 &serde_json::json!({
1262 "format_version": OUTPUT_META_VERSION,
1263 "status": "complete",
1264 "identity": identity,
1265 "passes": 6,
1266 "png_files": 4,
1267 "png_sha256": checksums,
1268 "atlas": atlas_ref()
1269 }),
1270 );
1271 assert!(matches!(
1272 ExportRun::open(&tmp.0),
1273 Err(ExportError::Traversal(_))
1274 ));
1275 }
1276 }
1277
1278 #[test]
1279 fn depth_range_contract_and_old_global_range_schema() {
1280 let parse = |v: serde_json::Value| -> RenderManifest { serde_json::from_value(v).unwrap() };
1281 validate_render(&parse(base_render(4))).unwrap();
1282 let mut old = base_render(4);
1283 old["format_version"] = 4.into();
1284 let err = validate_render(&parse(old)).unwrap_err();
1285 assert!(matches!(&err, ExportError::Identity(m) if m.contains("globaal dieptebereik")));
1286 for (key, value) in [
1287 ("near", serde_json::json!(0.05)),
1288 ("far", serde_json::json!(40.0)),
1289 ("depth_far", serde_json::json!(12.0)),
1290 ("depth_encoding", serde_json::json!("other")),
1291 ("depth_bits", serde_json::json!(12)),
1292 ("depth_bits", serde_json::Value::Null),
1293 ] {
1294 let mut doc = base_render(4);
1295 doc[key] = value;
1296 assert!(matches!(
1297 validate_render(&parse(doc)),
1298 Err(ExportError::Identity(_))
1299 ));
1300 }
1301 // Schema 5 (before depth_bits) is still read as 16-bit gray.
1302 let mut legacy = base_render(4);
1303 legacy["format_version"] = 5.into();
1304 legacy.as_object_mut().unwrap().remove("depth_bits");
1305 legacy["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1306 let m = parse(legacy.clone());
1307 validate_render(&m).unwrap();
1308 assert_eq!(
1309 m.depth_format().unwrap(),
1310 crate::camera::DepthFormat::GRAY16
1311 );
1312 // ...but schema 5 cannot claim a new encoding.
1313 legacy["depth_bits"] = 8.into();
1314 legacy["depth_encoding"] = "rgb_packed".into();
1315 assert!(validate_render(&parse(legacy)).is_err());
1316 // Every new encoding/bit combination is accepted in schema 6.
1317 for bits in [8, 16] {
1318 for e in ["gray", "rgb_bands", "rgb_packed"] {
1319 let mut doc = base_render(4);
1320 doc["depth_bits"] = bits.into();
1321 doc["depth_encoding"] = e.into();
1322 validate_render(&parse(doc)).unwrap();
1323 }
1324 }
1325 }
1326
1327 #[test]
1328 fn rejects_stale_schema_and_identity_drift() {
1329 let tmp = Temp::new();
1330 let checksums = publish_four(&tmp.0);
1331 let mut render = base_render(4);
1332 render["format_version"] = serde_json::json!(2);
1333 write_atomic_json(&tmp.0, "render.json", &render);
1334 write_atomic_json(
1335 &tmp.0,
1336 "complete.json",
1337 &serde_json::json!({
1338 "format_version": OUTPUT_META_VERSION,
1339 "status": "complete",
1340 "identity": {
1341 "project_id": "demo",
1342 "project_name": "Demo",
1343 "sex": "male",
1344 "set": "body",
1345 "body_model_sha256": digest::sha256_hex(b"body"),
1346 "equipment_preset_sha256": null,
1347 "target": [1,1],
1348 "fps": 12,
1349 "framing": framing(),
1350 "pivot_source": {"kind": "manual"},
1351 "near": crate::camera::depth_range(framing()).0,
1352 "far": crate::camera::depth_range(framing()).1,
1353 "clips": [{"name":"","token":"clip000-bind","frames":1,"duration":0.0}],
1354 "saved_maps": ["diffuse","depth","normal","specular"]
1355 },
1356 "passes": 6,
1357 "png_files": 4,
1358 "png_sha256": checksums,
1359 "atlas": atlas_ref()
1360 }),
1361 );
1362 assert!(matches!(
1363 ExportRun::open(&tmp.0),
1364 Err(ExportError::Identity(_))
1365 ));
1366 }
1367
1368 #[test]
1369 fn rejects_bad_normal_and_wrong_depth_type() {
1370 let tmp = Temp::new();
1371 // Broken normal: transparent with non-zero RGB
1372 write_rgba(
1373 &tmp.0,
1374 Path::new("bad_normal.png"),
1375 &ImageBuf {
1376 width: 1,
1377 height: 1,
1378 rgba: vec![10, 0, 0, 0],
1379 },
1380 )
1381 .unwrap();
1382 let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();
1383 assert!(matches!(
1384 decode_map(
1385 &bytes,
1386 MapKind::Normal,
1387 1,
1388 1,
1389 crate::camera::DepthFormat::GRAY16
1390 ),
1391 Err(ExportError::Corrupt(_))
1392 ));
1393 // RGBA written as depth should fail color type check
1394 write_rgba(
1395 &tmp.0,
1396 Path::new("fake_depth.png"),
1397 &ImageBuf {
1398 width: 1,
1399 height: 1,
1400 rgba: vec![0, 0, 0, 255],
1401 },
1402 )
1403 .unwrap();
1404 let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();
1405 assert!(matches!(
1406 decode_map(
1407 &bytes,
1408 MapKind::Depth,
1409 1,
1410 1,
1411 crate::camera::DepthFormat::GRAY16
1412 ),
1413 Err(ExportError::Corrupt(_))
1414 ));
1415 }
1416}