BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / export_set.rs

export_set.rs

export_set.rs 37.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 = 3;
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 pass_order: Vec<String>,
163 pub saved_maps: Vec<String>,
164 pub clips: Vec<ClipRecord>,
165 pub images: u32,
166 pub passes: u32,
167 pub png_files: u32,
168}
169
170#[derive(Debug, Clone, Deserialize)]
171pub struct ProjectRef {
172 pub id: String,
173 pub name: String,
174}
175
176#[derive(Debug, Clone, Deserialize)]
177pub struct CompleteIdentity {
178 pub project_id: String,
179 pub project_name: String,
180 pub sex: String,
181 pub set: String,
182 pub body_model_sha256: String,
183 #[serde(default)]
184 pub equipment_preset_sha256: Option<String>,
185 pub target: [u32; 2],
186 pub fps: u32,
187 pub framing: CameraFraming,
188 pub pivot_source: PivotSourceJson,
189 pub near: f32,
190 pub far: f32,
191 pub clips: Vec<ClipRecord>,
192 pub saved_maps: Vec<String>,
193}
194
195#[derive(Debug, Clone, Deserialize)]
196pub struct CompleteManifest {
197 pub format_version: u32,
198 pub status: String,
199 pub identity: CompleteIdentity,
200 pub passes: u32,
201 pub png_files: u32,
202 pub png_sha256: BTreeMap<String, String>,
203}
204
205#[derive(Debug, Clone)]
206pub struct ExportRun {
207 root: PathBuf,
208 pub render: RenderManifest,
209 pub complete: CompleteManifest,
210}
211
212impl ExportRun {
213 pub fn root(&self) -> &Path {
214 &self.root
215 }
216
217 pub fn open(run_dir: &Path) -> Result<Self, ExportError> {
218 let root = canonicalize_dir(run_dir)?;
219 if root.join("cancelled.json").exists() || root.join("failed.json").exists() {
220 return Err(ExportError::Incomplete(
221 "run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(),
222 ));
223 }
224 let render_bytes = read_manifest_file(&root, "render.json")?;
225 let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
226 if matches!(err, ExportError::Incomplete(_)) {
227 ExportError::Incomplete(
228 "complete.json ontbreekt; de exportset is niet volledig".into(),
229 )
230 } else {
231 err
232 }
233 })?;
234 let render: RenderManifest = serde_json::from_slice(&render_bytes)
235 .map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;
236 let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)
237 .map_err(|e| ExportError::Corrupt(format!("complete.json: {e}")))?;
238 validate_render(&render)?;
239 validate_complete(&complete)?;
240 identities_match(&render, &complete)?;
241 if complete.png_sha256.len() as u32 != complete.png_files {
242 return Err(ExportError::Corrupt(
243 "complete.json png_sha256 telt niet gelijk aan png_files".into(),
244 ));
245 }
246 if complete.png_files != render.png_files {
247 return Err(ExportError::Identity(
248 "png_files in complete.json wijkt af van render.json".into(),
249 ));
250 }
251 // Verify every checksum exists as a bounded regular file under the run.
252 for (relative, digest) in &complete.png_sha256 {
253 let path = resolve_inside(&root, relative)?;
254 verify_regular_file(&path, &root)?;
255 let meta = fs::metadata(&path).map_err(|e| ExportError::Io(e.to_string()))?;
256 if meta.len() > MAX_PNG_BYTES {
257 return Err(ExportError::Resource(format!(
258 "PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes"
259 )));
260 }
261 let bytes = fs::read(&path).map_err(|e| ExportError::Io(e.to_string()))?;
262 let actual = digest::sha256_hex(&bytes);
263 if actual != *digest {
264 return Err(ExportError::Corrupt(format!(
265 "checksum mismatch voor '{relative}'"
266 )));
267 }
268 }
269 Ok(Self {
270 root,
271 render,
272 complete,
273 })
274 }
275
276 pub fn map_path(
277 &self,
278 clip_token: &str,
279 frame: u32,
280 pitch: i32,
281 yaw: u32,
282 kind: MapKind,
283 ) -> Result<String, ExportError> {
284 let relative = crate::production::output_path(
285 &self.render.sex,
286 &self.render.set,
287 clip_token,
288 frame,
289 pitch,
290 yaw,
291 kind.token(),
292 )
293 .map_err(ExportError::Identity)?;
294 let key = relative
295 .to_str()
296 .ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?
297 .replace('\\', "/");
298 if !self.complete.png_sha256.contains_key(&key) {
299 return Err(ExportError::Incomplete(format!(
300 "map '{key}' staat niet in complete.json"
301 )));
302 }
303 Ok(key)
304 }
305
306 pub fn load_map(
307 &self,
308 clip_token: &str,
309 frame: u32,
310 pitch: i32,
311 yaw: u32,
312 kind: MapKind,
313 ) -> Result<MapImage, ExportError> {
314 let key = self.map_path(clip_token, frame, pitch, yaw, kind)?;
315 self.load_relative(&key, kind)
316 }
317
318 pub fn load_relative(
319 &self,
320 relative: &str,
321 expected: MapKind,
322 ) -> Result<MapImage, ExportError> {
323 let expected_digest = self
324 .complete
325 .png_sha256
326 .get(relative)
327 .ok_or_else(|| ExportError::Incomplete(format!("onbekende map '{relative}'")))?;
328 let path = resolve_inside(&self.root, relative)?;
329 verify_regular_file(&path, &self.root)?;
330 let bytes = read_bounded_file(&path, MAX_PNG_BYTES)?;
331 let actual = digest::sha256_hex(&bytes);
332 if actual != *expected_digest {
333 return Err(ExportError::Corrupt(format!(
334 "checksum mismatch voor '{relative}'"
335 )));
336 }
337 let token = relative
338 .rsplit('_')
339 .next()
340 .and_then(|s| s.strip_suffix(".png"))
341 .ok_or_else(|| ExportError::Corrupt(format!("geen map-token in '{relative}'")))?;
342 let kind = MapKind::parse(token).ok_or_else(|| {
343 ExportError::Corrupt(format!("onbekend map-token '{token}' in '{relative}'"))
344 })?;
345 if kind != expected {
346 return Err(ExportError::Identity(format!(
347 "verwachtte {} maar pad is {}",
348 expected.token(),
349 kind.token()
350 )));
351 }
352 let [width, height] = self.render.target;
353 decode_map(&bytes, kind, width, height)
354 }
355
356 pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {
357 self.complete
358 .png_sha256
359 .iter()
360 .map(|(k, v)| (k.as_str(), v.as_str()))
361 }
362}
363
364fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
365 if m.format_version != OUTPUT_META_VERSION {
366 return Err(ExportError::Identity(format!(
367 "render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})",
368 m.format_version
369 )));
370 }
371 if m.status != "incomplete" && m.status != "complete" {
372 // production leaves status=incomplete in render.json even after success
373 return Err(ExportError::Corrupt(format!(
374 "onbekende render.json status '{}'",
375 m.status
376 )));
377 }
378 if !["male", "female"].contains(&m.sex.as_str()) {
379 return Err(ExportError::Identity("ongeldig geslacht".into()));
380 }
381 if m.set != "body" && !crate::equipment::valid_name(&m.set) {
382 return Err(ExportError::Identity("ongeldige setnaam".into()));
383 }
384 if m.target[0] == 0 || m.target[1] == 0 || m.target[0] > MAX_TARGET || m.target[1] > MAX_TARGET
385 {
386 return Err(ExportError::Resource("target buiten 1–256".into()));
387 }
388 if m.fps == 0 || m.fps > 240 {
389 return Err(ExportError::Identity("ongeldige fps".into()));
390 }
391 if m.highres_scale != SCALE {
392 return Err(ExportError::Identity("highres_scale wijkt af van 8".into()));
393 }
394 m.framing.validate().map_err(ExportError::Identity)?;
395 if (m.near - NEAR).abs() > f32::EPSILON || (m.far - FAR).abs() > f32::EPSILON {
396 return Err(ExportError::Identity(
397 "near/far wijkt af van contract".into(),
398 ));
399 }
400 if (m.camera_distance - 10.).abs() > f32::EPSILON {
401 return Err(ExportError::Identity(
402 "camera_distance wijkt af van contract".into(),
403 ));
404 }
405 if m.pass_order != PASSES {
406 return Err(ExportError::Identity(
407 "pass_order wijkt af van contract".into(),
408 ));
409 }
410 if m.saved_maps != ["diffuse", "depth", "normal", "specular"] {
411 return Err(ExportError::Identity(
412 "saved_maps wijkt af van diffuse/depth/normal/specular".into(),
413 ));
414 }
415 if m.clips.is_empty() || m.clips.len() > MAX_CLIPS {
416 return Err(ExportError::Resource("clip-inventaris ongeldig".into()));
417 }
418 for clip in &m.clips {
419 if clip.token.is_empty()
420 || clip.token.len() > 64
421 || !crate::equipment::valid_name(&clip.token)
422 {
423 return Err(ExportError::Identity(format!(
424 "ongeldige clip-token '{}'",
425 clip.token
426 )));
427 }
428 if clip.name.len() > MAX_CLIP_NAME {
429 return Err(ExportError::Resource("clipnaam te lang".into()));
430 }
431 if clip.frames == 0 {
432 return Err(ExportError::Identity("clip zonder frames".into()));
433 }
434 if !clip.duration.is_finite() || clip.duration < 0. {
435 return Err(ExportError::Identity("ongeldige clipduur".into()));
436 }
437 }
438 if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
439 return Err(ExportError::Resource("png_files buiten limiet".into()));
440 }
441 if !is_sha256_hex(&m.body_model.sha256) {
442 return Err(ExportError::Corrupt(
443 "body_model.sha256 is geen SHA-256 hex".into(),
444 ));
445 }
446 project::portable_relative(&m.body_model.path).map_err(ExportError::Traversal)?;
447 if m.project.id.is_empty() || m.project.id.len() > 128 {
448 return Err(ExportError::Identity("project.id ongeldig".into()));
449 }
450 if m.project.name.len() > 256 {
451 return Err(ExportError::Identity("project.name te lang".into()));
452 }
453 match &m.equipment_preset {
454 None if m.set == "body" => {}
455 Some(p)
456 if m.set == p.name
457 && is_sha256_hex(&p.sha256)
458 && crate::equipment::valid_name(&p.name) => {}
459 _ => {
460 return Err(ExportError::Identity(
461 "equipment_preset past niet bij set of mist geldige sha256".into(),
462 ))
463 }
464 }
465 match m.pivot_source.kind.as_str() {
466 "manual" | "neck" | "fallback" => {}
467 other => {
468 return Err(ExportError::Identity(format!(
469 "onbekende pivot_source.kind '{other}'"
470 )))
471 }
472 }
473 Ok(())
474}
475
476fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {
477 if m.format_version != OUTPUT_META_VERSION {
478 return Err(ExportError::Identity(format!(
479 "complete.json format_version {} wordt niet ondersteund",
480 m.format_version
481 )));
482 }
483 if m.status != "complete" {
484 return Err(ExportError::Incomplete(
485 "complete.json status is niet 'complete'".into(),
486 ));
487 }
488 if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
489 return Err(ExportError::Resource("png_files buiten limiet".into()));
490 }
491 if m.png_sha256.len() > MAX_PNG_FILES as usize {
492 return Err(ExportError::Resource("te veel png_sha256-items".into()));
493 }
494 if !is_sha256_hex(&m.identity.body_model_sha256) {
495 return Err(ExportError::Corrupt(
496 "identity.body_model_sha256 ongeldig".into(),
497 ));
498 }
499 if let Some(hash) = &m.identity.equipment_preset_sha256 {
500 if !is_sha256_hex(hash) {
501 return Err(ExportError::Corrupt(
502 "identity.equipment_preset_sha256 ongeldig".into(),
503 ));
504 }
505 }
506 for (path, hash) in &m.png_sha256 {
507 project::portable_relative(path).map_err(ExportError::Traversal)?;
508 if !path.ends_with(".png") {
509 return Err(ExportError::Corrupt(format!(
510 "png_sha256-sleutel '{path}' is geen .png"
511 )));
512 }
513 if !is_sha256_hex(hash) {
514 return Err(ExportError::Corrupt(format!(
515 "png_sha256 voor '{path}' is geen SHA-256 hex"
516 )));
517 }
518 }
519 Ok(())
520}
521
522fn identities_match(
523 render: &RenderManifest,
524 complete: &CompleteManifest,
525) -> Result<(), ExportError> {
526 let id = &complete.identity;
527 let check = |ok: bool, msg: &str| {
528 if ok {
529 Ok(())
530 } else {
531 Err(ExportError::Identity(msg.into()))
532 }
533 };
534 check(id.project_id == render.project.id, "project.id mismatch")?;
535 check(
536 id.project_name == render.project.name,
537 "project.name mismatch",
538 )?;
539 check(id.sex == render.sex, "sex mismatch")?;
540 check(id.set == render.set, "set mismatch")?;
541 check(
542 id.body_model_sha256 == render.body_model.sha256,
543 "body_model sha256 mismatch",
544 )?;
545 check(id.target == render.target, "target mismatch")?;
546 check(id.fps == render.fps, "fps mismatch")?;
547 check(id.framing == render.framing, "framing mismatch")?;
548 check(
549 id.near == render.near && id.far == render.far,
550 "near/far mismatch",
551 )?;
552 check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
553 check(
554 id.clips.len() == render.clips.len(),
555 "clips length mismatch",
556 )?;
557 for (a, b) in id.clips.iter().zip(render.clips.iter()) {
558 if a.token != b.token || a.frames != b.frames || a.name != b.name {
559 return Err(ExportError::Identity("clip inventory mismatch".into()));
560 }
561 }
562 check(
563 id.pivot_source.kind == render.pivot_source.kind,
564 "pivot_source mismatch",
565 )?;
566 let preset_hash = render.equipment_preset.as_ref().map(|p| p.sha256.clone());
567 check(
568 id.equipment_preset_sha256 == preset_hash,
569 "equipment preset sha256 mismatch",
570 )?;
571 Ok(())
572}
573
574fn decode_map(
575 bytes: &[u8],
576 kind: MapKind,
577 expect_w: u32,
578 expect_h: u32,
579) -> Result<MapImage, ExportError> {
580 let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
581 decoder.set_transformations(png::Transformations::IDENTITY);
582 let mut reader = decoder
583 .read_info()
584 .map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;
585 let info = reader.info();
586 if info.width != expect_w || info.height != expect_h {
587 return Err(ExportError::Corrupt(format!(
588 "PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",
589 info.width, info.height
590 )));
591 }
592 if info.width > MAX_TARGET || info.height > MAX_TARGET {
593 return Err(ExportError::Resource("PNG groter dan targetlimiet".into()));
594 }
595 match kind {
596 MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
597 if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
598 return Err(ExportError::Corrupt(
599 "RGBA8-map heeft onverwacht kleurtype/bitdiepte".into(),
600 ));
601 }
602 let size = reader.output_buffer_size().ok_or_else(|| {
603 ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
604 })?;
605 let mut buf = vec![0u8; size];
606 let frame = reader
607 .next_frame(&mut buf)
608 .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
609 if frame.buffer_size() != expect_w as usize * expect_h as usize * 4 {
610 return Err(ExportError::Corrupt("PNG buffergrootte klopt niet".into()));
611 }
612 buf.truncate(frame.buffer_size());
613 if kind == MapKind::Normal {
614 validate_normals(&buf, expect_w, expect_h)?;
615 }
616 if kind == MapKind::Specular {
617 validate_specular_channels(&buf)?;
618 }
619 Ok(MapImage::Rgba8 {
620 width: expect_w,
621 height: expect_h,
622 rgba: buf,
623 })
624 }
625 MapKind::Depth => {
626 if info.color_type != png::ColorType::GrayscaleAlpha
627 || info.bit_depth != png::BitDepth::Sixteen
628 {
629 return Err(ExportError::Corrupt(
630 "depth-map moet gray-alpha 16-bit zijn".into(),
631 ));
632 }
633 let size = reader.output_buffer_size().ok_or_else(|| {
634 ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
635 })?;
636 let mut buf = vec![0u8; size];
637 let frame = reader
638 .next_frame(&mut buf)
639 .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
640 let expected = expect_w as usize * expect_h as usize * 4; // 2 channels * 2 bytes
641 if frame.buffer_size() != expected {
642 return Err(ExportError::Corrupt(
643 "depth buffergrootte klopt niet".into(),
644 ));
645 }
646 buf.truncate(frame.buffer_size());
647 let mut samples = Vec::with_capacity(expect_w as usize * expect_h as usize * 2);
648 for chunk in buf.chunks_exact(2) {
649 samples.push(u16::from_be_bytes([chunk[0], chunk[1]]));
650 }
651 validate_depth_alpha(&samples)?;
652 Ok(MapImage::Depth16 {
653 width: expect_w,
654 height: expect_h,
655 samples,
656 })
657 }
658 }
659}
660
661fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {
662 for px in rgba.chunks_exact(4) {
663 let a = px[3];
664 if a == 0 {
665 if px[0] != 0 || px[1] != 0 || px[2] != 0 {
666 return Err(ExportError::Corrupt(
667 "transparante normal heeft niet-nul RGB".into(),
668 ));
669 }
670 continue;
671 }
672 let nx = px[0] as f32 / 255. * 2. - 1.;
673 let ny = px[1] as f32 / 255. * 2. - 1.;
674 let nz = px[2] as f32 / 255. * 2. - 1.;
675 let len = (nx * nx + ny * ny + nz * nz).sqrt();
676 if !(0.85..=1.15).contains(&len) {
677 return Err(ExportError::Corrupt(format!(
678 "normal lengte {len} buiten tolerantie"
679 )));
680 }
681 }
682 Ok(())
683}
684
685fn validate_specular_channels(rgba: &[u8]) -> Result<(), ExportError> {
686 for px in rgba.chunks_exact(4) {
687 if px[0] != px[1] || px[1] != px[2] {
688 return Err(ExportError::Corrupt(
689 "specular RGB-kanalen zijn niet gelijk".into(),
690 ));
691 }
692 }
693 Ok(())
694}
695
696fn validate_depth_alpha(samples: &[u16]) -> Result<(), ExportError> {
697 for pair in samples.chunks_exact(2) {
698 let gray = pair[0];
699 let alpha = pair[1];
700 if alpha == 0 && gray != 0 {
701 return Err(ExportError::Corrupt(
702 "transparante depth heeft niet-nul gray".into(),
703 ));
704 }
705 }
706 Ok(())
707}
708
709fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {
710 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
711 if !meta.is_dir() {
712 return Err(ExportError::Io("runpad is geen map".into()));
713 }
714 path.canonicalize()
715 .map_err(|e| ExportError::Io(e.to_string()))
716}
717
718fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
719 let path = root.join(name);
720 if !path.exists() {
721 return Err(ExportError::Incomplete(format!("{name} ontbreekt")));
722 }
723 verify_regular_file(&path, root)?;
724 read_bounded_file(&path, MAX_MANIFEST_BYTES)
725}
726
727fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
728 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
729 if meta.len() > max {
730 return Err(ExportError::Resource(format!(
731 "{} groter dan {max} bytes",
732 path.display()
733 )));
734 }
735 let file = fs::File::open(path).map_err(|e| ExportError::Io(e.to_string()))?;
736 let mut bytes = Vec::new();
737 file.take(max + 1)
738 .read_to_end(&mut bytes)
739 .map_err(|e| ExportError::Io(e.to_string()))?;
740 if bytes.len() as u64 > max {
741 return Err(ExportError::Resource(format!(
742 "{} groter dan {max} bytes",
743 path.display()
744 )));
745 }
746 Ok(bytes)
747}
748
749fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
750 let clean = project::portable_relative(relative).map_err(ExportError::Traversal)?;
751 let path = root.join(&clean);
752 // Lexical rejection of oddities already handled; still verify canonical containment.
753 Ok(path)
754}
755
756fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
757 let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
758 if meta.file_type().is_symlink() {
759 return Err(ExportError::Traversal(format!(
760 "symlink geweigerd: {}",
761 path.display()
762 )));
763 }
764 if !meta.is_file() {
765 return Err(ExportError::Io(format!(
766 "geen regulier bestand: {}",
767 path.display()
768 )));
769 }
770 let root_real = root
771 .canonicalize()
772 .map_err(|e| ExportError::Io(e.to_string()))?;
773 let path_real = path
774 .canonicalize()
775 .map_err(|e| ExportError::Io(e.to_string()))?;
776 if !path_real.starts_with(&root_real) {
777 return Err(ExportError::Traversal(format!(
778 "pad verlaat runmap: {}",
779 path.display()
780 )));
781 }
782 Ok(())
783}
784
785/// Build the identity object embedded in complete.json (shared with production).
786pub fn complete_identity_json(render: &serde_json::Value) -> Result<serde_json::Value, String> {
787 let project = render.get("project").ok_or("render.json mist project")?;
788 Ok(serde_json::json!({
789 "project_id": project.get("id").ok_or("project.id")?,
790 "project_name": project.get("name").ok_or("project.name")?,
791 "sex": render.get("sex").ok_or("sex")?,
792 "set": render.get("set").ok_or("set")?,
793 "body_model_sha256": render.pointer("/body_model/sha256").ok_or("body sha")?,
794 "equipment_preset_sha256": render.pointer("/equipment_preset/sha256").cloned().unwrap_or(serde_json::Value::Null),
795 "target": render.get("target").ok_or("target")?,
796 "fps": render.get("fps").ok_or("fps")?,
797 "framing": render.get("framing").ok_or("framing")?,
798 "pivot_source": render.get("pivot_source").ok_or("pivot")?,
799 "near": render.get("near").ok_or("near")?,
800 "far": render.get("far").ok_or("far")?,
801 "clips": render.get("clips").ok_or("clips")?,
802 "saved_maps": render.get("saved_maps").ok_or("maps")?,
803 }))
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 use crate::production::{write_depth, write_rgba};
810 use crate::render_job::ImageBuf;
811 use std::sync::atomic::{AtomicU64, Ordering};
812
813 struct Temp(PathBuf);
814 impl Temp {
815 fn new() -> Self {
816 static N: AtomicU64 = AtomicU64::new(0);
817 let id = N.fetch_add(1, Ordering::Relaxed);
818 let path = std::env::temp_dir().join(format!(
819 "paperdoll-export-set-{}-{}",
820 std::process::id(),
821 id
822 ));
823 let _ = fs::remove_dir_all(&path);
824 fs::create_dir_all(&path).unwrap();
825 Self(path)
826 }
827 }
828 impl Drop for Temp {
829 fn drop(&mut self) {
830 let _ = fs::remove_dir_all(&self.0);
831 }
832 }
833
834 fn framing() -> CameraFraming {
835 CameraFraming {
836 height_milli: 2600,
837 center_y_milli: 1000,
838 pivot_y_milli: Some(1460),
839 }
840 }
841
842 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
843 let data = serde_json::to_vec_pretty(value).unwrap();
844 let path = root.join(name);
845 let temp = path.with_extension(format!("part-{}", std::process::id()));
846 fs::write(&temp, &data).unwrap();
847 fs::hard_link(&temp, &path).unwrap();
848 let _ = fs::remove_file(temp);
849 }
850
851 fn base_render(png_files: u32) -> serde_json::Value {
852 serde_json::json!({
853 "format_version": 3,
854 "status": "incomplete",
855 "project": {"id": "demo", "name": "Demo"},
856 "sex": "male",
857 "set": "body",
858 "body_model": {"path": "assets/body/x.glb", "sha256": digest::sha256_hex(b"body")},
859 "equipment_preset": null,
860 "target": [1, 1],
861 "fps": 12,
862 "highres_scale": 8,
863 "framing": framing(),
864 "pivot_source": {"kind": "manual"},
865 "near": NEAR,
866 "far": FAR,
867 "camera_distance": 10.0,
868 "yaw_zero": "camera on -Z",
869 "normal_space": "view (+Y up), PNG rows top-down",
870 "pitches": [-30, 0, 30],
871 "yaw_step": 30,
872 "sampling": "test",
873 "alpha": "straight",
874 "diffuse": "unlit",
875 "depth": "16-bit",
876 "specular": "scalar",
877 "pass_order": PASSES,
878 "saved_maps": ["diffuse", "depth", "normal", "specular"],
879 "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
880 "images": 1,
881 "passes": 6,
882 "png_files": png_files
883 })
884 }
885
886 fn publish_four(root: &Path) -> BTreeMap<String, String> {
887 let mut map = BTreeMap::new();
888 let rels = [
889 (
890 "male/body/clip000-bind/f000000/p-30_y000_diffuse.png",
891 "diffuse",
892 ),
893 (
894 "male/body/clip000-bind/f000000/p-30_y000_depth.png",
895 "depth",
896 ),
897 (
898 "male/body/clip000-bind/f000000/p-30_y000_normal.png",
899 "normal",
900 ),
901 (
902 "male/body/clip000-bind/f000000/p-30_y000_specular.png",
903 "specular",
904 ),
905 ];
906 for (rel, kind) in rels {
907 let path = Path::new(rel);
908 match kind {
909 "depth" => write_depth(root, path, 1, 1, &[20000, 65535]).unwrap(),
910 "normal" => write_rgba(
911 root,
912 path,
913 &ImageBuf {
914 width: 1,
915 height: 1,
916 rgba: vec![128, 128, 255, 255],
917 },
918 )
919 .unwrap(),
920 "specular" => write_rgba(
921 root,
922 path,
923 &ImageBuf {
924 width: 1,
925 height: 1,
926 rgba: vec![0, 0, 0, 255],
927 },
928 )
929 .unwrap(),
930 _ => write_rgba(
931 root,
932 path,
933 &ImageBuf {
934 width: 1,
935 height: 1,
936 rgba: vec![10, 20, 30, 255],
937 },
938 )
939 .unwrap(),
940 };
941 let bytes = fs::read(root.join(rel)).unwrap();
942 map.insert(rel.to_string(), digest::sha256_hex(&bytes));
943 }
944 map
945 }
946
947 #[test]
948 fn opens_completed_run_and_reads_maps() {
949 let tmp = Temp::new();
950 let checksums = publish_four(&tmp.0);
951 let render = base_render(4);
952 write_atomic_json(&tmp.0, "render.json", &render);
953 let identity = complete_identity_json(&render).unwrap();
954 write_atomic_json(
955 &tmp.0,
956 "complete.json",
957 &serde_json::json!({
958 "format_version": 3,
959 "status": "complete",
960 "identity": identity,
961 "passes": 6,
962 "png_files": 4,
963 "png_sha256": checksums
964 }),
965 );
966 let run = ExportRun::open(&tmp.0).unwrap();
967 let diffuse = run
968 .load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse)
969 .unwrap();
970 match diffuse {
971 MapImage::Rgba8 {
972 width,
973 height,
974 rgba,
975 } => {
976 assert_eq!((width, height), (1, 1));
977 assert_eq!(rgba, [10, 20, 30, 255]);
978 }
979 _ => panic!("expected rgba"),
980 }
981 let depth = run
982 .load_map("clip000-bind", 0, -30, 0, MapKind::Depth)
983 .unwrap();
984 match depth {
985 MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),
986 _ => panic!("expected depth"),
987 }
988 }
989
990 #[test]
991 fn rejects_missing_complete_and_checksum_mismatch() {
992 let tmp = Temp::new();
993 let render = base_render(4);
994 write_atomic_json(&tmp.0, "render.json", &render);
995 assert!(matches!(
996 ExportRun::open(&tmp.0),
997 Err(ExportError::Incomplete(_))
998 ));
999 let mut checksums = publish_four(&tmp.0);
1000 *checksums.values_mut().next().unwrap() = digest::sha256_hex(b"tampered");
1001 let identity = complete_identity_json(&render).unwrap();
1002 write_atomic_json(
1003 &tmp.0,
1004 "complete.json",
1005 &serde_json::json!({
1006 "format_version": 3,
1007 "status": "complete",
1008 "identity": identity,
1009 "passes": 6,
1010 "png_files": 4,
1011 "png_sha256": checksums
1012 }),
1013 );
1014 assert!(matches!(
1015 ExportRun::open(&tmp.0),
1016 Err(ExportError::Corrupt(_))
1017 ));
1018 }
1019
1020 #[test]
1021 fn rejects_path_traversal_and_symlink_escape() {
1022 let tmp = Temp::new();
1023 let mut checksums = publish_four(&tmp.0);
1024 checksums.insert("../escape.png".into(), digest::sha256_hex(b"x"));
1025 let render = base_render(5);
1026 write_atomic_json(&tmp.0, "render.json", &render);
1027 let identity = complete_identity_json(&render).unwrap();
1028 write_atomic_json(
1029 &tmp.0,
1030 "complete.json",
1031 &serde_json::json!({
1032 "format_version": 3,
1033 "status": "complete",
1034 "identity": identity,
1035 "passes": 6,
1036 "png_files": 5,
1037 "png_sha256": checksums
1038 }),
1039 );
1040 assert!(matches!(
1041 ExportRun::open(&tmp.0),
1042 Err(ExportError::Traversal(_))
1043 ));
1044
1045 #[cfg(unix)]
1046 {
1047 let tmp = Temp::new();
1048 let outside = Temp::new();
1049 let checksums = publish_four(&tmp.0);
1050 let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png";
1051 let _ = fs::remove_file(tmp.0.join(evil));
1052 std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();
1053 fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();
1054 let render = base_render(4);
1055 write_atomic_json(&tmp.0, "render.json", &render);
1056 let identity = complete_identity_json(&render).unwrap();
1057 let mut checksums = checksums;
1058 checksums.insert(evil.into(), digest::sha256_hex(b"not-a-png"));
1059 write_atomic_json(
1060 &tmp.0,
1061 "complete.json",
1062 &serde_json::json!({
1063 "format_version": 3,
1064 "status": "complete",
1065 "identity": identity,
1066 "passes": 6,
1067 "png_files": 4,
1068 "png_sha256": checksums
1069 }),
1070 );
1071 assert!(matches!(
1072 ExportRun::open(&tmp.0),
1073 Err(ExportError::Traversal(_))
1074 ));
1075 }
1076 }
1077
1078 #[test]
1079 fn rejects_stale_schema_and_identity_drift() {
1080 let tmp = Temp::new();
1081 let checksums = publish_four(&tmp.0);
1082 let mut render = base_render(4);
1083 render["format_version"] = serde_json::json!(2);
1084 write_atomic_json(&tmp.0, "render.json", &render);
1085 write_atomic_json(
1086 &tmp.0,
1087 "complete.json",
1088 &serde_json::json!({
1089 "format_version": 3,
1090 "status": "complete",
1091 "identity": {
1092 "project_id": "demo",
1093 "project_name": "Demo",
1094 "sex": "male",
1095 "set": "body",
1096 "body_model_sha256": digest::sha256_hex(b"body"),
1097 "equipment_preset_sha256": null,
1098 "target": [1,1],
1099 "fps": 12,
1100 "framing": framing(),
1101 "pivot_source": {"kind": "manual"},
1102 "near": NEAR,
1103 "far": FAR,
1104 "clips": [{"name":"","token":"clip000-bind","frames":1,"duration":0.0}],
1105 "saved_maps": ["diffuse","depth","normal","specular"]
1106 },
1107 "passes": 6,
1108 "png_files": 4,
1109 "png_sha256": checksums
1110 }),
1111 );
1112 assert!(matches!(
1113 ExportRun::open(&tmp.0),
1114 Err(ExportError::Identity(_))
1115 ));
1116 }
1117
1118 #[test]
1119 fn rejects_bad_normal_and_wrong_depth_type() {
1120 let tmp = Temp::new();
1121 // Broken normal: transparent with non-zero RGB
1122 write_rgba(
1123 &tmp.0,
1124 Path::new("bad_normal.png"),
1125 &ImageBuf {
1126 width: 1,
1127 height: 1,
1128 rgba: vec![10, 0, 0, 0],
1129 },
1130 )
1131 .unwrap();
1132 let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();
1133 assert!(matches!(
1134 decode_map(&bytes, MapKind::Normal, 1, 1),
1135 Err(ExportError::Corrupt(_))
1136 ));
1137 // RGBA written as depth should fail color type check
1138 write_rgba(
1139 &tmp.0,
1140 Path::new("fake_depth.png"),
1141 &ImageBuf {
1142 width: 1,
1143 height: 1,
1144 rgba: vec![0, 0, 0, 255],
1145 },
1146 )
1147 .unwrap();
1148 let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();
1149 assert!(matches!(
1150 decode_map(&bytes, MapKind::Depth, 1, 1),
1151 Err(ExportError::Corrupt(_))
1152 ));
1153 }
1154}