Blacksite/crates/scene/src/composition.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

280 lines
8.8 KiB
Rust

//! 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::<String, PathBuf>::new();
visit_scene(
&entry,
&project_root,
&mut visiting,
&mut visited,
&mut scene_ids,
)
}
pub(crate) fn find_project_root(path: &Path) -> Option<PathBuf> {
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<PathBuf>,
visited: &mut HashSet<PathBuf>,
scene_ids: &mut HashMap<String, PathBuf>,
) -> Result<(), String> {
if let Some(cycle_start) = visiting.iter().position(|candidate| candidate == path) {
let mut cycle: Vec<String> = 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<PathBuf, String> {
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<PathBuf, String> {
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::<Vec<_>>()
.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"));
}
}