280 lines
8.8 KiB
Rust
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"));
|
|
}
|
|
}
|