//! Safe reader/index for completed production export runs. //! //! A run directory is accepted only when both `render.json` (schema 3) and //! `complete.json` are present, their identity contracts match, every listed //! PNG checksum verifies, and each map decodes under the published pixel //! contract. Filenames alone never prove compatibility. use crate::{ digest::{self, is_sha256_hex}, production::{FAR, MAX_HIGHRES, NEAR, PASSES, SCALE}, project::{self, CameraFraming}, }; use serde::Deserialize; use std::{ collections::BTreeMap, fs, io::Read, path::{Path, PathBuf}, }; /// Output metadata schema written by production and required by this reader. pub const OUTPUT_META_VERSION: u32 = 3; pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE; /// Hard ceiling for a single published PNG on disk (encoded). pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024; /// Same batch file ceiling as production planning. pub const MAX_PNG_FILES: u32 = 500_000; const MAX_MANIFEST_BYTES: u64 = 32 * 1024 * 1024; const MAX_CLIP_NAME: usize = 256; const MAX_CLIPS: usize = 4096; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExportError { Incomplete(String), Identity(String), Io(String), Corrupt(String), Resource(String), Traversal(String), } impl std::fmt::Display for ExportError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Incomplete(m) | Self::Identity(m) | Self::Io(m) | Self::Corrupt(m) | Self::Resource(m) | Self::Traversal(m) => write!(f, "{m}"), } } } impl std::error::Error for ExportError {} #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MapKind { Diffuse, Normal, Specular, Depth, } impl MapKind { pub fn token(self) -> &'static str { match self { Self::Diffuse => "diffuse", Self::Normal => "normal", Self::Specular => "specular", Self::Depth => "depth", } } pub fn parse(token: &str) -> Option { match token { "diffuse" => Some(Self::Diffuse), "normal" => Some(Self::Normal), "specular" => Some(Self::Specular), "depth" => Some(Self::Depth), _ => None, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum MapImage { Rgba8 { width: u32, height: u32, rgba: Vec, }, Depth16 { width: u32, height: u32, /// Interleaved gray, alpha samples (length = width*height*2). samples: Vec, }, } impl MapImage { pub fn width(&self) -> u32 { match self { Self::Rgba8 { width, .. } | Self::Depth16 { width, .. } => *width, } } pub fn height(&self) -> u32 { match self { Self::Rgba8 { height, .. } | Self::Depth16 { height, .. } => *height, } } } #[derive(Debug, Clone, Deserialize)] pub struct SourceRef { pub path: String, pub sha256: String, } #[derive(Debug, Clone, Deserialize)] pub struct PresetRef { pub name: String, pub sha256: String, } #[derive(Debug, Clone, Deserialize)] pub struct ClipRecord { pub name: String, pub token: String, pub frames: u32, pub duration: f32, } #[derive(Debug, Clone, Deserialize)] pub struct PivotSourceJson { pub kind: String, #[serde(default)] pub joint_index: Option, #[serde(default)] pub joint_name: Option, #[serde(default)] pub reason: Option, } #[derive(Debug, Clone, Deserialize)] pub struct RenderManifest { pub format_version: u32, pub status: String, pub project: ProjectRef, pub sex: String, pub set: String, pub body_model: SourceRef, #[serde(default)] pub equipment_preset: Option, pub target: [u32; 2], pub fps: u32, pub highres_scale: u32, pub framing: CameraFraming, pub pivot_source: PivotSourceJson, pub near: f32, pub far: f32, pub camera_distance: f32, pub pass_order: Vec, pub saved_maps: Vec, pub clips: Vec, pub images: u32, pub passes: u32, pub png_files: u32, } #[derive(Debug, Clone, Deserialize)] pub struct ProjectRef { pub id: String, pub name: String, } #[derive(Debug, Clone, Deserialize)] pub struct CompleteIdentity { pub project_id: String, pub project_name: String, pub sex: String, pub set: String, pub body_model_sha256: String, #[serde(default)] pub equipment_preset_sha256: Option, pub target: [u32; 2], pub fps: u32, pub framing: CameraFraming, pub pivot_source: PivotSourceJson, pub near: f32, pub far: f32, pub clips: Vec, pub saved_maps: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct CompleteManifest { pub format_version: u32, pub status: String, pub identity: CompleteIdentity, pub passes: u32, pub png_files: u32, pub png_sha256: BTreeMap, } #[derive(Debug, Clone)] pub struct ExportRun { root: PathBuf, pub render: RenderManifest, pub complete: CompleteManifest, } impl ExportRun { pub fn root(&self) -> &Path { &self.root } pub fn open(run_dir: &Path) -> Result { let root = canonicalize_dir(run_dir)?; if root.join("cancelled.json").exists() || root.join("failed.json").exists() { return Err(ExportError::Incomplete( "run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(), )); } let render_bytes = read_manifest_file(&root, "render.json")?; let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| { if matches!(err, ExportError::Incomplete(_)) { ExportError::Incomplete( "complete.json ontbreekt; de exportset is niet volledig".into(), ) } else { err } })?; let render: RenderManifest = serde_json::from_slice(&render_bytes) .map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?; let complete: CompleteManifest = serde_json::from_slice(&complete_bytes) .map_err(|e| ExportError::Corrupt(format!("complete.json: {e}")))?; validate_render(&render)?; validate_complete(&complete)?; identities_match(&render, &complete)?; if complete.png_sha256.len() as u32 != complete.png_files { return Err(ExportError::Corrupt( "complete.json png_sha256 telt niet gelijk aan png_files".into(), )); } if complete.png_files != render.png_files { return Err(ExportError::Identity( "png_files in complete.json wijkt af van render.json".into(), )); } // Verify every checksum exists as a bounded regular file under the run. for (relative, digest) in &complete.png_sha256 { let path = resolve_inside(&root, relative)?; verify_regular_file(&path, &root)?; let meta = fs::metadata(&path).map_err(|e| ExportError::Io(e.to_string()))?; if meta.len() > MAX_PNG_BYTES { return Err(ExportError::Resource(format!( "PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes" ))); } let bytes = fs::read(&path).map_err(|e| ExportError::Io(e.to_string()))?; let actual = digest::sha256_hex(&bytes); if actual != *digest { return Err(ExportError::Corrupt(format!( "checksum mismatch voor '{relative}'" ))); } } Ok(Self { root, render, complete, }) } pub fn map_path( &self, clip_token: &str, frame: u32, pitch: i32, yaw: u32, kind: MapKind, ) -> Result { let relative = crate::production::output_path( &self.render.sex, &self.render.set, clip_token, frame, pitch, yaw, kind.token(), ) .map_err(ExportError::Identity)?; let key = relative .to_str() .ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))? .replace('\\', "/"); if !self.complete.png_sha256.contains_key(&key) { return Err(ExportError::Incomplete(format!( "map '{key}' staat niet in complete.json" ))); } Ok(key) } pub fn load_map( &self, clip_token: &str, frame: u32, pitch: i32, yaw: u32, kind: MapKind, ) -> Result { let key = self.map_path(clip_token, frame, pitch, yaw, kind)?; self.load_relative(&key, kind) } pub fn load_relative( &self, relative: &str, expected: MapKind, ) -> Result { let expected_digest = self .complete .png_sha256 .get(relative) .ok_or_else(|| ExportError::Incomplete(format!("onbekende map '{relative}'")))?; let path = resolve_inside(&self.root, relative)?; verify_regular_file(&path, &self.root)?; let bytes = read_bounded_file(&path, MAX_PNG_BYTES)?; let actual = digest::sha256_hex(&bytes); if actual != *expected_digest { return Err(ExportError::Corrupt(format!( "checksum mismatch voor '{relative}'" ))); } let token = relative .rsplit('_') .next() .and_then(|s| s.strip_suffix(".png")) .ok_or_else(|| ExportError::Corrupt(format!("geen map-token in '{relative}'")))?; let kind = MapKind::parse(token).ok_or_else(|| { ExportError::Corrupt(format!("onbekend map-token '{token}' in '{relative}'")) })?; if kind != expected { return Err(ExportError::Identity(format!( "verwachtte {} maar pad is {}", expected.token(), kind.token() ))); } let [width, height] = self.render.target; decode_map(&bytes, kind, width, height) } pub fn listed_maps(&self) -> impl Iterator { self.complete .png_sha256 .iter() .map(|(k, v)| (k.as_str(), v.as_str())) } } fn validate_render(m: &RenderManifest) -> Result<(), ExportError> { if m.format_version != OUTPUT_META_VERSION { return Err(ExportError::Identity(format!( "render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})", m.format_version ))); } if m.status != "incomplete" && m.status != "complete" { // production leaves status=incomplete in render.json even after success return Err(ExportError::Corrupt(format!( "onbekende render.json status '{}'", m.status ))); } if !["male", "female"].contains(&m.sex.as_str()) { return Err(ExportError::Identity("ongeldig geslacht".into())); } if m.set != "body" && !crate::equipment::valid_name(&m.set) { return Err(ExportError::Identity("ongeldige setnaam".into())); } if m.target[0] == 0 || m.target[1] == 0 || m.target[0] > MAX_TARGET || m.target[1] > MAX_TARGET { return Err(ExportError::Resource("target buiten 1–256".into())); } if m.fps == 0 || m.fps > 240 { return Err(ExportError::Identity("ongeldige fps".into())); } if m.highres_scale != SCALE { return Err(ExportError::Identity("highres_scale wijkt af van 8".into())); } m.framing.validate().map_err(ExportError::Identity)?; if (m.near - NEAR).abs() > f32::EPSILON || (m.far - FAR).abs() > f32::EPSILON { return Err(ExportError::Identity( "near/far wijkt af van contract".into(), )); } if (m.camera_distance - 10.).abs() > f32::EPSILON { return Err(ExportError::Identity( "camera_distance wijkt af van contract".into(), )); } if m.pass_order != PASSES { return Err(ExportError::Identity( "pass_order wijkt af van contract".into(), )); } if m.saved_maps != ["diffuse", "depth", "normal", "specular"] { return Err(ExportError::Identity( "saved_maps wijkt af van diffuse/depth/normal/specular".into(), )); } if m.clips.is_empty() || m.clips.len() > MAX_CLIPS { return Err(ExportError::Resource("clip-inventaris ongeldig".into())); } for clip in &m.clips { if clip.token.is_empty() || clip.token.len() > 64 || !crate::equipment::valid_name(&clip.token) { return Err(ExportError::Identity(format!( "ongeldige clip-token '{}'", clip.token ))); } if clip.name.len() > MAX_CLIP_NAME { return Err(ExportError::Resource("clipnaam te lang".into())); } if clip.frames == 0 { return Err(ExportError::Identity("clip zonder frames".into())); } if !clip.duration.is_finite() || clip.duration < 0. { return Err(ExportError::Identity("ongeldige clipduur".into())); } } if m.png_files == 0 || m.png_files > MAX_PNG_FILES { return Err(ExportError::Resource("png_files buiten limiet".into())); } if !is_sha256_hex(&m.body_model.sha256) { return Err(ExportError::Corrupt( "body_model.sha256 is geen SHA-256 hex".into(), )); } project::portable_relative(&m.body_model.path).map_err(ExportError::Traversal)?; if m.project.id.is_empty() || m.project.id.len() > 128 { return Err(ExportError::Identity("project.id ongeldig".into())); } if m.project.name.len() > 256 { return Err(ExportError::Identity("project.name te lang".into())); } match &m.equipment_preset { None if m.set == "body" => {} Some(p) if m.set == p.name && is_sha256_hex(&p.sha256) && crate::equipment::valid_name(&p.name) => {} _ => { return Err(ExportError::Identity( "equipment_preset past niet bij set of mist geldige sha256".into(), )) } } match m.pivot_source.kind.as_str() { "manual" | "neck" | "fallback" => {} other => { return Err(ExportError::Identity(format!( "onbekende pivot_source.kind '{other}'" ))) } } Ok(()) } fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> { if m.format_version != OUTPUT_META_VERSION { return Err(ExportError::Identity(format!( "complete.json format_version {} wordt niet ondersteund", m.format_version ))); } if m.status != "complete" { return Err(ExportError::Incomplete( "complete.json status is niet 'complete'".into(), )); } if m.png_files == 0 || m.png_files > MAX_PNG_FILES { return Err(ExportError::Resource("png_files buiten limiet".into())); } if m.png_sha256.len() > MAX_PNG_FILES as usize { return Err(ExportError::Resource("te veel png_sha256-items".into())); } if !is_sha256_hex(&m.identity.body_model_sha256) { return Err(ExportError::Corrupt( "identity.body_model_sha256 ongeldig".into(), )); } if let Some(hash) = &m.identity.equipment_preset_sha256 { if !is_sha256_hex(hash) { return Err(ExportError::Corrupt( "identity.equipment_preset_sha256 ongeldig".into(), )); } } for (path, hash) in &m.png_sha256 { project::portable_relative(path).map_err(ExportError::Traversal)?; if !path.ends_with(".png") { return Err(ExportError::Corrupt(format!( "png_sha256-sleutel '{path}' is geen .png" ))); } if !is_sha256_hex(hash) { return Err(ExportError::Corrupt(format!( "png_sha256 voor '{path}' is geen SHA-256 hex" ))); } } Ok(()) } fn identities_match( render: &RenderManifest, complete: &CompleteManifest, ) -> Result<(), ExportError> { let id = &complete.identity; let check = |ok: bool, msg: &str| { if ok { Ok(()) } else { Err(ExportError::Identity(msg.into())) } }; check(id.project_id == render.project.id, "project.id mismatch")?; check( id.project_name == render.project.name, "project.name mismatch", )?; check(id.sex == render.sex, "sex mismatch")?; check(id.set == render.set, "set mismatch")?; check( id.body_model_sha256 == render.body_model.sha256, "body_model sha256 mismatch", )?; check(id.target == render.target, "target mismatch")?; check(id.fps == render.fps, "fps mismatch")?; check(id.framing == render.framing, "framing mismatch")?; check( id.near == render.near && id.far == render.far, "near/far mismatch", )?; check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?; check( id.clips.len() == render.clips.len(), "clips length mismatch", )?; for (a, b) in id.clips.iter().zip(render.clips.iter()) { if a.token != b.token || a.frames != b.frames || a.name != b.name { return Err(ExportError::Identity("clip inventory mismatch".into())); } } check( id.pivot_source.kind == render.pivot_source.kind, "pivot_source mismatch", )?; let preset_hash = render.equipment_preset.as_ref().map(|p| p.sha256.clone()); check( id.equipment_preset_sha256 == preset_hash, "equipment preset sha256 mismatch", )?; Ok(()) } fn decode_map( bytes: &[u8], kind: MapKind, expect_w: u32, expect_h: u32, ) -> Result { let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); decoder.set_transformations(png::Transformations::IDENTITY); let mut reader = decoder .read_info() .map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?; let info = reader.info(); if info.width != expect_w || info.height != expect_h { return Err(ExportError::Corrupt(format!( "PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}", info.width, info.height ))); } if info.width > MAX_TARGET || info.height > MAX_TARGET { return Err(ExportError::Resource("PNG groter dan targetlimiet".into())); } match kind { MapKind::Diffuse | MapKind::Normal | MapKind::Specular => { if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight { return Err(ExportError::Corrupt( "RGBA8-map heeft onverwacht kleurtype/bitdiepte".into(), )); } let size = reader.output_buffer_size().ok_or_else(|| { ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into()) })?; let mut buf = vec![0u8; size]; let frame = reader .next_frame(&mut buf) .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?; if frame.buffer_size() != expect_w as usize * expect_h as usize * 4 { return Err(ExportError::Corrupt("PNG buffergrootte klopt niet".into())); } buf.truncate(frame.buffer_size()); if kind == MapKind::Normal { validate_normals(&buf, expect_w, expect_h)?; } if kind == MapKind::Specular { validate_specular_channels(&buf)?; } Ok(MapImage::Rgba8 { width: expect_w, height: expect_h, rgba: buf, }) } MapKind::Depth => { if info.color_type != png::ColorType::GrayscaleAlpha || info.bit_depth != png::BitDepth::Sixteen { return Err(ExportError::Corrupt( "depth-map moet gray-alpha 16-bit zijn".into(), )); } let size = reader.output_buffer_size().ok_or_else(|| { ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into()) })?; let mut buf = vec![0u8; size]; let frame = reader .next_frame(&mut buf) .map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?; let expected = expect_w as usize * expect_h as usize * 4; // 2 channels * 2 bytes if frame.buffer_size() != expected { return Err(ExportError::Corrupt( "depth buffergrootte klopt niet".into(), )); } buf.truncate(frame.buffer_size()); let mut samples = Vec::with_capacity(expect_w as usize * expect_h as usize * 2); for chunk in buf.chunks_exact(2) { samples.push(u16::from_be_bytes([chunk[0], chunk[1]])); } validate_depth_alpha(&samples)?; Ok(MapImage::Depth16 { width: expect_w, height: expect_h, samples, }) } } } fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> { for px in rgba.chunks_exact(4) { let a = px[3]; if a == 0 { if px[0] != 0 || px[1] != 0 || px[2] != 0 { return Err(ExportError::Corrupt( "transparante normal heeft niet-nul RGB".into(), )); } continue; } let nx = px[0] as f32 / 255. * 2. - 1.; let ny = px[1] as f32 / 255. * 2. - 1.; let nz = px[2] as f32 / 255. * 2. - 1.; let len = (nx * nx + ny * ny + nz * nz).sqrt(); if !(0.85..=1.15).contains(&len) { return Err(ExportError::Corrupt(format!( "normal lengte {len} buiten tolerantie" ))); } } Ok(()) } fn validate_specular_channels(rgba: &[u8]) -> Result<(), ExportError> { for px in rgba.chunks_exact(4) { if px[0] != px[1] || px[1] != px[2] { return Err(ExportError::Corrupt( "specular RGB-kanalen zijn niet gelijk".into(), )); } } Ok(()) } fn validate_depth_alpha(samples: &[u16]) -> Result<(), ExportError> { for pair in samples.chunks_exact(2) { let gray = pair[0]; let alpha = pair[1]; if alpha == 0 && gray != 0 { return Err(ExportError::Corrupt( "transparante depth heeft niet-nul gray".into(), )); } } Ok(()) } fn canonicalize_dir(path: &Path) -> Result { let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?; if !meta.is_dir() { return Err(ExportError::Io("runpad is geen map".into())); } path.canonicalize() .map_err(|e| ExportError::Io(e.to_string())) } fn read_manifest_file(root: &Path, name: &str) -> Result, ExportError> { let path = root.join(name); if !path.exists() { return Err(ExportError::Incomplete(format!("{name} ontbreekt"))); } verify_regular_file(&path, root)?; read_bounded_file(&path, MAX_MANIFEST_BYTES) } fn read_bounded_file(path: &Path, max: u64) -> Result, ExportError> { let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?; if meta.len() > max { return Err(ExportError::Resource(format!( "{} groter dan {max} bytes", path.display() ))); } let file = fs::File::open(path).map_err(|e| ExportError::Io(e.to_string()))?; let mut bytes = Vec::new(); file.take(max + 1) .read_to_end(&mut bytes) .map_err(|e| ExportError::Io(e.to_string()))?; if bytes.len() as u64 > max { return Err(ExportError::Resource(format!( "{} groter dan {max} bytes", path.display() ))); } Ok(bytes) } fn resolve_inside(root: &Path, relative: &str) -> Result { let clean = project::portable_relative(relative).map_err(ExportError::Traversal)?; let path = root.join(&clean); // Lexical rejection of oddities already handled; still verify canonical containment. Ok(path) } fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> { let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?; if meta.file_type().is_symlink() { return Err(ExportError::Traversal(format!( "symlink geweigerd: {}", path.display() ))); } if !meta.is_file() { return Err(ExportError::Io(format!( "geen regulier bestand: {}", path.display() ))); } let root_real = root .canonicalize() .map_err(|e| ExportError::Io(e.to_string()))?; let path_real = path .canonicalize() .map_err(|e| ExportError::Io(e.to_string()))?; if !path_real.starts_with(&root_real) { return Err(ExportError::Traversal(format!( "pad verlaat runmap: {}", path.display() ))); } Ok(()) } /// Build the identity object embedded in complete.json (shared with production). pub fn complete_identity_json(render: &serde_json::Value) -> Result { let project = render.get("project").ok_or("render.json mist project")?; Ok(serde_json::json!({ "project_id": project.get("id").ok_or("project.id")?, "project_name": project.get("name").ok_or("project.name")?, "sex": render.get("sex").ok_or("sex")?, "set": render.get("set").ok_or("set")?, "body_model_sha256": render.pointer("/body_model/sha256").ok_or("body sha")?, "equipment_preset_sha256": render.pointer("/equipment_preset/sha256").cloned().unwrap_or(serde_json::Value::Null), "target": render.get("target").ok_or("target")?, "fps": render.get("fps").ok_or("fps")?, "framing": render.get("framing").ok_or("framing")?, "pivot_source": render.get("pivot_source").ok_or("pivot")?, "near": render.get("near").ok_or("near")?, "far": render.get("far").ok_or("far")?, "clips": render.get("clips").ok_or("clips")?, "saved_maps": render.get("saved_maps").ok_or("maps")?, })) } #[cfg(test)] mod tests { use super::*; use crate::production::{write_depth, write_rgba}; use crate::render_job::ImageBuf; use std::sync::atomic::{AtomicU64, Ordering}; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: AtomicU64 = AtomicU64::new(0); let id = N.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "paperdoll-export-set-{}-{}", std::process::id(), id )); let _ = fs::remove_dir_all(&path); fs::create_dir_all(&path).unwrap(); Self(path) } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn framing() -> CameraFraming { CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: Some(1460), } } fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) { let data = serde_json::to_vec_pretty(value).unwrap(); let path = root.join(name); let temp = path.with_extension(format!("part-{}", std::process::id())); fs::write(&temp, &data).unwrap(); fs::hard_link(&temp, &path).unwrap(); let _ = fs::remove_file(temp); } fn base_render(png_files: u32) -> serde_json::Value { serde_json::json!({ "format_version": 3, "status": "incomplete", "project": {"id": "demo", "name": "Demo"}, "sex": "male", "set": "body", "body_model": {"path": "assets/body/x.glb", "sha256": digest::sha256_hex(b"body")}, "equipment_preset": null, "target": [1, 1], "fps": 12, "highres_scale": 8, "framing": framing(), "pivot_source": {"kind": "manual"}, "near": NEAR, "far": FAR, "camera_distance": 10.0, "yaw_zero": "camera on -Z", "normal_space": "view (+Y up), PNG rows top-down", "pitches": [-30, 0, 30], "yaw_step": 30, "sampling": "test", "alpha": "straight", "diffuse": "unlit", "depth": "16-bit", "specular": "scalar", "pass_order": PASSES, "saved_maps": ["diffuse", "depth", "normal", "specular"], "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}], "images": 1, "passes": 6, "png_files": png_files }) } fn publish_four(root: &Path) -> BTreeMap { let mut map = BTreeMap::new(); let rels = [ ( "male/body/clip000-bind/f000000/p-30_y000_diffuse.png", "diffuse", ), ( "male/body/clip000-bind/f000000/p-30_y000_depth.png", "depth", ), ( "male/body/clip000-bind/f000000/p-30_y000_normal.png", "normal", ), ( "male/body/clip000-bind/f000000/p-30_y000_specular.png", "specular", ), ]; for (rel, kind) in rels { let path = Path::new(rel); match kind { "depth" => write_depth(root, path, 1, 1, &[20000, 65535]).unwrap(), "normal" => write_rgba( root, path, &ImageBuf { width: 1, height: 1, rgba: vec![128, 128, 255, 255], }, ) .unwrap(), "specular" => write_rgba( root, path, &ImageBuf { width: 1, height: 1, rgba: vec![0, 0, 0, 255], }, ) .unwrap(), _ => write_rgba( root, path, &ImageBuf { width: 1, height: 1, rgba: vec![10, 20, 30, 255], }, ) .unwrap(), }; let bytes = fs::read(root.join(rel)).unwrap(); map.insert(rel.to_string(), digest::sha256_hex(&bytes)); } map } #[test] fn opens_completed_run_and_reads_maps() { let tmp = Temp::new(); let checksums = publish_four(&tmp.0); let render = base_render(4); write_atomic_json(&tmp.0, "render.json", &render); let identity = complete_identity_json(&render).unwrap(); write_atomic_json( &tmp.0, "complete.json", &serde_json::json!({ "format_version": 3, "status": "complete", "identity": identity, "passes": 6, "png_files": 4, "png_sha256": checksums }), ); let run = ExportRun::open(&tmp.0).unwrap(); let diffuse = run .load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse) .unwrap(); match diffuse { MapImage::Rgba8 { width, height, rgba, } => { assert_eq!((width, height), (1, 1)); assert_eq!(rgba, [10, 20, 30, 255]); } _ => panic!("expected rgba"), } let depth = run .load_map("clip000-bind", 0, -30, 0, MapKind::Depth) .unwrap(); match depth { MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]), _ => panic!("expected depth"), } } #[test] fn rejects_missing_complete_and_checksum_mismatch() { let tmp = Temp::new(); let render = base_render(4); write_atomic_json(&tmp.0, "render.json", &render); assert!(matches!( ExportRun::open(&tmp.0), Err(ExportError::Incomplete(_)) )); let mut checksums = publish_four(&tmp.0); *checksums.values_mut().next().unwrap() = digest::sha256_hex(b"tampered"); let identity = complete_identity_json(&render).unwrap(); write_atomic_json( &tmp.0, "complete.json", &serde_json::json!({ "format_version": 3, "status": "complete", "identity": identity, "passes": 6, "png_files": 4, "png_sha256": checksums }), ); assert!(matches!( ExportRun::open(&tmp.0), Err(ExportError::Corrupt(_)) )); } #[test] fn rejects_path_traversal_and_symlink_escape() { let tmp = Temp::new(); let mut checksums = publish_four(&tmp.0); checksums.insert("../escape.png".into(), digest::sha256_hex(b"x")); let render = base_render(5); write_atomic_json(&tmp.0, "render.json", &render); let identity = complete_identity_json(&render).unwrap(); write_atomic_json( &tmp.0, "complete.json", &serde_json::json!({ "format_version": 3, "status": "complete", "identity": identity, "passes": 6, "png_files": 5, "png_sha256": checksums }), ); assert!(matches!( ExportRun::open(&tmp.0), Err(ExportError::Traversal(_)) )); #[cfg(unix)] { let tmp = Temp::new(); let outside = Temp::new(); let checksums = publish_four(&tmp.0); let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png"; let _ = fs::remove_file(tmp.0.join(evil)); std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap(); fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap(); let render = base_render(4); write_atomic_json(&tmp.0, "render.json", &render); let identity = complete_identity_json(&render).unwrap(); let mut checksums = checksums; checksums.insert(evil.into(), digest::sha256_hex(b"not-a-png")); write_atomic_json( &tmp.0, "complete.json", &serde_json::json!({ "format_version": 3, "status": "complete", "identity": identity, "passes": 6, "png_files": 4, "png_sha256": checksums }), ); assert!(matches!( ExportRun::open(&tmp.0), Err(ExportError::Traversal(_)) )); } } #[test] fn rejects_stale_schema_and_identity_drift() { let tmp = Temp::new(); let checksums = publish_four(&tmp.0); let mut render = base_render(4); render["format_version"] = serde_json::json!(2); write_atomic_json(&tmp.0, "render.json", &render); write_atomic_json( &tmp.0, "complete.json", &serde_json::json!({ "format_version": 3, "status": "complete", "identity": { "project_id": "demo", "project_name": "Demo", "sex": "male", "set": "body", "body_model_sha256": digest::sha256_hex(b"body"), "equipment_preset_sha256": null, "target": [1,1], "fps": 12, "framing": framing(), "pivot_source": {"kind": "manual"}, "near": NEAR, "far": FAR, "clips": [{"name":"","token":"clip000-bind","frames":1,"duration":0.0}], "saved_maps": ["diffuse","depth","normal","specular"] }, "passes": 6, "png_files": 4, "png_sha256": checksums }), ); assert!(matches!( ExportRun::open(&tmp.0), Err(ExportError::Identity(_)) )); } #[test] fn rejects_bad_normal_and_wrong_depth_type() { let tmp = Temp::new(); // Broken normal: transparent with non-zero RGB write_rgba( &tmp.0, Path::new("bad_normal.png"), &ImageBuf { width: 1, height: 1, rgba: vec![10, 0, 0, 0], }, ) .unwrap(); let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap(); assert!(matches!( decode_map(&bytes, MapKind::Normal, 1, 1), Err(ExportError::Corrupt(_)) )); // RGBA written as depth should fail color type check write_rgba( &tmp.0, Path::new("fake_depth.png"), &ImageBuf { width: 1, height: 1, rgba: vec![0, 0, 0, 255], }, ) .unwrap(); let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap(); assert!(matches!( decode_map(&bytes, MapKind::Depth, 1, 1), Err(ExportError::Corrupt(_)) )); } }