Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/webcodex-validation/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
208 changes: 46 additions & 162 deletions crates/webcodex-validation/src/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand All @@ -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)]
Expand Down Expand Up @@ -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>,
Expand All @@ -137,17 +138,16 @@ pub fn resolve_validation_recipe_with_packages(
test_filter: Option<&str>,
package_scope: Option<&[String]>,
) -> Result<ResolvedValidationRecipe, RecipeError> {
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,
Expand All @@ -164,22 +164,24 @@ 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)?
}
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!(
Expand All @@ -200,83 +202,8 @@ pub fn resolve_validation_recipe_with_packages(
})
}

fn resolve_cwd(root: &Path, raw: Option<&str>) -> Result<PathBuf, RecipeError> {
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<RecipeId>,
) -> 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::<Vec<_>>();
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],
Expand Down Expand Up @@ -515,47 +442,6 @@ fn reject_filter(filter: Option<&str>) -> Result<(), RecipeError> {
}
}

fn read_manifest(execution_root: &Path, path: &Path) -> Result<Vec<u8>, 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<I>(execution_root: &Path, paths: I) -> Result<String, RecipeError>
where
I: IntoIterator<Item = PathBuf>,
{
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")
}
Expand All @@ -576,9 +462,7 @@ pub fn detect_validation_recipe(
cwd: Option<&str>,
hint: Option<RecipeId>,
) -> Result<RecipeId, RecipeError> {
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)
}
33 changes: 33 additions & 0 deletions crates/webcodex-validation/src/recipe_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 4 additions & 0 deletions crates/webcodex-workspace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading