BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

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