From cd79c07370aa9628ec58f9f957658f380f85e3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=94=B9=E5=90=8D=E5=8F=AA=E9=9C=80=E4=B8=89=E7=A7=92?= <133723203+ITSGJY@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:55:33 +0800 Subject: [PATCH] refactor(project): centralize project recipe resolution --- Cargo.lock | 1 + crates/webcodex-validation/Cargo.toml | 1 + crates/webcodex-validation/src/recipe.rs | 208 ++++------------ .../webcodex-validation/src/recipe_tests.rs | 33 +++ crates/webcodex-workspace/src/lib.rs | 4 + .../webcodex-workspace/src/project_recipe.rs | 231 ++++++++++++++++++ .../src/project_recipe_tests.rs | 217 ++++++++++++++++ workspace-boundaries.toml | 1 + 8 files changed, 534 insertions(+), 162 deletions(-) create mode 100644 crates/webcodex-workspace/src/project_recipe.rs create mode 100644 crates/webcodex-workspace/src/project_recipe_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1cce8cac1..f48e57fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5698,6 +5698,7 @@ dependencies = [ "webcodex-tool-contracts", "webcodex-tool-runtime-contracts", "webcodex-workflow-session", + "webcodex-workspace", ] [[package]] diff --git a/crates/webcodex-validation/Cargo.toml b/crates/webcodex-validation/Cargo.toml index 0e8942013..980c18e8b 100644 --- a/crates/webcodex-validation/Cargo.toml +++ b/crates/webcodex-validation/Cargo.toml @@ -14,6 +14,7 @@ session-evidence = ["dep:webcodex-tool-contracts", "dep:webcodex-workflow-sessio webcodex-core = { path = "../webcodex-core" } webcodex-tool-contracts = { path = "../webcodex-tool-contracts", optional = true } webcodex-workflow-session = { path = "../webcodex-workflow-session", optional = true } +webcodex-workspace = { path = "../webcodex-workspace" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/webcodex-validation/src/recipe.rs b/crates/webcodex-validation/src/recipe.rs index a8c02dcad..cabc18100 100644 --- a/crates/webcodex-validation/src/recipe.rs +++ b/crates/webcodex-validation/src/recipe.rs @@ -5,12 +5,16 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::fs; -use std::path::{Component, Path, PathBuf}; +use std::path::Path; use webcodex_core::runner_protocol::{normalize_rust_test_filter, ShellJobValidationStep}; +use webcodex_workspace::project_recipe::{ + digest_project_recipe_files, read_project_recipe_file, resolve_project_recipe_root, + ProjectRecipeResolutionError, +}; + +pub use webcodex_workspace::project_recipe::ProjectRecipeId as RecipeId; const RECIPE_VERSION: u32 = 1; -const RECIPE_NAMES: [&str; 4] = ["rust", "node", "python", "go"]; -const RECIPE_MARKERS: [&str; 4] = ["Cargo.toml", "package.json", "pyproject.toml", "go.mod"]; const PYTHON_MANIFESTLESS_DIGEST_SEED: &[u8] = b"webcodex.python.manifestless.recipe.v1"; const PYTHON_UNITTEST_ARGS: [&str; 5] = ["-B", "-m", "unittest", "discover", "-v"]; const NODE_LOCKFILES: [(&str, &str); 6] = [ @@ -22,30 +26,6 @@ const NODE_LOCKFILES: [(&str, &str); 6] = [ ("bun.lockb", "bun"), ]; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -#[repr(usize)] -pub enum RecipeId { - Rust, - Node, - Python, - Go, -} - -impl RecipeId { - pub fn as_str(self) -> &'static str { - RECIPE_NAMES[self as usize] - } - - fn marker(self) -> &'static str { - RECIPE_MARKERS[self as usize] - } - - fn all() -> [Self; 4] { - [Self::Rust, Self::Node, Self::Python, Self::Go] - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] #[repr(usize)] @@ -112,6 +92,27 @@ impl RecipeError { } } +fn map_project_recipe_error(error: ProjectRecipeResolutionError) -> RecipeError { + match error { + ProjectRecipeResolutionError::ExecutionRootUnavailable + | ProjectRecipeResolutionError::NotFound => RecipeError::new("validation_recipe_not_found"), + ProjectRecipeResolutionError::CwdMismatch + | ProjectRecipeResolutionError::ExplicitNotFound { .. } => { + RecipeError::new("validation_recipe_mismatch") + } + ProjectRecipeResolutionError::ExplicitMismatch { + recipe_root, + candidates, + .. + } => RecipeError::new("validation_recipe_mismatch").at(recipe_root, &candidates), + ProjectRecipeResolutionError::Ambiguous { + recipe_root, + candidates, + } => RecipeError::new("validation_recipe_ambiguous").at(recipe_root, &candidates), + ProjectRecipeResolutionError::SourceFileInvalid => manifest_invalid(), + } +} + pub fn resolve_validation_recipe( execution_root: &Path, cwd: Option<&str>, @@ -137,17 +138,16 @@ pub fn resolve_validation_recipe_with_packages( test_filter: Option<&str>, package_scope: Option<&[String]>, ) -> Result { - let root = execution_root - .canonicalize() - .map_err(|_| RecipeError::new("validation_recipe_not_found"))?; - let cwd = resolve_cwd(&root, cwd)?; - let (recipe, recipe_root) = nearest_recipe_root(&root, &cwd, explicit_recipe)?; + let resolved_root = resolve_project_recipe_root(execution_root, cwd, explicit_recipe) + .map_err(map_project_recipe_error)?; + let root = &resolved_root.execution_root; + let recipe = resolved_root.recipe; + let recipe_root = &resolved_root.absolute_root; if package_scope.is_some() && !matches!(recipe, RecipeId::Rust | RecipeId::Go) { return Err(RecipeError::new("validation_scope_unsupported")); } - let root_relative = relative_root(&root, &recipe_root); - let marker = recipe.marker(); - let marker_path = recipe_root.join(marker); + let root_relative = resolved_root.relative_root.clone(); + let marker_path = resolved_root.marker_path(); let manifestless_python = if recipe == RecipeId::Python { match fs::symlink_metadata(&marker_path) { Ok(_) => false, @@ -164,7 +164,8 @@ pub fn resolve_validation_recipe_with_packages( format!("{:x}", Sha256::digest(PYTHON_MANIFESTLESS_DIGEST_SEED)), ) } else { - let manifest = read_manifest(&root, &marker_path)?; + let manifest = + read_project_recipe_file(root, &marker_path).map_err(map_project_recipe_error)?; let (steps, extra_digest_files) = match recipe { RecipeId::Rust | RecipeId::Go => { canonical_adapter_steps(recipe, checks, test_filter.as_deref(), package_scope)? @@ -172,14 +173,15 @@ pub fn resolve_validation_recipe_with_packages( RecipeId::Node => node_steps(&recipe_root, &manifest, checks)?, RecipeId::Python => python_steps(&manifest, checks)?, }; - let manifest_digest = digest_files( - &root, + let manifest_digest = digest_project_recipe_files( + root, std::iter::once(marker_path).chain( extra_digest_files .into_iter() .map(|file| recipe_root.join(file)), ), - )?; + ) + .map_err(map_project_recipe_error)?; (steps, manifest_digest) }; let invocation_digest = format!( @@ -200,83 +202,8 @@ pub fn resolve_validation_recipe_with_packages( }) } -fn resolve_cwd(root: &Path, raw: Option<&str>) -> Result { - let raw = raw.unwrap_or("."); - let path = Path::new(raw); - if raw.is_empty() - || raw.contains('\0') - || path.is_absolute() - || path.components().any(|part| { - matches!( - part, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) - { - return Err(RecipeError::new("validation_recipe_mismatch")); - } - let cwd = root - .join(path) - .canonicalize() - .map_err(|_| RecipeError::new("validation_recipe_mismatch"))?; - if !cwd.starts_with(root) || !cwd.is_dir() { - return Err(RecipeError::new("validation_recipe_mismatch")); - } - Ok(cwd) -} - -fn nearest_recipe_root( - root: &Path, - cwd: &Path, - explicit: Option, -) -> Result<(RecipeId, PathBuf), RecipeError> { - let mut directory = cwd.to_path_buf(); - loop { - let candidates = RecipeId::all() - .into_iter() - .filter(|recipe| directory.join(recipe.marker()).is_file()) - .collect::>(); - if !candidates.is_empty() { - let relative = relative_root(root, &directory); - if let Some(explicit) = explicit { - if candidates.contains(&explicit) { - return Ok((explicit, directory)); - } - if explicit == RecipeId::Python { - return Ok((RecipeId::Python, cwd.to_path_buf())); - } - return Err( - RecipeError::new("validation_recipe_mismatch").at(relative, &candidates) - ); - } - if candidates.len() == 1 { - return Ok((candidates[0], directory)); - } - let mut candidates = candidates; - candidates.sort_by_key(|recipe| recipe.as_str()); - return Err(RecipeError::new("validation_recipe_ambiguous").at(relative, &candidates)); - } - if directory == root { - break; - } - let Some(parent) = directory.parent() else { - break; - }; - directory = parent.to_path_buf(); - } - if explicit == Some(RecipeId::Python) { - return Ok((RecipeId::Python, cwd.to_path_buf())); - } - let code = if explicit.is_some() { - "validation_recipe_mismatch" - } else { - "validation_recipe_not_found" - }; - Err(RecipeError::new(code)) -} - -/// Recipe resolution owns project/root provenance; canonical adapters own the -/// executable and argv for production Rust/Go validation steps. +/// Shared project recipe resolution supplies filesystem/root facts; validation +/// owns semantic action planning and canonical Rust/Go executable/argv synthesis. fn canonical_adapter_steps( recipe: RecipeId, checks: &[SemanticCheck], @@ -515,47 +442,6 @@ fn reject_filter(filter: Option<&str>) -> Result<(), RecipeError> { } } -fn read_manifest(execution_root: &Path, path: &Path) -> Result, RecipeError> { - let canonical = path.canonicalize().map_err(|_| manifest_invalid())?; - if !canonical.starts_with(execution_root) || !canonical.is_file() { - return Err(manifest_invalid()); - } - fs::read(canonical).map_err(|_| manifest_invalid()) -} - -fn digest_files(execution_root: &Path, paths: I) -> Result -where - I: IntoIterator, -{ - let mut hasher = Sha256::new(); - for path in paths { - if !path.exists() { - continue; - } - let canonical = path.canonicalize().map_err(|_| manifest_invalid())?; - if !canonical.starts_with(execution_root) || !canonical.is_file() { - return Err(manifest_invalid()); - } - let content = fs::read(&canonical).map_err(|_| manifest_invalid())?; - let relative = canonical - .strip_prefix(execution_root) - .map_err(|_| manifest_invalid())?; - hasher.update(relative.as_os_str().as_encoded_bytes()); - hasher.update((content.len() as u64).to_be_bytes()); - hasher.update(content); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn relative_root(root: &Path, recipe_root: &Path) -> String { - let relative = recipe_root.strip_prefix(root).unwrap_or(Path::new("")); - if relative.as_os_str().is_empty() { - ".".to_string() - } else { - relative.to_string_lossy().replace('\\', "/") - } -} - fn manifest_invalid() -> RecipeError { RecipeError::new("validation_manifest_invalid") } @@ -576,9 +462,7 @@ pub fn detect_validation_recipe( cwd: Option<&str>, hint: Option, ) -> Result { - let root = root - .canonicalize() - .map_err(|_| RecipeError::new("validation_recipe_not_found"))?; - let cwd = resolve_cwd(&root, cwd)?; - nearest_recipe_root(&root, &cwd, hint).map(|(recipe, _)| recipe) + resolve_project_recipe_root(root, cwd, hint) + .map(|resolved| resolved.recipe) + .map_err(map_project_recipe_error) } diff --git a/crates/webcodex-validation/src/recipe_tests.rs b/crates/webcodex-validation/src/recipe_tests.rs index 643a23ba2..bca74bc75 100644 --- a/crates/webcodex-validation/src/recipe_tests.rs +++ b/crates/webcodex-validation/src/recipe_tests.rs @@ -149,6 +149,14 @@ fn recipe_resolution_fails_closed_for_ambiguity_mismatch_and_missing_marker() { ) .unwrap_err(); assert_eq!(mismatch.code, "validation_recipe_mismatch"); + assert_eq!( + mismatch.details.as_ref().unwrap(), + &serde_json::json!({ + "recipe_root": ".", + "candidate_recipes": ["node", "go"], + "detected_markers": ["package.json", "go.mod"], + }) + ); let empty = tempfile::tempdir().unwrap(); let missing = resolve(empty.path(), None, None, &[SemanticCheck::Check], None).unwrap_err(); @@ -629,6 +637,31 @@ fn rust_filter_contract_normalizes_rejects_and_binds_identity() { assert!(!outside.path().join("target").exists()); } +#[cfg(unix)] +#[test] +fn marker_symlink_escape_remains_a_manifest_error_after_recipe_detection() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + write( + outside.path(), + "Cargo.toml", + "[package]\nname='outside'\nversion='0.1.0'\n", + ); + symlink( + outside.path().join("Cargo.toml"), + root.path().join("Cargo.toml"), + ) + .unwrap(); + assert_eq!( + resolve(root.path(), None, None, &[SemanticCheck::Check], None) + .unwrap_err() + .code, + "validation_manifest_invalid" + ); +} + #[cfg(unix)] #[test] fn cwd_symlink_escape_is_rejected() { diff --git a/crates/webcodex-workspace/src/lib.rs b/crates/webcodex-workspace/src/lib.rs index a87ed9017..33c79ae2d 100644 --- a/crates/webcodex-workspace/src/lib.rs +++ b/crates/webcodex-workspace/src/lib.rs @@ -5,5 +5,9 @@ pub mod file_read_range; pub mod path_policy; pub mod project_context; pub mod project_overview; +pub mod project_recipe; + +#[cfg(test)] +mod project_recipe_tests; #[cfg(feature = "workspace-checkpoints")] pub mod workspace_checkpoint; diff --git a/crates/webcodex-workspace/src/project_recipe.rs b/crates/webcodex-workspace/src/project_recipe.rs new file mode 100644 index 000000000..737632a9d --- /dev/null +++ b/crates/webcodex-workspace/src/project_recipe.rs @@ -0,0 +1,231 @@ +//! Neutral project recipe/root and source-file resolution. +//! +//! This module owns filesystem facts shared by project operations. It does not +//! select validation actions, synthesize argv, or attach validation evidence. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const PROJECT_RECIPE_NAMES: [&str; 4] = ["rust", "node", "python", "go"]; +const PROJECT_RECIPE_MARKERS: [&str; 4] = + ["Cargo.toml", "package.json", "pyproject.toml", "go.mod"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[repr(usize)] +pub enum ProjectRecipeId { + Rust, + Node, + Python, + Go, +} + +impl ProjectRecipeId { + pub fn as_str(self) -> &'static str { + PROJECT_RECIPE_NAMES[self as usize] + } + + pub fn marker(self) -> &'static str { + PROJECT_RECIPE_MARKERS[self as usize] + } + + fn all() -> [Self; 4] { + [Self::Rust, Self::Node, Self::Python, Self::Go] + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedProjectRecipeRoot { + pub recipe: ProjectRecipeId, + /// Canonical registered project root used as the containment boundary. + pub execution_root: PathBuf, + pub absolute_root: PathBuf, + pub relative_root: String, +} + +impl ResolvedProjectRecipeRoot { + pub fn marker_path(&self) -> PathBuf { + self.absolute_root.join(self.recipe.marker()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectRecipeResolutionError { + ExecutionRootUnavailable, + CwdMismatch, + NotFound, + ExplicitNotFound { + expected: ProjectRecipeId, + }, + ExplicitMismatch { + recipe_root: String, + expected: ProjectRecipeId, + candidates: Vec, + }, + Ambiguous { + recipe_root: String, + candidates: Vec, + }, + SourceFileInvalid, +} + +pub fn resolve_project_recipe_root( + execution_root: &Path, + cwd: Option<&str>, + hint: Option, +) -> Result { + let execution_root = execution_root + .canonicalize() + .map_err(|_| ProjectRecipeResolutionError::ExecutionRootUnavailable)?; + let cwd = resolve_cwd(&execution_root, cwd)?; + let (recipe, absolute_root) = nearest_recipe_root(&execution_root, &cwd, hint)?; + let relative_root = relative_root(&execution_root, &absolute_root); + Ok(ResolvedProjectRecipeRoot { + recipe, + execution_root, + absolute_root, + relative_root, + }) +} + +fn resolve_cwd(root: &Path, raw: Option<&str>) -> Result { + let raw = raw.unwrap_or("."); + let path = Path::new(raw); + if raw.is_empty() + || raw.contains('\0') + || path.is_absolute() + || path.components().any(|part| { + matches!( + part, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(ProjectRecipeResolutionError::CwdMismatch); + } + let cwd = root + .join(path) + .canonicalize() + .map_err(|_| ProjectRecipeResolutionError::CwdMismatch)?; + if !cwd.starts_with(root) || !cwd.is_dir() { + return Err(ProjectRecipeResolutionError::CwdMismatch); + } + Ok(cwd) +} + +fn nearest_recipe_root( + root: &Path, + cwd: &Path, + explicit: Option, +) -> Result<(ProjectRecipeId, PathBuf), ProjectRecipeResolutionError> { + let mut directory = cwd.to_path_buf(); + loop { + let candidates = ProjectRecipeId::all() + .into_iter() + .filter(|recipe| directory.join(recipe.marker()).is_file()) + .collect::>(); + if !candidates.is_empty() { + let recipe_root = relative_root(root, &directory); + if let Some(expected) = explicit { + if candidates.contains(&expected) { + return Ok((expected, directory)); + } + if expected == ProjectRecipeId::Python { + return Ok((ProjectRecipeId::Python, cwd.to_path_buf())); + } + return Err(ProjectRecipeResolutionError::ExplicitMismatch { + recipe_root, + expected, + candidates, + }); + } + if candidates.len() == 1 { + return Ok((candidates[0], directory)); + } + let mut candidates = candidates; + candidates.sort_by_key(|recipe| recipe.as_str()); + return Err(ProjectRecipeResolutionError::Ambiguous { + recipe_root, + candidates, + }); + } + if directory == root { + break; + } + let Some(parent) = directory.parent() else { + break; + }; + directory = parent.to_path_buf(); + } + if explicit == Some(ProjectRecipeId::Python) { + return Ok((ProjectRecipeId::Python, cwd.to_path_buf())); + } + match explicit { + Some(expected) => Err(ProjectRecipeResolutionError::ExplicitNotFound { expected }), + None => Err(ProjectRecipeResolutionError::NotFound), + } +} + +pub fn read_project_recipe_file( + execution_root: &Path, + path: &Path, +) -> Result, ProjectRecipeResolutionError> { + let path = anchored_path(execution_root, path); + let canonical = path + .canonicalize() + .map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid)?; + if !canonical.starts_with(execution_root) || !canonical.is_file() { + return Err(ProjectRecipeResolutionError::SourceFileInvalid); + } + fs::read(canonical).map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid) +} + +pub fn digest_project_recipe_files( + execution_root: &Path, + paths: I, +) -> Result +where + I: IntoIterator, +{ + let mut hasher = Sha256::new(); + for path in paths { + let path = anchored_path(execution_root, &path); + if !path.exists() { + continue; + } + let canonical = path + .canonicalize() + .map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid)?; + if !canonical.starts_with(execution_root) || !canonical.is_file() { + return Err(ProjectRecipeResolutionError::SourceFileInvalid); + } + let content = + fs::read(&canonical).map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid)?; + let relative = canonical + .strip_prefix(execution_root) + .map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid)?; + hasher.update(relative.as_os_str().as_encoded_bytes()); + hasher.update((content.len() as u64).to_be_bytes()); + hasher.update(content); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn anchored_path(execution_root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + execution_root.join(path) + } +} + +fn relative_root(root: &Path, recipe_root: &Path) -> String { + let relative = recipe_root.strip_prefix(root).unwrap_or(Path::new("")); + if relative.as_os_str().is_empty() { + ".".to_string() + } else { + relative.to_string_lossy().replace('\\', "/") + } +} diff --git a/crates/webcodex-workspace/src/project_recipe_tests.rs b/crates/webcodex-workspace/src/project_recipe_tests.rs new file mode 100644 index 000000000..bad1b6134 --- /dev/null +++ b/crates/webcodex-workspace/src/project_recipe_tests.rs @@ -0,0 +1,217 @@ +use super::project_recipe::*; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; + +fn write(root: &Path, path: &str, content: &str) { + let path = root.join(path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); +} + +#[test] +fn resolves_each_recipe_marker_and_nearest_nested_root() { + for (marker, expected) in [ + ("Cargo.toml", ProjectRecipeId::Rust), + ("package.json", ProjectRecipeId::Node), + ("pyproject.toml", ProjectRecipeId::Python), + ("go.mod", ProjectRecipeId::Go), + ] { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), marker, ""); + let resolved = resolve_project_recipe_root(temp.path(), None, None).unwrap(); + assert_eq!(resolved.recipe, expected); + assert_eq!(resolved.relative_root, "."); + } + + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), "Cargo.toml", ""); + write(temp.path(), "nested/go.mod", "module example.test/nested\n"); + let resolved = resolve_project_recipe_root(temp.path(), Some("nested"), None).unwrap(); + assert_eq!(resolved.recipe, ProjectRecipeId::Go); + assert_eq!(resolved.relative_root, "nested"); +} + +#[test] +fn ambiguity_is_sorted_but_explicit_mismatch_preserves_catalog_order() { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), "Cargo.toml", ""); + write(temp.path(), "package.json", "{}"); + write(temp.path(), "go.mod", "module example.test/root\n"); + + let ambiguous = resolve_project_recipe_root(temp.path(), None, None).unwrap_err(); + assert_eq!( + ambiguous, + ProjectRecipeResolutionError::Ambiguous { + recipe_root: ".".into(), + candidates: vec![ + ProjectRecipeId::Go, + ProjectRecipeId::Node, + ProjectRecipeId::Rust, + ], + } + ); + + let python = + resolve_project_recipe_root(temp.path(), None, Some(ProjectRecipeId::Python)).unwrap(); + assert_eq!(python.recipe, ProjectRecipeId::Python); + assert_eq!(python.relative_root, "."); + + let node = resolve_project_recipe_root(temp.path(), None, Some(ProjectRecipeId::Node)).unwrap(); + assert_eq!(node.recipe, ProjectRecipeId::Node); + + fs::remove_file(temp.path().join("package.json")).unwrap(); + let mismatch = + resolve_project_recipe_root(temp.path(), None, Some(ProjectRecipeId::Node)).unwrap_err(); + assert_eq!( + mismatch, + ProjectRecipeResolutionError::ExplicitMismatch { + recipe_root: ".".into(), + expected: ProjectRecipeId::Node, + candidates: vec![ProjectRecipeId::Rust, ProjectRecipeId::Go], + } + ); +} + +#[test] +fn explicit_not_found_and_unhinted_not_found_remain_distinct() { + let temp = tempfile::tempdir().unwrap(); + assert_eq!( + resolve_project_recipe_root(temp.path(), None, Some(ProjectRecipeId::Rust)).unwrap_err(), + ProjectRecipeResolutionError::ExplicitNotFound { + expected: ProjectRecipeId::Rust + } + ); + assert_eq!( + resolve_project_recipe_root(temp.path(), None, None).unwrap_err(), + ProjectRecipeResolutionError::NotFound + ); +} + +#[test] +fn cwd_validation_preserves_existing_recipe_predicate() { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), "Cargo.toml", ""); + for cwd in ["", "../outside", "/tmp", "nul\0byte"] { + assert_eq!( + resolve_project_recipe_root(temp.path(), Some(cwd), None).unwrap_err(), + ProjectRecipeResolutionError::CwdMismatch, + "{cwd:?}" + ); + } +} + +#[cfg(unix)] +#[test] +fn cwd_symlink_escape_is_rejected() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + write(outside.path(), "Cargo.toml", ""); + symlink(outside.path(), root.path().join("outside")).unwrap(); + assert_eq!( + resolve_project_recipe_root(root.path(), Some("outside"), None).unwrap_err(), + ProjectRecipeResolutionError::CwdMismatch + ); +} + +#[test] +fn explicit_python_keeps_manifestless_cwd_even_with_other_markers() { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), "Cargo.toml", ""); + write( + temp.path(), + "nested/calculator.py", + "def add(a, b): return a + b\n", + ); + let resolved = + resolve_project_recipe_root(temp.path(), Some("nested"), Some(ProjectRecipeId::Python)) + .unwrap(); + assert_eq!(resolved.recipe, ProjectRecipeId::Python); + assert_eq!(resolved.relative_root, "nested"); + assert_eq!(resolved.absolute_root, temp.path().join("nested")); +} + +#[test] +fn source_reads_anchor_relative_paths_to_execution_root() { + let temp = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + write(temp.path(), "Cargo.toml", "root-manifest\n"); + write(outside.path(), "Cargo.toml", "outside\n"); + let root = temp.path().canonicalize().unwrap(); + assert_eq!( + read_project_recipe_file(&root, Path::new("Cargo.toml")).unwrap(), + b"root-manifest\n" + ); + assert_eq!( + read_project_recipe_file(&root, &root.join("Cargo.toml")).unwrap(), + b"root-manifest\n" + ); + assert_eq!( + read_project_recipe_file(&root, &outside.path().join("Cargo.toml")).unwrap_err(), + ProjectRecipeResolutionError::SourceFileInvalid + ); + fs::create_dir(root.join("not-a-file")).unwrap(); + assert_eq!( + read_project_recipe_file(&root, Path::new("not-a-file")).unwrap_err(), + ProjectRecipeResolutionError::SourceFileInvalid + ); +} + +#[cfg(unix)] +#[test] +fn marker_symlink_is_detected_before_source_containment_rejects_it() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + write( + outside.path(), + "Cargo.toml", + "[package]\nname='outside'\nversion='0.1.0'\n", + ); + symlink( + outside.path().join("Cargo.toml"), + root.path().join("Cargo.toml"), + ) + .unwrap(); + + let resolved = resolve_project_recipe_root(root.path(), None, None).unwrap(); + assert_eq!(resolved.recipe, ProjectRecipeId::Rust); + assert_eq!( + read_project_recipe_file(&resolved.execution_root, &resolved.marker_path()).unwrap_err(), + ProjectRecipeResolutionError::SourceFileInvalid + ); +} + +#[test] +fn source_digest_preserves_path_length_content_order_and_skips_missing_optional_files() { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), "Cargo.toml", "manifest\n"); + write(temp.path(), "Cargo.lock", "lock\n"); + let root = temp.path().canonicalize().unwrap(); + + let actual = digest_project_recipe_files( + &root, + [ + Path::new("Cargo.toml").to_path_buf(), + Path::new("missing.lock").to_path_buf(), + Path::new("Cargo.lock").to_path_buf(), + ], + ) + .unwrap(); + + let mut hasher = Sha256::new(); + for (path, content) in [ + ("Cargo.toml", b"manifest\n".as_slice()), + ("Cargo.lock", b"lock\n".as_slice()), + ] { + hasher.update(Path::new(path).as_os_str().as_encoded_bytes()); + hasher.update((content.len() as u64).to_be_bytes()); + hasher.update(content); + } + assert_eq!(actual, format!("{:x}", hasher.finalize())); +} diff --git a/workspace-boundaries.toml b/workspace-boundaries.toml index bb838d055..0522b882a 100644 --- a/workspace-boundaries.toml +++ b/workspace-boundaries.toml @@ -216,6 +216,7 @@ normal = [ "webcodex-core", "webcodex-tool-contracts", "webcodex-workflow-session", + "webcodex-workspace", ] dev = [ "webcodex-tool-contracts",