BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

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