diff --git a/Cargo.lock b/Cargo.lock index 89d550f2a..d2ad13cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5736,6 +5736,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "toml 0.8.23", "webcodex-core", ] diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs index 9066169f7..247b17260 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs @@ -6,18 +6,41 @@ use webcodex_core::runner_operation::{ #[test] fn project_build_rechecks_manifest_and_dependency_state_after_queueing() { - for (marker, dependency, change_dependency) in [ - ("Cargo.toml", "Cargo.lock", false), - ("Cargo.toml", "Cargo.lock", true), - ("go.mod", "go.sum", false), - ("go.mod", "go.sum", true), + for (marker, dependency, change_dependency, member_cwd) in [ + ("Cargo.toml", "Cargo.lock", false, false), + ("Cargo.toml", "Cargo.lock", true, false), + ("go.mod", "go.sum", false, false), + ("go.mod", "go.sum", true, false), + ("Cargo.toml", "Cargo.lock", false, true), + ("Cargo.toml", "Cargo.lock", true, true), ] { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); let registry = temp.path().join("registry"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(®istry).unwrap(); - std::fs::write(root.join(marker), "").unwrap(); + if member_cwd { + std::fs::create_dir_all(root.join("member")).unwrap(); + std::fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["member"] +resolver = "2" +"#, + ) + .unwrap(); + std::fs::write( + root.join("member/Cargo.toml"), + r#"[package] +name = "member" +version = "0.1.0" +edition = "2021" +"#, + ) + .unwrap(); + } else { + std::fs::write(root.join(marker), "").unwrap(); + } std::fs::write(root.join(dependency), "").unwrap(); std::fs::write( registry.join("demo.toml"), @@ -33,7 +56,7 @@ fn project_build_rechecks_manifest_and_dependency_state_after_queueing() { }; let request = ProjectBuildRequest { project_id: "demo".into(), - cwd: None, + cwd: member_cwd.then(|| "member".into()), adapter: ProjectBuildAdapter::Auto, scope: None, }; @@ -41,7 +64,7 @@ fn project_build_rechecks_manifest_and_dependency_state_after_queueing() { crate::webcodex_runner::project_build::plan(&policy, ®istry, &request).unwrap(); let mut context = structured_process_context(&cwd, plan.process.args.len(), false); context.runtime_project_id = Some("agent:structured-agent:demo".into()); - context.project_cwd = Some(".".into()); + context.project_cwd = Some(if member_cwd { "member" } else { "." }.into()); context.purpose = Some("build".into()); context .structured_execution @@ -111,18 +134,21 @@ fn project_build_rechecks_manifest_and_dependency_state_after_queueing() { .slot_reserved; manager.stop_all(); assert!(finished, "queued build worker did not terminate"); - assert_eq!(result.status, "failed", "{marker}/{changed}: {result:?}"); + assert_eq!( + result.status, "failed", + "{marker}/{changed}/member={member_cwd}: {result:?}" + ); assert_eq!( result.command_execution_state, Some(ShellCommandExecutionState::NotStarted), - "{marker}/{changed}: {result:?}" + "{marker}/{changed}/member={member_cwd}: {result:?}" ); assert!( result .error .as_deref() .is_some_and(|error| error.contains("build_plan_stale")), - "{marker}/{changed}: {result:?}" + "{marker}/{changed}/member={member_cwd}: {result:?}" ); assert!(!reserved, "rejected build retained the execution slot"); assert!(!root.join("target").exists()); diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs index 5f189ef45..e1f0e162f 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs @@ -9,18 +9,41 @@ use webcodex_core::runner_protocol::{ShellJobContext, ShellJobValidationMetadata #[test] fn project_validation_rechecks_manifest_and_dependency_state_after_queueing() { - for (marker, dependency, change_dependency) in [ - ("Cargo.toml", "Cargo.lock", false), - ("Cargo.toml", "Cargo.lock", true), - ("go.mod", "go.sum", false), - ("go.mod", "go.sum", true), + for (marker, dependency, change_dependency, member_cwd) in [ + ("Cargo.toml", "Cargo.lock", false, false), + ("Cargo.toml", "Cargo.lock", true, false), + ("go.mod", "go.sum", false, false), + ("go.mod", "go.sum", true, false), + ("Cargo.toml", "Cargo.lock", false, true), + ("Cargo.toml", "Cargo.lock", true, true), ] { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); let registry = temp.path().join("registry"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(®istry).unwrap(); - std::fs::write(root.join(marker), "").unwrap(); + if member_cwd { + std::fs::create_dir_all(root.join("member")).unwrap(); + std::fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["member"] +resolver = "2" +"#, + ) + .unwrap(); + std::fs::write( + root.join("member/Cargo.toml"), + r#"[package] +name = "member" +version = "0.1.0" +edition = "2021" +"#, + ) + .unwrap(); + } else { + std::fs::write(root.join(marker), "").unwrap(); + } std::fs::write(root.join(dependency), "").unwrap(); std::fs::write( registry.join("demo.toml"), @@ -37,7 +60,7 @@ fn project_validation_rechecks_manifest_and_dependency_state_after_queueing() { }; let request = ProjectValidationRequest { project_id: "demo".into(), - cwd: None, + cwd: member_cwd.then(|| "member".into()), action: ProjectValidationAction::Check, adapter: ProjectValidationAdapter::Auto, scope: None, @@ -68,7 +91,7 @@ fn project_validation_rechecks_manifest_and_dependency_state_after_queueing() { validation: Some(metadata), workflow_session_id: None, ssh_resource: None, - project_cwd: Some(".".into()), + project_cwd: Some(if member_cwd { "member" } else { "." }.into()), cwd: Some(cwd.to_string_lossy().into_owned()), purpose: Some("validation".into()), shell: None, @@ -143,18 +166,21 @@ fn project_validation_rechecks_manifest_and_dependency_state_after_queueing() { .slot_reserved; manager.stop_all(); - assert_eq!(result.status, "failed", "{marker}/{changed}: {result:?}"); + assert_eq!( + result.status, "failed", + "{marker}/{changed}/member={member_cwd}: {result:?}" + ); assert_eq!( result.command_execution_state, Some(ShellCommandExecutionState::NotStarted), - "{marker}/{changed}: {result:?}" + "{marker}/{changed}/member={member_cwd}: {result:?}" ); assert!( result .error .as_deref() .is_some_and(|error| error.contains("validation_plan_stale")), - "{marker}/{changed}: {result:?}" + "{marker}/{changed}/member={member_cwd}: {result:?}" ); assert!(!reserved, "rejected validation retained the execution slot"); assert!(lock_unpoison(&manager.queued).is_empty()); diff --git a/crates/webcodex-runner/src/webcodex_runner/project_build.rs b/crates/webcodex-runner/src/webcodex_runner/project_build.rs index 4f4748606..b41c3e2eb 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_build.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_build.rs @@ -12,8 +12,8 @@ use webcodex_core::project_build::{ }; use webcodex_core::runner_operation::RunnerJobOperation; use webcodex_workspace::project_recipe::{ - digest_project_recipe_files, project_recipe_dependency_state_files, - resolve_project_recipe_root, ProjectRecipeId, ProjectRecipeResolutionError, + digest_project_recipe_files, project_recipe_provenance_files, resolve_project_recipe_root, + ProjectRecipeId, ProjectRecipeResolutionError, }; fn unavailable(code: &str, backend: Option<&str>) -> ProjectBuildPlanningResult { @@ -79,16 +79,12 @@ pub(crate) fn plan( let process = canonical_project_build_process(backend.as_str(), request) .map_err(|_| unavailable("build_scope_invalid", Some(backend.as_str())))?; - let manifest_digest = digest_project_recipe_files( - &resolved.execution_root, - std::iter::once(resolved.marker_path()).chain( - project_recipe_dependency_state_files(backend) - .expect("project build supports only Rust/Go") - .iter() - .map(|file| resolved.absolute_root.join(file)), - ), - ) - .map_err(|error| unavailable(map_recipe_error(error), Some(backend.as_str())))?; + let provenance_files = project_recipe_provenance_files(&resolved) + .map_err(|error| unavailable(map_recipe_error(error), Some(backend.as_str())))? + .expect("project build supports only Rust/Go"); + let manifest_digest = + digest_project_recipe_files(&resolved.execution_root, provenance_files) + .map_err(|error| unavailable(map_recipe_error(error), Some(backend.as_str())))?; let plan = ProjectBuildPlan { provenance: ProjectBuildProvenance { diff --git a/crates/webcodex-validation/src/recipe.rs b/crates/webcodex-validation/src/recipe.rs index d4948817f..7168861d8 100644 --- a/crates/webcodex-validation/src/recipe.rs +++ b/crates/webcodex-validation/src/recipe.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::Path; use webcodex_core::runner_protocol::{normalize_rust_test_filter, ShellJobValidationStep}; use webcodex_workspace::project_recipe::{ - digest_project_recipe_files, project_recipe_dependency_state_files, read_project_recipe_file, + digest_project_recipe_files, project_recipe_provenance_files, read_project_recipe_file, resolve_project_recipe_root, ProjectRecipeResolutionError, }; @@ -166,22 +166,40 @@ pub fn resolve_validation_recipe_with_packages( } else { let manifest = read_project_recipe_file(root, &marker_path).map_err(map_project_recipe_error)?; - let (steps, extra_digest_files) = match recipe { + let (steps, provenance_files) = match recipe { RecipeId::Rust | RecipeId::Go => { - canonical_adapter_steps(recipe, checks, test_filter.as_deref(), package_scope)? + let steps = + canonical_adapter_steps(recipe, checks, test_filter.as_deref(), package_scope)?; + let provenance_files = project_recipe_provenance_files(&resolved_root) + .map_err(map_project_recipe_error)? + .expect("canonical project validation adapters are Rust/Go"); + (steps, provenance_files) + } + RecipeId::Node => { + let (steps, extra_digest_files) = node_steps(&recipe_root, &manifest, checks)?; + let provenance_files = std::iter::once(marker_path.clone()) + .chain( + extra_digest_files + .into_iter() + .map(|file| recipe_root.join(file)), + ) + .collect(); + (steps, provenance_files) + } + RecipeId::Python => { + let (steps, extra_digest_files) = python_steps(&manifest, checks)?; + let provenance_files = std::iter::once(marker_path.clone()) + .chain( + extra_digest_files + .into_iter() + .map(|file| recipe_root.join(file)), + ) + .collect(); + (steps, provenance_files) } - RecipeId::Node => node_steps(&recipe_root, &manifest, checks)?, - RecipeId::Python => python_steps(&manifest, checks)?, }; - 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)?; + let manifest_digest = digest_project_recipe_files(root, provenance_files) + .map_err(map_project_recipe_error)?; (steps, manifest_digest) }; let invocation_digest = format!( @@ -209,7 +227,7 @@ fn canonical_adapter_steps( checks: &[SemanticCheck], test_filter: Option<&str>, package_scope: Option<&[String]>, -) -> Result<(Vec, Vec<&'static str>), RecipeError> { +) -> Result, RecipeError> { let mut steps = Vec::with_capacity(checks.len()); for check in checks { let operation = crate::project_validation_operation( @@ -240,10 +258,7 @@ fn canonical_adapter_steps( })?; steps.push(plan.structured_step); } - let extra_digest_files = project_recipe_dependency_state_files(recipe) - .expect("canonical project validation adapters are Rust/Go") - .to_vec(); - Ok((steps, extra_digest_files)) + Ok(steps) } fn node_steps( diff --git a/crates/webcodex-validation/src/recipe_tests.rs b/crates/webcodex-validation/src/recipe_tests.rs index fdcfe6253..2346fa69a 100644 --- a/crates/webcodex-validation/src/recipe_tests.rs +++ b/crates/webcodex-validation/src/recipe_tests.rs @@ -563,6 +563,66 @@ fn project_recipe_filtered_execution_is_deterministic_without_repeating_arg_buil } } +#[test] +fn rust_member_manifest_digest_tracks_workspace_root_inputs() { + let temp = tempfile::tempdir().unwrap(); + write( + temp.path(), + "Cargo.toml", + "[workspace]\nmembers=['member']\nresolver='2'\n", + ); + write(temp.path(), "Cargo.lock", "version = 4\n"); + write( + temp.path(), + "member/Cargo.toml", + "[package]\nname='member'\nversion='0.1.0'\nedition='2021'\n", + ); + + let resolve_member = || { + resolve( + temp.path(), + Some("member"), + Some(RecipeId::Rust), + &[SemanticCheck::Check], + None, + ) + .unwrap() + }; + let original = resolve_member(); + assert_eq!(original.recipe_root_relative, "member"); + assert_eq!(original.steps[0].args, ["check", "--all-targets"]); + + write( + temp.path(), + "member/src/lib.rs", + "pub fn value() -> u8 { 1 }\n", + ); + let source_only = resolve_member(); + assert_eq!(source_only.manifest_digest, original.manifest_digest); + assert_eq!(source_only.invocation_digest, original.invocation_digest); + + write(temp.path(), "Cargo.lock", "version = 3\n"); + let lock_changed = resolve_member(); + assert_ne!(lock_changed.manifest_digest, original.manifest_digest); + assert_eq!(lock_changed.invocation_digest, original.invocation_digest); + + write(temp.path(), "Cargo.lock", "version = 4\n"); + write( + temp.path(), + "Cargo.toml", + "[workspace]\nmembers=['member']\nresolver='2'\n[profile.dev]\nopt-level=1\n", + ); + let workspace_manifest_changed = resolve_member(); + assert_ne!( + workspace_manifest_changed.manifest_digest, + original.manifest_digest + ); + assert_eq!( + workspace_manifest_changed.invocation_digest, + original.invocation_digest + ); +} + #[test] fn unavailable_checks_and_unsupported_filters_fail_before_execution() { let go = tempfile::tempdir().unwrap(); diff --git a/crates/webcodex-workspace/Cargo.toml b/crates/webcodex-workspace/Cargo.toml index 91a265a6b..876e5f60d 100644 --- a/crates/webcodex-workspace/Cargo.toml +++ b/crates/webcodex-workspace/Cargo.toml @@ -13,6 +13,7 @@ webcodex-core = { path = "../webcodex-core" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +toml = "0.8" [dev-dependencies] tempfile = "3" diff --git a/crates/webcodex-workspace/src/project_recipe.rs b/crates/webcodex-workspace/src/project_recipe.rs index 1d82bc7b5..941908ab1 100644 --- a/crates/webcodex-workspace/src/project_recipe.rs +++ b/crates/webcodex-workspace/src/project_recipe.rs @@ -51,17 +51,30 @@ impl ResolvedProjectRecipeRoot { } } -/// Canonical optional dependency-state files for the portable Rust/Go recipe -/// consumers. Missing files remain valid and are skipped by the digest helper. -/// Node/Python intentionally return None because their dependency/package-manager -/// provenance is not represented by this narrow source-truth contract. -pub fn project_recipe_dependency_state_files( - recipe: ProjectRecipeId, -) -> Option<&'static [&'static str]> { - match recipe { - ProjectRecipeId::Rust => Some(&["Cargo.lock"]), - ProjectRecipeId::Go => Some(&["go.sum"]), - ProjectRecipeId::Node | ProjectRecipeId::Python => None, +/// Canonical manifest/dependency inputs for portable Rust/Go project-operation +/// provenance. Paths stay inside the registered execution root. Rust members +/// include the effective Cargo workspace manifest and root lockfile while their +/// native execution cwd remains the nearest package/recipe root. +pub fn project_recipe_provenance_files( + resolved: &ResolvedProjectRecipeRoot, +) -> Result>, ProjectRecipeResolutionError> { + match resolved.recipe { + ProjectRecipeId::Rust => { + let workspace_root = rust_workspace_root(resolved)?; + let marker_path = resolved.marker_path(); + let workspace_manifest = workspace_root.join("Cargo.toml"); + let mut files = vec![marker_path.clone()]; + if workspace_manifest != marker_path { + files.push(workspace_manifest); + } + files.push(workspace_root.join("Cargo.lock")); + Ok(Some(files)) + } + ProjectRecipeId::Go => Ok(Some(vec![ + resolved.marker_path(), + resolved.absolute_root.join("go.sum"), + ])), + ProjectRecipeId::Node | ProjectRecipeId::Python => Ok(None), } } @@ -104,6 +117,192 @@ pub fn resolve_project_recipe_root( }) } +fn rust_workspace_root( + resolved: &ResolvedProjectRecipeRoot, +) -> Result { + debug_assert_eq!(resolved.recipe, ProjectRecipeId::Rust); + let member_manifest = parse_cargo_manifest(&resolved.execution_root, &resolved.marker_path())?; + if cargo_manifest_has_workspace(&member_manifest)? { + return Ok(resolved.absolute_root.clone()); + } + + if let Some(workspace) = cargo_manifest_workspace_path(&member_manifest)? { + let workspace_root = resolved + .absolute_root + .join(workspace) + .canonicalize() + .map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid)?; + if !workspace_root.starts_with(&resolved.execution_root) || !workspace_root.is_dir() { + return Err(ProjectRecipeResolutionError::SourceFileInvalid); + } + let workspace_manifest = + parse_cargo_manifest(&resolved.execution_root, &workspace_root.join("Cargo.toml"))?; + if !cargo_manifest_has_workspace(&workspace_manifest)? { + return Err(ProjectRecipeResolutionError::SourceFileInvalid); + } + return Ok(workspace_root); + } + + let mut directory = resolved.absolute_root.clone(); + while directory != resolved.execution_root { + let Some(parent) = directory.parent() else { + break; + }; + if !parent.starts_with(&resolved.execution_root) { + break; + } + directory = parent.to_path_buf(); + let manifest_path = directory.join("Cargo.toml"); + if !manifest_path.is_file() { + continue; + } + let manifest = parse_cargo_manifest(&resolved.execution_root, &manifest_path)?; + if cargo_manifest_has_workspace(&manifest)? { + return Ok(directory); + } + } + // Cargo workspace inheritance is authoritative evidence that this package + // is not standalone. If no workspace root was found inside the registered + // Project boundary, do not silently hash a package-local lockfile that + // Cargo itself will not use; the caller must register the containing + // workspace instead. + if cargo_manifest_uses_workspace_inheritance(&member_manifest)? { + return Err(ProjectRecipeResolutionError::SourceFileInvalid); + } + Ok(resolved.absolute_root.clone()) +} + +fn cargo_manifest_uses_workspace_inheritance( + manifest: &toml::Value, +) -> Result { + let root = manifest + .as_table() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)?; + + if let Some(package) = root.get("package") { + let package = package + .as_table() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)?; + for key in [ + "authors", + "categories", + "description", + "documentation", + "edition", + "exclude", + "homepage", + "include", + "keywords", + "license", + "license-file", + "publish", + "readme", + "repository", + "rust-version", + "version", + ] { + if package + .get(key) + .and_then(toml::Value::as_table) + .and_then(|table| table.get("workspace")) + .and_then(toml::Value::as_bool) + == Some(true) + { + return Ok(true); + } + } + } + + if let Some(lints) = root.get("lints") { + let lints = lints + .as_table() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)?; + if lints.get("workspace").and_then(toml::Value::as_bool) == Some(true) { + return Ok(true); + } + } + + for key in ["dependencies", "dev-dependencies", "build-dependencies"] { + if cargo_dependency_table_uses_workspace(root.get(key))? { + return Ok(true); + } + } + + if let Some(targets) = root.get("target") { + let targets = targets + .as_table() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)?; + for target in targets.values() { + let target = target + .as_table() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)?; + for key in ["dependencies", "dev-dependencies", "build-dependencies"] { + if cargo_dependency_table_uses_workspace(target.get(key))? { + return Ok(true); + } + } + } + } + + Ok(false) +} + +fn cargo_dependency_table_uses_workspace( + value: Option<&toml::Value>, +) -> Result { + let Some(value) = value else { + return Ok(false); + }; + let dependencies = value + .as_table() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)?; + Ok(dependencies.values().any(|dependency| { + dependency + .as_table() + .and_then(|table| table.get("workspace")) + .and_then(toml::Value::as_bool) + == Some(true) + })) +} + +fn parse_cargo_manifest( + execution_root: &Path, + path: &Path, +) -> Result { + let bytes = read_project_recipe_file(execution_root, path)?; + let text = + std::str::from_utf8(&bytes).map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid)?; + toml::from_str(text).map_err(|_| ProjectRecipeResolutionError::SourceFileInvalid) +} + +fn cargo_manifest_has_workspace( + manifest: &toml::Value, +) -> Result { + match manifest.get("workspace") { + None => Ok(false), + Some(value) if value.is_table() => Ok(true), + Some(_) => Err(ProjectRecipeResolutionError::SourceFileInvalid), + } +} + +fn cargo_manifest_workspace_path<'a>( + manifest: &'a toml::Value, +) -> Result, ProjectRecipeResolutionError> { + let Some(package) = manifest.get("package") else { + return Ok(None); + }; + let package = package + .as_table() + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid)?; + let Some(workspace) = package.get("workspace") else { + return Ok(None); + }; + workspace + .as_str() + .map(Some) + .ok_or(ProjectRecipeResolutionError::SourceFileInvalid) +} + fn resolve_cwd(root: &Path, raw: Option<&str>) -> Result { let raw = raw.unwrap_or("."); let path = Path::new(raw); diff --git a/crates/webcodex-workspace/src/project_recipe_tests.rs b/crates/webcodex-workspace/src/project_recipe_tests.rs index 91aac5dc2..7903385d6 100644 --- a/crates/webcodex-workspace/src/project_recipe_tests.rs +++ b/crates/webcodex-workspace/src/project_recipe_tests.rs @@ -217,15 +217,151 @@ fn source_digest_preserves_path_length_content_order_and_skips_missing_optional_ } #[test] -fn project_recipe_dependency_state_files_are_narrow_rust_go_source_truth() { +fn project_recipe_provenance_files_preserve_flat_rust_go_source_truth() { + for (marker, manifest, recipe, dependency) in [ + ( + "Cargo.toml", + "[package]\nname='demo'\nversion='0.1.0'\n", + ProjectRecipeId::Rust, + "Cargo.lock", + ), + ( + "go.mod", + "module example.test/demo\n", + ProjectRecipeId::Go, + "go.sum", + ), + ] { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), marker, manifest); + let resolved = resolve_project_recipe_root(temp.path(), None, Some(recipe)).unwrap(); + let root = temp.path().canonicalize().unwrap(); + assert_eq!( + project_recipe_provenance_files(&resolved).unwrap().unwrap(), + [root.join(marker), root.join(dependency)] + ); + } + + for (marker, recipe) in [ + ("package.json", ProjectRecipeId::Node), + ("pyproject.toml", ProjectRecipeId::Python), + ] { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), marker, ""); + let resolved = resolve_project_recipe_root(temp.path(), None, Some(recipe)).unwrap(); + assert!(project_recipe_provenance_files(&resolved) + .unwrap() + .is_none()); + } +} + +#[test] +fn rust_member_provenance_includes_workspace_manifest_and_root_lockfile() { + let temp = tempfile::tempdir().unwrap(); + write( + temp.path(), + "Cargo.toml", + "[workspace]\nmembers=['member']\nresolver='2'\n[workspace.package]\nversion='0.1.0'\nedition='2021'\n", + ); + write( + temp.path(), + "member/Cargo.toml", + "[package]\nname='member'\nversion.workspace=true\nedition.workspace=true\n", + ); + let resolved = + resolve_project_recipe_root(temp.path(), Some("member"), Some(ProjectRecipeId::Rust)) + .unwrap(); + let root = temp.path().canonicalize().unwrap(); + + assert_eq!(resolved.absolute_root, root.join("member")); assert_eq!( - project_recipe_dependency_state_files(ProjectRecipeId::Rust), - Some(&["Cargo.lock"][..]) + project_recipe_provenance_files(&resolved).unwrap().unwrap(), + [ + root.join("member/Cargo.toml"), + root.join("Cargo.toml"), + root.join("Cargo.lock"), + ] ); +} + +#[test] +fn rust_member_workspace_inheritance_cannot_silently_escape_project_boundary() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("member"); + write( + &project, + "Cargo.toml", + "[package]\nname='member'\nversion.workspace=true\nedition.workspace=true\n[dependencies]\nserde.workspace=true\n", + ); + let resolved = + resolve_project_recipe_root(&project, None, Some(ProjectRecipeId::Rust)).unwrap(); + assert_eq!( - project_recipe_dependency_state_files(ProjectRecipeId::Go), - Some(&["go.sum"][..]) + project_recipe_provenance_files(&resolved).unwrap_err(), + ProjectRecipeResolutionError::SourceFileInvalid + ); + + // Arbitrary package metadata is not Cargo workspace inheritance. + write( + &project, + "Cargo.toml", + "[package]\nname='member'\nversion='0.1.0'\nedition='2021'\n[package.metadata]\nworkspace=true\n", + ); + let standalone = + resolve_project_recipe_root(&project, None, Some(ProjectRecipeId::Rust)).unwrap(); + assert!(project_recipe_provenance_files(&standalone).is_ok()); +} + +#[test] +fn rust_member_explicit_workspace_path_selects_contained_workspace_sources() { + let temp = tempfile::tempdir().unwrap(); + write( + temp.path(), + "workspace/Cargo.toml", + "[workspace]\nmembers=['../member']\nresolver='2'\n", + ); + write( + temp.path(), + "member/Cargo.toml", + "[package]\nname='member'\nversion='0.1.0'\nworkspace='../workspace'\n", + ); + let resolved = + resolve_project_recipe_root(temp.path(), Some("member"), Some(ProjectRecipeId::Rust)) + .unwrap(); + let root = temp.path().canonicalize().unwrap(); + + assert_eq!( + project_recipe_provenance_files(&resolved).unwrap().unwrap(), + [ + root.join("member/Cargo.toml"), + root.join("workspace/Cargo.toml"), + root.join("workspace/Cargo.lock"), + ] + ); +} + +#[test] +fn rust_member_explicit_workspace_path_cannot_escape_execution_root() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&outside).unwrap(); + write( + &project, + "member/Cargo.toml", + "[package]\nname='member'\nversion='0.1.0'\nworkspace='../../outside'\n", + ); + write( + &outside, + "Cargo.toml", + "[workspace]\nmembers=['../project/member']\nresolver='2'\n", + ); + let resolved = + resolve_project_recipe_root(&project, Some("member"), Some(ProjectRecipeId::Rust)).unwrap(); + + assert_eq!( + project_recipe_provenance_files(&resolved).unwrap_err(), + ProjectRecipeResolutionError::SourceFileInvalid ); - assert!(project_recipe_dependency_state_files(ProjectRecipeId::Node).is_none()); - assert!(project_recipe_dependency_state_files(ProjectRecipeId::Python).is_none()); }