diff --git a/src/bin/cargo/commands/locate_project.rs b/src/bin/cargo/commands/locate_project.rs index 49d6e6ca256..98d3ac968cc 100644 --- a/src/bin/cargo/commands/locate_project.rs +++ b/src/bin/cargo/commands/locate_project.rs @@ -27,6 +27,7 @@ pub struct ProjectLocation<'a> { pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult { let root_manifest; + let workspace_root; let workspace; let root = match WhatToFind::parse(args) { WhatToFind::CurrentManifest => { @@ -34,8 +35,19 @@ pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult { &root_manifest } WhatToFind::Workspace => { - workspace = args.workspace(gctx)?; - workspace.root_manifest() + root_manifest = args.root_manifest(gctx)?; + // Try fast path first - only works when package is explicitly listed in members + if let Some(ws_root) = + cargo::core::find_workspace_root_with_membership_check(&root_manifest, gctx)? + { + workspace_root = ws_root; + &workspace_root + } else { + // Fallback to full workspace loading for path dependency membership. + // If loading fails, we must propagate the error to avoid false results. + workspace = args.workspace(gctx)?; + workspace.root_manifest() + } } }; diff --git a/src/cargo/core/mod.rs b/src/cargo/core/mod.rs index 786883f2dd3..1b0da87d746 100644 --- a/src/cargo/core/mod.rs +++ b/src/cargo/core/mod.rs @@ -12,7 +12,7 @@ pub use self::source_id::SourceId; pub use self::summary::{FeatureMap, FeatureValue, Summary}; pub use self::workspace::{ MaybePackage, Workspace, WorkspaceConfig, WorkspaceRootConfig, find_workspace_root, - resolve_relative_path, + find_workspace_root_with_membership_check, resolve_relative_path, }; pub use cargo_util_schemas::core::{GitReference, PackageIdSpec, SourceKind}; diff --git a/src/cargo/core/workspace.rs b/src/cargo/core/workspace.rs index 7c5517c71df..b9ec68f7c52 100644 --- a/src/cargo/core/workspace.rs +++ b/src/cargo/core/workspace.rs @@ -2059,10 +2059,50 @@ impl WorkspaceRootConfig { !explicit_member && excluded } + /// Checks if the path is explicitly listed as a workspace member. + /// + /// Returns `true` ONLY if: + /// - The path is the workspace root manifest itself, or + /// - The path matches one of the explicit `members` patterns + /// + /// NOTE: This does NOT check for implicit path dependency membership. + /// A `false` return does NOT mean the package is definitely not a member - + /// it could still be a member via path dependencies. Callers should fallback + /// to full workspace loading when this returns `false`. + fn is_explicitly_listed_member(&self, manifest_path: &Path) -> bool { + let root_manifest = self.root_dir.join("Cargo.toml"); + if manifest_path == root_manifest { + return true; + } + match self.members { + Some(ref members) => { + // Use members_paths to properly expand glob patterns + let Ok(expanded_members) = self.members_paths(members) else { + return false; + }; + // Normalize the manifest path for comparison + let normalized_manifest = paths::normalize_path(manifest_path); + expanded_members.iter().any(|(member_path, _)| { + // Normalize the member path as glob expansion may leave ".." components + let normalized_member = paths::normalize_path(member_path); + // Compare the manifest's parent directory with the member path exactly + // instead of using starts_with to avoid matching nested directories + normalized_manifest.parent() == Some(normalized_member.as_path()) + }) + } + None => false, + } + } + fn has_members_list(&self) -> bool { self.members.is_some() } + /// Returns true if this workspace config has default-members defined. + fn has_default_members(&self) -> bool { + self.default_members.is_some() + } + /// Returns expanded paths along with the glob that they were expanded from. /// The glob is `None` if the path matched exactly. #[tracing::instrument(skip_all)] @@ -2157,6 +2197,66 @@ pub fn find_workspace_root( }) } +/// Finds the workspace root for a manifest, with minimal verification. +/// +/// This is similar to `find_workspace_root`, but additionally verifies that the +/// package and workspace agree on each other: +/// - If the package has an explicit `package.workspace` pointer, it is trusted +/// - Otherwise, the workspace must include the package in its `members` list +pub fn find_workspace_root_with_membership_check( + manifest_path: &Path, + gctx: &GlobalContext, +) -> CargoResult> { + let source_id = SourceId::for_manifest_path(manifest_path)?; + let current_manifest = read_manifest(manifest_path, source_id, gctx)?; + + match current_manifest.workspace_config() { + WorkspaceConfig::Root(root_config) => { + // This manifest is a workspace root itself + // If default-members are defined, fall back to full loading for proper validation + if root_config.has_default_members() { + Ok(None) + } else { + Ok(Some(manifest_path.to_path_buf())) + } + } + WorkspaceConfig::Member { + root: Some(path_to_root), + } => { + // Has explicit `package.workspace` pointer - verify the workspace agrees + let ws_manifest_path = read_root_pointer(manifest_path, path_to_root); + let ws_source_id = SourceId::for_manifest_path(&ws_manifest_path)?; + let ws_manifest = read_manifest(&ws_manifest_path, ws_source_id, gctx)?; + + // Verify the workspace includes this package in its members + if let WorkspaceConfig::Root(ref root_config) = *ws_manifest.workspace_config() { + if root_config.is_explicitly_listed_member(manifest_path) + && !root_config.is_excluded(manifest_path) + { + return Ok(Some(ws_manifest_path)); + } + } + // Workspace doesn't agree with the pointer - not a valid workspace root + Ok(None) + } + WorkspaceConfig::Member { root: None } => { + // No explicit pointer, walk up with membership validation + find_workspace_root_with_loader(manifest_path, gctx, |candidate_manifest_path| { + let source_id = SourceId::for_manifest_path(candidate_manifest_path)?; + let manifest = read_manifest(candidate_manifest_path, source_id, gctx)?; + if let WorkspaceConfig::Root(ref root_config) = *manifest.workspace_config() { + if root_config.is_explicitly_listed_member(manifest_path) + && !root_config.is_excluded(manifest_path) + { + return Ok(Some(candidate_manifest_path.to_path_buf())); + } + } + Ok(None) + }) + } + } +} + /// Finds the path of the root of the workspace. /// /// This uses a callback to determine if the given path tells us what the diff --git a/tests/testsuite/locate_project.rs b/tests/testsuite/locate_project.rs index 867f977f7d4..24365b9d6e1 100644 --- a/tests/testsuite/locate_project.rs +++ b/tests/testsuite/locate_project.rs @@ -126,3 +126,675 @@ fn workspace() { ) .run(); } + +#[cargo_test] +fn workspace_missing_member() { + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "root" + version = "0.0.0" + + [workspace] + members = ["missing_member"] + "#, + ) + .file("src/main.rs", "fn main() {}") + .build(); + + p.cargo("locate-project --workspace") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_nested_with_explicit_pointer() { + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "root" + version = "0.0.0" + + [workspace] + members = ["nested"] + "#, + ) + .file("src/lib.rs", "") + .file( + "nested/Cargo.toml", + r#" + [package] + name = "nested" + version = "0.0.0" + workspace = ".." + "#, + ) + .file("nested/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("nested") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_not_a_member() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["member"] + "#, + ) + .file( + "member/Cargo.toml", + r#" + [package] + name = "member" + version = "0.0.0" + "#, + ) + .file("member/src/lib.rs", "") + .file( + "not-member/Cargo.toml", + r#" + [package] + name = "not-member" + version = "0.0.0" + "#, + ) + .file("not-member/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("not-member") + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] current package believes it's in a workspace when it's not: +current: [ROOT]/foo/not-member/Cargo.toml +workspace: [ROOT]/foo/Cargo.toml + +this may be fixable by adding `not-member` to the `workspace.members` array of the manifest located at: [ROOT]/foo/Cargo.toml +Alternatively, to keep it out of the workspace, add the package to the `workspace.exclude` array, or add an empty `[workspace]` table to the package's manifest. + +"#]]) + .run(); + + p.cargo("locate-project --workspace") + .cwd("not-member/src") + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] current package believes it's in a workspace when it's not: +current: [ROOT]/foo/not-member/Cargo.toml +workspace: [ROOT]/foo/Cargo.toml + +this may be fixable by adding `not-member` to the `workspace.members` array of the manifest located at: [ROOT]/foo/Cargo.toml +Alternatively, to keep it out of the workspace, add the package to the `workspace.exclude` array, or add an empty `[workspace]` table to the package's manifest. + +"#]]) + .run(); +} + +#[cargo_test] +fn workspace_pointer_to_sibling_workspace() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["outer-member"] + "#, + ) + .file( + "outer-member/Cargo.toml", + r#" + [package] + name = "outer-member" + version = "0.0.0" + "#, + ) + .file("outer-member/src/lib.rs", "") + .file( + "sibling-workspace/Cargo.toml", + r#" + [workspace] + members = ["../pkg"] + "#, + ) + .file( + "pkg/Cargo.toml", + r#" + [package] + name = "pkg" + version = "0.0.0" + workspace = "../sibling-workspace" + "#, + ) + .file("pkg/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("pkg") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/sibling-workspace/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_member_in_both_members_and_exclude() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["pkg"] + exclude = ["pkg"] + "#, + ) + .file( + "pkg/Cargo.toml", + r#" + [package] + name = "pkg" + version = "0.0.0" + "#, + ) + .file("pkg/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("pkg") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_default_members_not_in_members() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = [] + default-members = ["pkg"] + "#, + ) + .file( + "pkg/Cargo.toml", + r#" + [package] + name = "pkg" + version = "0.0.0" + "#, + ) + .file("pkg/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package `[ROOT]/foo/pkg` is listed in default-members but is not a member +for workspace at `[ROOT]/foo/Cargo.toml`. + +"#]]) + .run(); +} + +#[cargo_test] +fn workspace_default_members_and_exclude() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["pkg", "other"] + default-members = ["pkg"] + exclude = ["pkg"] + "#, + ) + .file( + "pkg/Cargo.toml", + r#" + [package] + name = "pkg" + version = "0.0.0" + "#, + ) + .file("pkg/src/lib.rs", "") + .file( + "other/Cargo.toml", + r#" + [package] + name = "other" + version = "0.0.0" + "#, + ) + .file("other/src/lib.rs", "") + .build(); + + // pkg is in members, default-members, and exclude. + // Since it's in members, it's still a workspace member (member wins over exclude). + p.cargo("locate-project --workspace") + .cwd("pkg") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_member_with_own_workspace_invalid_default_members() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["pkg"] + "#, + ) + .file( + "pkg/Cargo.toml", + r#" + [package] + name = "pkg" + version = "0.0.0" + + [workspace] + default-members = ["nonexistent"] + "#, + ) + .file("pkg/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("pkg") + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package `[ROOT]/foo/pkg/nonexistent` is listed in default-members but is not a member +for workspace at `[ROOT]/foo/pkg/Cargo.toml`. + +"#]]) + .run(); +} + +#[cargo_test] +fn workspace_default_member_and_exclude_but_not_member() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["pkg-a"] + default-members = ["pkg-b"] + exclude = ["pkg-b"] + "#, + ) + .file( + "pkg-a/Cargo.toml", + r#" + [package] + name = "pkg-a" + version = "0.0.0" + "#, + ) + .file("pkg-a/src/lib.rs", "") + .file( + "pkg-b/Cargo.toml", + r#" + [package] + name = "pkg-b" + version = "0.0.0" + "#, + ) + .file("pkg-b/src/lib.rs", "") + .build(); + + // Should error because pkg-b is in default-members but not in members + // The exclude doesn't help since it's not in members either + p.cargo("locate-project --workspace") + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] package `[ROOT]/foo/pkg-b` is listed in default-members but is not a member +for workspace at `[ROOT]/foo/Cargo.toml`. + +"#]]) + .run(); +} + +#[cargo_test] +fn workspace_only_in_exclude() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["other"] + exclude = ["pkg"] + "#, + ) + .file( + "other/Cargo.toml", + r#" + [package] + name = "other" + version = "0.0.0" + "#, + ) + .file("other/src/lib.rs", "") + .file( + "pkg/Cargo.toml", + r#" + [package] + name = "pkg" + version = "0.0.0" + "#, + ) + .file("pkg/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("pkg") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/pkg/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_only_exclude_no_members() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + exclude = ["pkg"] + "#, + ) + .file( + "pkg/Cargo.toml", + r#" + [package] + name = "pkg" + version = "0.0.0" + "#, + ) + .file("pkg/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("pkg") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/pkg/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_glob_members() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["crates/*"] + "#, + ) + .file( + "crates/foo/Cargo.toml", + r#" + [package] + name = "foo" + version = "0.0.0" + "#, + ) + .file("crates/foo/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("crates/foo") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_glob_members_parent_path() { + let p = project() + .file( + "workspace/Cargo.toml", + r#" + [workspace] + members = ["../crates/*"] + "#, + ) + .file( + "crates/foo/Cargo.toml", + r#" + [package] + name = "foo" + version = "0.0.0" + workspace = "../../workspace" + "#, + ) + .file("crates/foo/src/lib.rs", "") + .file( + "crates/bar/Cargo.toml", + r#" + [package] + name = "bar" + version = "0.0.0" + workspace = "../../workspace" + "#, + ) + .file("crates/bar/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("crates/foo") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/workspace/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); + + p.cargo("locate-project --workspace") + .cwd("crates/bar") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/workspace/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_path_dependency_member() { + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "root" + version = "0.0.0" + + [workspace] + + [dependencies] + path-dep = { path = "path-dep" } + "#, + ) + .file("src/lib.rs", "") + .file( + "path-dep/Cargo.toml", + r#" + [package] + name = "path-dep" + version = "0.0.0" + "#, + ) + .file("path-dep/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("path-dep") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +} + +#[cargo_test] +fn workspace_nested_subdirectory_not_member() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["crate-a"] + "#, + ) + .file( + "crate-a/Cargo.toml", + r#" + [package] + name = "crate-a" + version = "0.0.0" + "#, + ) + .file("crate-a/src/lib.rs", "") + .file( + "crate-a/subcrate/Cargo.toml", + r#" + [package] + name = "subcrate" + version = "0.0.0" + "#, + ) + .file("crate-a/subcrate/src/lib.rs", "") + .build(); + + p.cargo("locate-project --workspace") + .cwd("crate-a/subcrate") + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] current package believes it's in a workspace when it's not: +current: [ROOT]/foo/crate-a/subcrate/Cargo.toml +workspace: [ROOT]/foo/Cargo.toml + +this may be fixable by adding `crate-a/subcrate` to the `workspace.members` array of the manifest located at: [ROOT]/foo/Cargo.toml +Alternatively, to keep it out of the workspace, add the package to the `workspace.exclude` array, or add an empty `[workspace]` table to the package's manifest. + +"#]]) + .run(); +} + +#[cargo_test] +fn nested_independent_workspace() { + let p = project() + .file( + "Cargo.toml", + r#" + [workspace] + members = ["member"] + "#, + ) + .file( + "member/Cargo.toml", + r#" + [package] + name = "member" + version = "0.0.0" + "#, + ) + .file("member/src/lib.rs", "") + .file( + "nested-ws/Cargo.toml", + r#" + [package] + name = "nested-ws" + version = "0.0.0" + + [workspace] + "#, + ) + .file("nested-ws/src/main.rs", "fn main() {}") + .build(); + + p.cargo("locate-project --workspace") + .cwd("nested-ws/src") + .with_stdout_data( + str![[r#" +{ + "root": "[ROOT]/foo/nested-ws/Cargo.toml" +} +"#]] + .is_json(), + ) + .run(); +}