BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

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