//! Stable subscene reference validation for authored level compositions. use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use shared::SceneComposition; use crate::document::SceneDocument; pub fn validate_scene_composition(composition: &SceneComposition) -> Result<(), String> { if composition.scene_id.trim().is_empty() { return Err("scene composition has an empty scene_id".to_string()); } let mut reference_ids = HashSet::new(); for reference in &composition.subscenes { if reference.id.trim().is_empty() { return Err("subscene reference has an empty id".to_string()); } if !reference_ids.insert(reference.id.as_str()) { return Err(format!( "scene composition contains duplicate subscene id `{}`", reference.id )); } validate_project_relative_path(&reference.path)?; } Ok(()) } pub fn validate_composition_graph(entry: &Path, project_root: &Path) -> Result<(), String> { let entry = canonical_existing(entry)?; let project_root = canonical_existing(project_root)?; let mut visiting = Vec::new(); let mut visited = HashSet::new(); let mut scene_ids = HashMap::::new(); visit_scene( &entry, &project_root, &mut visiting, &mut visited, &mut scene_ids, ) } pub(crate) fn find_project_root(path: &Path) -> Option { let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().ok()?.join(path) }; absolute.ancestors().find_map(|ancestor| { ancestor .join("assets/project.ron") .is_file() .then(|| ancestor.to_path_buf()) }) } fn visit_scene( path: &Path, project_root: &Path, visiting: &mut Vec, visited: &mut HashSet, scene_ids: &mut HashMap, ) -> Result<(), String> { if let Some(cycle_start) = visiting.iter().position(|candidate| candidate == path) { let mut cycle: Vec = visiting[cycle_start..] .iter() .map(|candidate| project_label(candidate, project_root)) .collect(); cycle.push(project_label(path, project_root)); return Err(format!("cyclic subscene reference: {}", cycle.join(" -> "))); } if !visited.insert(path.to_path_buf()) { return Ok(()); } let text = std::fs::read_to_string(path) .map_err(|error| format!("could not read {}: {error}", path.display()))?; let document = SceneDocument::from_ron_text(&text) .map_err(|error| format!("{}: {error}", path.display()))?; let Some(composition) = document.composition else { return Ok(()); }; validate_scene_composition(&composition) .map_err(|error| format!("{}: {error}", path.display()))?; if let Some(existing) = scene_ids.insert(composition.scene_id.clone(), path.to_path_buf()) { if existing != path { return Err(format!( "duplicate scene_id `{}` in {} and {}", composition.scene_id, existing.display(), path.display() )); } } visiting.push(path.to_path_buf()); for reference in composition.subscenes { let target = canonical_project_path(project_root, &reference.path)?; visit_scene(&target, project_root, visiting, visited, scene_ids)?; } visiting.pop(); Ok(()) } fn canonical_project_path(project_root: &Path, relative: &str) -> Result { validate_project_relative_path(relative)?; let target = project_root.join(relative); let target = canonical_existing(&target).map_err(|error| { format!( "missing subscene `{relative}` under {}: {error}", project_root.display() ) })?; if !target.starts_with(project_root) { return Err(format!("subscene `{relative}` escapes the project root")); } Ok(target) } fn validate_project_relative_path(path: &str) -> Result<(), String> { let path = Path::new(path); if path.as_os_str().is_empty() || path.is_absolute() { return Err(format!( "subscene path `{}` must be project-relative", path.display() )); } if path.components().any(|component| { matches!( component, Component::ParentDir | Component::RootDir | Component::Prefix(_) ) }) { return Err(format!( "subscene path `{}` may not escape the project root", path.display() )); } if !path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".scn.ron")) { return Err(format!( "subscene path `{}` must name a .scn.ron file", path.display() )); } Ok(()) } fn canonical_existing(path: &Path) -> Result { path.canonicalize() .map_err(|error| format!("{}: {error}", path.display())) } fn project_label(path: &Path, project_root: &Path) -> String { path.strip_prefix(project_root) .unwrap_or(path) .display() .to_string() } #[cfg(test)] mod tests { use super::*; use std::fs; use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); fn fixture_root() -> PathBuf { let root = std::env::temp_dir().join(format!( "blacksite-composition-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(root.join("assets/levels")).unwrap(); fs::write(root.join("assets/project.ron"), "()").unwrap(); root } fn scene(scene_id: &str, references: &[(&str, &str)]) -> String { let refs = references .iter() .map(|(id, path)| format!("(id:\"{id}\",path:\"{path}\",visible:true,locked:true)")) .collect::>() .join(","); format!( "(schema_version:2,resources:{{\"shared::components::SceneComposition\":(scene_id:\"{scene_id}\",subscenes:[{refs}])}},entities:{{}})" ) } #[test] fn valid_two_subscene_graph_passes() { let root = fixture_root(); let main = root.join("assets/levels/main.scn.ron"); fs::write( &main, scene( "main", &[ ("geometry", "assets/levels/geometry.scn.ron"), ("lighting", "assets/levels/lighting.scn.ron"), ], ), ) .unwrap(); fs::write( root.join("assets/levels/geometry.scn.ron"), scene("geometry", &[]), ) .unwrap(); fs::write( root.join("assets/levels/lighting.scn.ron"), scene("lighting", &[]), ) .unwrap(); validate_composition_graph(&main, &root).unwrap(); fs::remove_dir_all(root).unwrap(); } #[test] fn missing_and_cyclic_references_are_rejected() { let root = fixture_root(); let main = root.join("assets/levels/main.scn.ron"); fs::write( &main, scene("main", &[("missing", "assets/levels/missing.scn.ron")]), ) .unwrap(); assert!(validate_composition_graph(&main, &root) .unwrap_err() .contains("missing subscene")); let child = root.join("assets/levels/child.scn.ron"); fs::write( &main, scene("main", &[("child", "assets/levels/child.scn.ron")]), ) .unwrap(); fs::write( &child, scene("child", &[("main", "assets/levels/main.scn.ron")]), ) .unwrap(); assert!(validate_composition_graph(&main, &root) .unwrap_err() .contains("cyclic subscene reference")); fs::remove_dir_all(root).unwrap(); } #[test] fn unsafe_paths_and_duplicate_reference_ids_are_rejected() { let mut composition = SceneComposition { scene_id: "main".to_string(), subscenes: vec![ shared::SubsceneReference { id: "same".to_string(), path: "../outside.scn.ron".to_string(), ..Default::default() }, shared::SubsceneReference { id: "same".to_string(), path: "assets/levels/other.scn.ron".to_string(), ..Default::default() }, ], }; assert!(validate_scene_composition(&composition).is_err()); composition.subscenes[0].path = "assets/levels/one.scn.ron".to_string(); assert!(validate_scene_composition(&composition) .unwrap_err() .contains("duplicate subscene id")); } }