From d268020997385673ef54ff811fdba1d127e97110 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 10:20:43 -0700 Subject: [PATCH 01/10] feat: forward Cargo namespace commands Implements [[tasks/meta-cargo-command-forwarding]] --- Cargo.toml | 4 +- src/lib.rs | 662 +++++++++++++++++++++++++++++++++++++------ src/main.rs | 40 +-- tests/windows_cmd.rs | 143 ++++++++++ 4 files changed, 726 insertions(+), 123 deletions(-) create mode 100644 tests/windows_cmd.rs diff --git a/Cargo.toml b/Cargo.toml index 28c1d82..1afcfb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ anyhow = "1" colored = "2" serde_json = "1" indexmap = "2" +shell-escape = "0.1" [dev-dependencies] -tempfile = "3" \ No newline at end of file +shell-words = "1" +tempfile = "3" diff --git a/src/lib.rs b/src/lib.rs index 9c62a16..ba2047a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,54 +2,227 @@ //! //! Provides Rust/Cargo commands for meta repositories. +use indexmap::IndexMap; pub use meta_plugin_protocol::{ - output_execution_plan, CommandResult, ExecutionPlan, PlanResponse, PlannedCommand, + output_execution_plan, CommandResult, ExecutionPlan, PlanResponse, PlannedCommand, PluginHelp, }; -use std::path::Path; +#[cfg(not(windows))] +use std::borrow::Cow; +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; -/// Get all project directories from .meta config (including root ".") -/// If provided_projects is not empty, uses that list instead (for --recursive support) +/// Normalize project paths without requiring them to exist. +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + } + } + + normalized +} + +/// Normalize and deduplicate paths while preserving their first-seen order. +fn normalize_project_directories(paths: &[String], cwd: &Path) -> Vec { + let base = if cwd.is_absolute() { + cwd.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(cwd) + }; + let mut seen = HashSet::new(); + + paths + .iter() + .filter_map(|path| { + let path = Path::new(path); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + // Resolve the original path before lexically collapsing `..`. + // Filesystem traversal through `symlink/..` is not necessarily + // equivalent to removing both components textually. + let normalized = std::fs::canonicalize(&absolute).unwrap_or_else(|_| { + let lexical = normalize_path(&absolute); + std::fs::canonicalize(&lexical).unwrap_or(lexical) + }); + + if seen.insert(normalized.clone()) { + Some(normalized.to_string_lossy().into_owned()) + } else { + None + } + }) + .collect() +} + +/// Get normalized project directories from the host or local Meta config. +/// +/// If `provided_projects` is non-empty, that list is authoritative. fn get_project_directories( provided_projects: &[String], cwd: &Path, ) -> anyhow::Result> { - // If we have provided projects from meta_cli (e.g., when --recursive is used), use them + // A host-supplied project list already includes the Meta root and reflects + // recursion, worktree selection, and tag filtering. Treat it as authoritative; + // include/exclude filters are applied below before Rust-project detection. if !provided_projects.is_empty() { - // Include root "." plus all provided project paths - let mut dirs = vec![".".to_string()]; - for p in provided_projects { - dirs.push(p.clone()); - } - return Ok(dirs); + return Ok(normalize_project_directories(provided_projects, cwd)); } // Use canonical config parsing (supports JSON + YAML) let tree = match meta_core::config::walk_meta_tree(cwd, Some(0)) { Ok(t) => t, - Err(_) => return Ok(vec![".".to_string()]), + Err(_) => { + return Ok(normalize_project_directories(&[".".to_string()], cwd)); + } }; let mut dirs = vec![".".to_string()]; let mut paths: Vec = tree.iter().map(|n| n.info.path.clone()).collect(); paths.sort(); dirs.extend(paths); - Ok(dirs) + Ok(normalize_project_directories(&dirs, cwd)) } /// Filter directories to only those with Cargo.toml -fn filter_rust_projects(dirs: &[String], cwd: &Path) -> Vec { +fn filter_rust_projects(dirs: &[String]) -> Vec { dirs.iter() - .filter(|dir| { - let cargo_path = if *dir == "." { - cwd.join("Cargo.toml") - } else { - cwd.join(dir).join("Cargo.toml") - }; - cargo_path.exists() - }) + .filter(|dir| Path::new(dir).join("Cargo.toml").is_file()) .cloned() .collect() } +/// Apply the host's directory selection before deciding whether the scope has +/// any Rust projects. loop_lib applies the same filters during execution, but +/// planning must see the selected scope to produce a clear empty result. +fn filter_selected_projects( + dirs: &[String], + include_filters: Option<&[String]>, + exclude_filters: Option<&[String]>, +) -> Vec { + let mut selected = dirs.to_vec(); + + if let Some(includes) = include_filters.filter(|filters| !filters.is_empty()) { + selected.retain(|path| includes.iter().any(|filter| path.contains(filter))); + } + + if let Some(excludes) = exclude_filters.filter(|filters| !filters.is_empty()) { + selected.retain(|path| { + !excludes + .iter() + .map(|filter| filter.trim_end_matches('/')) + .any(|filter| path.contains(filter)) + }); + } + + selected +} + +fn help_requested(args: &[String]) -> bool { + args.iter() + .take_while(|arg| arg.as_str() != "--") + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) +} + +#[cfg(any(windows, test))] +const WINDOWS_NEWLINE_ERROR: &str = + "Cargo arguments containing carriage returns or newlines cannot be transported safely through cmd.exe"; +#[cfg(any(windows, test))] +const WINDOWS_NUL_ERROR: &str = + "Cargo arguments containing NUL bytes cannot be transported through cmd.exe"; + +#[cfg(any(windows, test))] +fn quote_windows_cmd_token(token: &str) -> Result { + // This follows Rust's hardened batch-argument encoding. cmd.exe does not + // understand the CRT-style `\\\"` quote emitted by generic Windows argv + // serializers, so embedded quotes must instead be doubled. Quote a broad + // denylist of ASCII syntax to keep cmd metacharacters inside the argument. + // + // cmd has no direct escape for a literal `%` on a `/C` command line. The + // zero-length `%cd:~,%` expansion prevents the two user-supplied percent + // signs in `%NAME%` from ever forming an environment-variable reference. + // Command extensions are enabled by default for a fresh cmd.exe process. + if token.contains('\0') { + return Err(WINDOWS_NUL_ERROR); + } + if token.contains(['\r', '\n']) { + return Err(WINDOWS_NEWLINE_ERROR); + } + + const UNQUOTED: &str = r"#$*+-./:?@\_"; + let quote = token.is_empty() + || token.ends_with('\\') + || token.chars().any(|ch| { + let ascii_needs_quotes = + ch.is_ascii() && !(ch.is_ascii_alphanumeric() || UNQUOTED.contains(ch)); + ascii_needs_quotes || ch.is_control() + }); + + let mut escaped = String::with_capacity(token.len() + 2); + if quote { + escaped.push('"'); + } + + let mut backslashes = 0; + for ch in token.chars() { + if ch == '\\' { + backslashes += 1; + escaped.push(ch); + continue; + } + + if ch == '"' { + // Add n backslashes to the n already emitted, then add the first + // of a doubled quote pair. The ordinary push below adds the second. + escaped.extend(std::iter::repeat_n('\\', backslashes)); + escaped.push('"'); + } else if ch == '%' { + escaped.push_str("%%cd:~,%"); + } + backslashes = 0; + escaped.push(ch); + } + + if quote { + // A quoted argument's trailing backslashes must be doubled so they do + // not consume the closing quote in the receiving CRT argv parser. + escaped.extend(std::iter::repeat_n('\\', backslashes)); + escaped.push('"'); + } + Ok(escaped) +} + +fn quote_shell_token(token: &str) -> Result { + #[cfg(windows)] + { + quote_windows_cmd_token(token) + } + + #[cfg(not(windows))] + { + Ok(shell_escape::unix::escape(Cow::Borrowed(token)).into_owned()) + } +} + +fn serialize_shell_command(tokens: &[String]) -> Result { + let quoted = tokens + .iter() + .map(|token| quote_shell_token(token)) + .collect::, _>>()?; + Ok(quoted.join(" ")) +} + /// Execute a Rust/Cargo command and return the result /// /// If `provided_projects` is not empty, it will be used instead of reading from .meta file. @@ -61,12 +234,35 @@ pub fn execute_command( provided_projects: &[String], cwd: &Path, ) -> CommandResult { - // Intercept --help/-h before dispatching to subcommand handlers - if command - .split_whitespace() - .chain(args.iter().map(String::as_str)) - .any(|a| a == "--help" || a == "-h") - { + execute_command_with_filters(command, args, parallel, provided_projects, cwd, None, None) +} + +/// Execute a Rust/Cargo command with the host's selected directory filters. +pub fn execute_command_with_filters( + command: &str, + args: &[String], + parallel: bool, + provided_projects: &[String], + cwd: &Path, + include_filters: Option<&[String]>, + exclude_filters: Option<&[String]>, +) -> CommandResult { + let mut command_parts = command.split_whitespace(); + let namespace = command_parts.next().unwrap_or_default(); + if !matches!(namespace, "cargo" | "rust") { + return CommandResult::ShowHelp(Some(format!( + "unrecognized Rust plugin namespace '{command}'" + ))); + } + + // Older hosts may send a multi-word matched command. New hosts advertise + // only the namespace and send every following token in `args`. + let mut cargo_args: Vec = command_parts.map(str::to_owned).collect(); + cargo_args.extend(args.iter().cloned()); + + // Help is Meta-aware and deliberately side-effect-free. Cargo/test-binary + // payload after `--` is opaque and must never be interpreted here. + if cargo_args.is_empty() || help_requested(&cargo_args) { return CommandResult::ShowHelp(None); } @@ -76,32 +272,24 @@ pub fn execute_command( Err(e) => return CommandResult::Error(format!("Failed to get project directories: {e}")), }; - // Filter to Rust projects only - let rust_dirs = filter_rust_projects(&dirs, cwd); + // Apply the host-selected scope before filtering to Rust projects so an + // include/exclude selection containing no Cargo.toml gets a clear result. + let selected_dirs = filter_selected_projects(&dirs, include_filters, exclude_filters); + let rust_dirs = filter_rust_projects(&selected_dirs); if rust_dirs.is_empty() { return CommandResult::Message("No Rust projects found (no Cargo.toml files)".to_string()); } - // Build the cargo command - let cargo_cmd = match command { - "cargo build" | "rust build" => { - let mut cmd = "cargo build".to_string(); - for arg in args { - cmd.push(' '); - cmd.push_str(arg); - } - cmd - } - "cargo test" | "rust test" => { - let mut cmd = "cargo test".to_string(); - for arg in args { - cmd.push(' '); - cmd.push_str(arg); - } - cmd - } - _ => return CommandResult::ShowHelp(Some(format!("unrecognized command '{command}'"))), + // Cargo owns subcommand validation, aliases, and installed cargo-* tools. + // Serialize each argv token independently because loop_lib executes the + // string plan through the platform shell. + let mut cargo_tokens = Vec::with_capacity(cargo_args.len() + 1); + cargo_tokens.push("cargo".to_string()); + cargo_tokens.extend(cargo_args); + let cargo_cmd = match serialize_shell_command(&cargo_tokens) { + Ok(command) => command, + Err(error) => return CommandResult::Error(error.to_string()), }; // Build execution plan @@ -117,17 +305,34 @@ pub fn execute_command( CommandResult::Plan(commands, Some(parallel)) } -/// Get help text for the plugin -pub fn get_help_text() -> &'static str { - r#"meta rust - Rust/Cargo Plugin +/// Build the structured runtime help advertised by the plugin. +pub fn plugin_help() -> PluginHelp { + let mut commands = IndexMap::new(); + commands.insert( + "cargo".to_string(), + "Run any Cargo command across selected Rust projects".to_string(), + ); + commands.insert( + "rust".to_string(), + "Alias for the cargo namespace".to_string(), + ); -Commands: - meta cargo build Run cargo build across all Rust projects - meta cargo test Run cargo test across all Rust projects - -This plugin detects Rust projects (by presence of Cargo.toml) and runs -the specified cargo command. Non-Rust directories are skipped. -"# + PluginHelp { + usage: "meta [META OPTIONS] cargo [cargo args...]\n meta [META OPTIONS] rust [cargo args...]".to_string(), + commands, + command_sections: IndexMap::new(), + examples: vec![ + "meta cargo clean".to_string(), + "meta --dry-run cargo clean --recursive".to_string(), + "meta cargo check --all-targets".to_string(), + "meta cargo clippy --all-targets -- -D warnings".to_string(), + "meta cargo nextest run".to_string(), + ], + note: Some( + "Meta selects and loops over directories containing Cargo.toml; Cargo validates the command and its arguments. Put Meta controls before cargo/rust. Use `cargo help ` for command-specific Cargo options." + .to_string(), + ), + } } #[cfg(test)] @@ -135,20 +340,62 @@ mod tests { use super::*; use tempfile::TempDir; + fn create_rust_project(path: &Path) { + std::fs::create_dir_all(path).unwrap(); + std::fs::write( + path.join("Cargo.toml"), + "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + } + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + fn expect_plan(result: CommandResult) -> (Vec, Option) { + match result { + CommandResult::Plan(commands, parallel) => (commands, parallel), + _ => panic!("Expected Plan result"), + } + } + #[test] - fn test_unknown_command() { - let result = execute_command("cargo unknown", &[], false, &[], Path::new(".")); + fn test_non_cargo_namespace_is_rejected() { + let result = execute_command("carg", &strings(&["clean"]), false, &[], Path::new(".")); match result { - CommandResult::ShowHelp(Some(msg)) => assert!(msg.contains("unrecognized command")), + CommandResult::ShowHelp(Some(msg)) => assert!(msg.contains("unrecognized")), _ => panic!("Expected ShowHelp result"), } } #[test] - fn test_get_help_text() { - let help = get_help_text(); - assert!(help.contains("cargo build")); - assert!(help.contains("cargo test")); + fn test_plugin_help_describes_general_pass_through() { + let help = plugin_help(); + assert!(help.usage.contains("cargo ")); + assert!(help.usage.contains("rust ")); + assert_eq!( + help.commands.get("rust").map(String::as_str), + Some("Alias for the cargo namespace") + ); + for example in ["clean", "check", "clippy", "nextest"] { + assert!(help.examples.iter().any(|line| line.contains(example))); + } + assert!(help.note.as_deref().unwrap().contains("Cargo validates")); + assert!(!help.note.as_deref().unwrap().contains("meta exec")); + } + + #[test] + fn test_bare_namespace_and_help_do_not_discover_projects() { + let missing = Path::new("/path/that/does/not/exist"); + assert!(matches!( + execute_command("cargo", &[], false, &[], missing), + CommandResult::ShowHelp(None) + )); + assert!(matches!( + execute_command("rust", &strings(&["clean", "--help"]), false, &[], missing), + CommandResult::ShowHelp(None) + )); } #[test] @@ -158,7 +405,7 @@ mod tests { // Create .meta with no Rust projects std::fs::write(temp_dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); - let result = execute_command("cargo build", &[], false, &[], temp_dir.path()); + let result = execute_command("cargo", &strings(&["build"]), false, &[], temp_dir.path()); match result { CommandResult::Message(msg) => assert!(msg.contains("No Rust projects")), @@ -167,35 +414,272 @@ mod tests { } #[test] - fn test_cargo_build_returns_plan() { + fn test_cargo_commands_pass_through_without_a_catalog() { let temp_dir = TempDir::new().unwrap(); + create_rust_project(temp_dir.path()); + let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; - // Create a Cargo.toml in root - std::fs::write( - temp_dir.path().join("Cargo.toml"), - "[package]\nname = \"test\"\n", - ) - .unwrap(); - std::fs::write(temp_dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); + for expected in [ + strings(&["cargo", "build", "--release"]), + strings(&["cargo", "test", "--workspace"]), + strings(&["cargo", "check"]), + strings(&["cargo", "clippy", "--all-targets", "--", "-D", "warnings"]), + strings(&["cargo", "nextest", "run"]), + strings(&["cargo", "definitely-not-a-built-in"]), + ] { + let (commands, parallel) = expect_plan(execute_command( + "cargo", + &expected[1..], + true, + &projects, + temp_dir.path(), + )); + assert_eq!(commands.len(), 1); + #[cfg(not(windows))] + assert_eq!(shell_words::split(&commands[0].cmd).unwrap(), expected); + #[cfg(windows)] + assert_eq!(commands[0].cmd, expected.join(" ")); + assert_eq!(parallel, Some(true)); + } + } + + #[test] + fn test_rust_alias_produces_canonical_cargo_plan() { + let temp_dir = TempDir::new().unwrap(); + create_rust_project(temp_dir.path()); + let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; + let args = strings(&["check", "--all-targets"]); - let result = execute_command( - "cargo build", - &["--release".to_string()], - true, - &[], + let cargo = expect_plan(execute_command( + "cargo", + &args, + false, + &projects, temp_dir.path(), + )); + let rust = expect_plan(execute_command( + "rust", + &args, + false, + &projects, + temp_dir.path(), + )); + + assert_eq!(cargo.0[0].cmd, "cargo check --all-targets"); + assert_eq!(cargo.0[0].cmd, rust.0[0].cmd); + assert_eq!(cargo.0[0].dir, rust.0[0].dir); + } + + #[test] + fn test_host_projects_are_authoritative_normalized_and_deduplicated() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let child = root.join("child"); + let non_rust = root.join("docs"); + create_rust_project(root); + create_rust_project(&child); + std::fs::create_dir_all(&non_rust).unwrap(); + + let projects = vec![ + child.join(".").to_string_lossy().into_owned(), + root.join(".").to_string_lossy().into_owned(), + root.to_string_lossy().into_owned(), + root.join("other/../child").to_string_lossy().into_owned(), + non_rust.to_string_lossy().into_owned(), + ]; + let (commands, _) = expect_plan(execute_command( + "cargo", + &strings(&["clean"]), + false, + &projects, + root, + )); + + assert_eq!(commands.len(), 2); + assert_eq!( + PathBuf::from(&commands[0].dir), + std::fs::canonicalize(child).unwrap() ); + assert_eq!( + PathBuf::from(&commands[1].dir), + std::fs::canonicalize(root).unwrap() + ); + } - match result { - CommandResult::Plan(commands, parallel) => { - assert_eq!(commands.len(), 1); - assert_eq!(commands[0].dir, "."); - assert!(commands[0].cmd.contains("cargo build")); - assert!(commands[0].cmd.contains("--release")); - assert_eq!(parallel, Some(true)); - } - _ => panic!("Expected Plan result"), + #[cfg(unix)] + #[test] + fn test_normalization_resolves_symlink_parent_components_before_lexical_cleanup() { + use std::os::unix::fs::symlink; + + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().join("root"); + let external = temp_dir.path().join("external"); + let anchor = external.join("anchor"); + let rust_project = external.join("crate"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&anchor).unwrap(); + create_rust_project(&rust_project); + symlink(&anchor, root.join("link")).unwrap(); + + let apparent = root.join("link/../crate").to_string_lossy().into_owned(); + let normalized = normalize_project_directories(&[apparent], &root); + + assert_eq!(normalized.len(), 1); + assert_eq!( + PathBuf::from(&normalized[0]), + std::fs::canonicalize(rust_project).unwrap() + ); + } + + #[test] + fn test_host_filters_define_scope_before_rust_detection() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let docs = root.join("docs"); + create_rust_project(root); + std::fs::create_dir_all(&docs).unwrap(); + let projects = vec![ + root.to_string_lossy().into_owned(), + docs.to_string_lossy().into_owned(), + ]; + + let include_filters = strings(&["docs"]); + let include_docs = execute_command_with_filters( + "cargo", + &strings(&["check"]), + false, + &projects, + root, + Some(&include_filters), + None, + ); + assert!(matches!( + include_docs, + CommandResult::Message(message) if message.contains("No Rust projects found") + )); + + let exclude_filters = vec![root.to_string_lossy().into_owned()]; + let exclude_all = execute_command_with_filters( + "cargo", + &strings(&["check"]), + false, + &projects, + root, + None, + Some(&exclude_filters), + ); + assert!(matches!( + exclude_all, + CommandResult::Message(message) if message.contains("No Rust projects found") + )); + } + + #[cfg(not(windows))] + #[test] + fn test_shell_serialization_preserves_argument_boundaries() { + let temp_dir = TempDir::new().unwrap(); + create_rust_project(temp_dir.path()); + let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; + let args = strings(&[ + "clippy", + "--", + "value with spaces", + "$(touch injected)", + "semi;colon", + "amp&ersand", + "single'quote", + "", + ]); + + let (commands, _) = expect_plan(execute_command( + "cargo", + &args, + false, + &projects, + temp_dir.path(), + )); + let mut expected = vec!["cargo".to_string()]; + expected.extend(args); + + assert_eq!(shell_words::split(&commands[0].cmd).unwrap(), expected); + } + + #[test] + fn test_windows_cmd_serialization_uses_cmd_native_escaping() { + assert_eq!(quote_windows_cmd_token("plain").unwrap(), "plain"); + assert_eq!( + quote_windows_cmd_token("amp&ersand").unwrap(), + "\"amp&ersand\"" + ); + assert_eq!( + quote_windows_cmd_token("pipe|value").unwrap(), + "\"pipe|value\"" + ); + assert_eq!( + quote_windows_cmd_token("trailing&\\").unwrap(), + "\"trailing&\\\\\"" + ); + + // cmd.exe does not use backslash to escape a quote. Doubling keeps the + // quote in the argument and leaves the following operator quoted. + assert_eq!( + quote_windows_cmd_token("quoted\"&echo injected").unwrap(), + "\"quoted\"\"&echo injected\"" + ); + + // Literal percent pairs must not become environment-variable syntax. + let percent = quote_windows_cmd_token("%PATH%").unwrap(); + assert_eq!(percent, "\"%%cd:~,%%PATH%%cd:~,%%\""); + assert_ne!(percent, "\"%PATH%\""); + } + + #[test] + fn test_windows_cmd_serialization_rejects_line_breaks() { + for token in ["line\nfeed", "carriage\rreturn", "both\r\n"] { + assert_eq!(quote_windows_cmd_token(token), Err(WINDOWS_NEWLINE_ERROR)); } + assert_eq!(quote_windows_cmd_token("nul\0byte"), Err(WINDOWS_NUL_ERROR)); + } + + #[test] + fn test_help_and_meta_like_tokens_after_separator_are_cargo_payload() { + let temp_dir = TempDir::new().unwrap(); + create_rust_project(temp_dir.path()); + let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; + let args = strings(&["test", "--", "--recursive", "--help"]); + + let (commands, _) = expect_plan(execute_command( + "cargo", + &args, + false, + &projects, + temp_dir.path(), + )); + + #[cfg(not(windows))] + assert_eq!( + shell_words::split(&commands[0].cmd).unwrap(), + strings(&["cargo", "test", "--", "--recursive", "--help"]) + ); + #[cfg(windows)] + assert_eq!(commands[0].cmd, "cargo test -- --recursive --help"); + } + + #[test] + fn test_multi_word_command_shape_remains_compatible() { + let temp_dir = TempDir::new().unwrap(); + create_rust_project(temp_dir.path()); + let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; + + let (commands, _) = expect_plan(execute_command( + "rust build", + &strings(&["--release"]), + false, + &projects, + temp_dir.path(), + )); + + assert_eq!(commands[0].cmd, "cargo build --release"); } #[test] diff --git a/src/main.rs b/src/main.rs index 7538de7..6312ed8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,46 +1,18 @@ //! meta-rust subprocess plugin -use indexmap::IndexMap; use meta_plugin_protocol::{ - run_plugin, CommandResult, PluginDefinition, PluginHelp, PluginInfo, PluginRequest, + run_plugin, CommandResult, PluginDefinition, PluginInfo, PluginRequest, }; use std::path::PathBuf; fn main() { - let mut help_commands = IndexMap::new(); - help_commands.insert( - "build".to_string(), - "Build all Rust projects in the workspace".to_string(), - ); - help_commands.insert( - "test".to_string(), - "Run tests across all Rust projects".to_string(), - ); - run_plugin(PluginDefinition { info: PluginInfo { name: "rust".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - commands: vec![ - "cargo build".to_string(), - "cargo test".to_string(), - "rust build".to_string(), - "rust test".to_string(), - ], - description: Some("Rust/Cargo commands for meta repositories".to_string()), - help: Some(PluginHelp { - usage: "meta cargo [args...]\n meta rust [args...]" - .to_string(), - commands: help_commands, - command_sections: IndexMap::new(), - examples: vec![ - "meta cargo build".to_string(), - "meta cargo test".to_string(), - "meta rust build".to_string(), - "meta cargo build --release".to_string(), - ], - note: Some("To run raw cargo commands: meta exec -- cargo ".to_string()), - }), + commands: vec!["cargo".to_string(), "rust".to_string()], + description: Some("Cargo command pass-through for Meta workspaces".to_string()), + help: Some(meta_rust_cli::plugin_help()), }, execute, }); @@ -56,11 +28,13 @@ fn execute(request: PluginRequest) -> CommandResult { PathBuf::from(&request.cwd) }; - meta_rust_cli::execute_command( + meta_rust_cli::execute_command_with_filters( &request.command, &request.args, request.options.parallel, &request.projects, &cwd, + request.options.include_filters.as_deref(), + request.options.exclude_filters.as_deref(), ) } diff --git a/tests/windows_cmd.rs b/tests/windows_cmd.rs new file mode 100644 index 0000000..edacd79 --- /dev/null +++ b/tests/windows_cmd.rs @@ -0,0 +1,143 @@ +#![cfg(windows)] + +use meta_rust_cli::{execute_command, CommandResult}; +use std::ffi::OsString; +use std::process::Command; +use tempfile::TempDir; + +fn encode_utf16(value: &str) -> String { + let units: Vec = value.encode_utf16().collect(); + let encoded = units + .iter() + .map(|unit| format!("{unit:04x}")) + .collect::>() + .join(","); + format!("{}:{encoded}", units.len()) +} + +fn compile_fake_cargo(temp: &TempDir) -> std::path::PathBuf { + let bin = temp.path().join("probe bin"); + std::fs::create_dir_all(&bin).unwrap(); + let source = temp.path().join("argv_probe.rs"); + std::fs::write( + &source, + r#" +use std::os::windows::ffi::OsStrExt; + +fn main() { + for arg in std::env::args_os().skip(1) { + let units: Vec = arg.encode_wide().collect(); + let encoded = units + .iter() + .map(|unit| format!("{unit:04x}")) + .collect::>() + .join(","); + println!("{}:{encoded}", units.len()); + } +} +"#, + ) + .unwrap(); + + let cargo = bin.join("cargo.exe"); + let status = Command::new("rustc") + .args(["--edition=2021", "-o"]) + .arg(&cargo) + .arg(&source) + .status() + .expect("rustc must be available while running Rust tests"); + assert!( + status.success(), + "failed to compile the fake cargo argv probe" + ); + cargo +} + +#[test] +fn planned_command_survives_the_real_cmd_boundary_without_injection() { + let temp = TempDir::new().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname='windows-cmd-probe'\nversion='0.0.0'\n", + ) + .unwrap(); + let cargo = compile_fake_cargo(&temp); + let marker = temp.path().join("injected marker"); + let injection = format!("quoted\" & type nul > \"{}\" & rem \"", marker.display()); + let args = vec![ + "probe".to_string(), + "value with spaces".to_string(), + "%PATH%".to_string(), + "prefix%PATH%suffix".to_string(), + "%hello".to_string(), + "%%cd:~,%".to_string(), + "%PATH%PATH%".to_string(), + "amp&ersand".to_string(), + "pipe|value".to_string(), + "redirect>value".to_string(), + "caret^value".to_string(), + "paren(value)".to_string(), + "quoted\"&echo injected".to_string(), + "trailing\\".to_string(), + "bang!PATH!".to_string(), + "semi;colon".to_string(), + "unicode-λ".to_string(), + String::new(), + injection, + ]; + let projects = vec![temp.path().to_string_lossy().into_owned()]; + + let command = match execute_command("cargo", &args, false, &projects, temp.path()) { + CommandResult::Plan(commands, _) => commands.into_iter().next().unwrap().cmd, + _ => panic!("expected a Cargo execution plan"), + }; + + let mut path_entries = vec![cargo.parent().unwrap().to_path_buf()]; + path_entries.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let path: OsString = std::env::join_paths(path_entries).unwrap(); + let output = Command::new("cmd.exe") + .arg("/c") + .arg(&command) + .current_dir(temp.path()) + .env("PATH", path) + .output() + .unwrap(); + + assert!( + output.status.success(), + "cmd failed:\nstdout: {}\nstderr: {}\ncommand: {command}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!marker.exists(), "hostile argument escaped into cmd syntax"); + + let actual: Vec = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(str::to_owned) + .collect(); + let expected: Vec = args.iter().map(|arg| encode_utf16(arg)).collect(); + assert_eq!(actual, expected); +} + +#[test] +fn planned_command_rejects_cmd_line_breaks() { + let temp = TempDir::new().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname='windows-cmd-probe'\nversion='0.0.0'\n", + ) + .unwrap(); + let projects = vec![temp.path().to_string_lossy().into_owned()]; + + let result = execute_command( + "cargo", + &["check".to_string(), "line\nbreak".to_string()], + false, + &projects, + temp.path(), + ); + assert!(matches!(result, CommandResult::Error(message) if message.contains("cmd.exe"))); +} From b10c770ab739f19df66b4a46f754f82814f03a8f Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 10:29:53 -0700 Subject: [PATCH 02/10] fix: normalize Windows project filters Implements [[tasks/meta-cargo-command-forwarding]] --- src/lib.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ba2047a..9ccd15d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -103,6 +103,44 @@ fn filter_rust_projects(dirs: &[String]) -> Vec { .collect() } +#[cfg(any(windows, test))] +fn windows_filter_match_key(value: &str) -> String { + let normalized = value.replace('\\', "/"); + let normalized = if let Some(rest) = normalized.strip_prefix("//?/UNC/") { + format!("//{rest}") + } else if let Some(rest) = normalized.strip_prefix("//?/") { + rest.to_string() + } else { + normalized + }; + + normalized.to_ascii_lowercase() +} + +fn filter_matches_path(path: &str, filter: &str) -> bool { + let filter = filter.trim_end_matches('/'); + + #[cfg(windows)] + { + // canonicalize() returns verbatim paths on Windows and may also expand + // short names or traverse junctions. Canonicalize an absolute filter + // too when possible so it has the same spelling as project paths. + let canonical_filter = Path::new(filter) + .is_absolute() + .then(|| std::fs::canonicalize(filter).ok()) + .flatten() + .map(|path| path.to_string_lossy().into_owned()); + let filter = canonical_filter.as_deref().unwrap_or(filter); + + windows_filter_match_key(path).contains(&windows_filter_match_key(filter)) + } + + #[cfg(not(windows))] + { + path.contains(filter) + } +} + /// Apply the host's directory selection before deciding whether the scope has /// any Rust projects. loop_lib applies the same filters during execution, but /// planning must see the selected scope to produce a clear empty result. @@ -114,15 +152,18 @@ fn filter_selected_projects( let mut selected = dirs.to_vec(); if let Some(includes) = include_filters.filter(|filters| !filters.is_empty()) { - selected.retain(|path| includes.iter().any(|filter| path.contains(filter))); + selected.retain(|path| { + includes + .iter() + .any(|filter| filter_matches_path(path, filter)) + }); } if let Some(excludes) = exclude_filters.filter(|filters| !filters.is_empty()) { selected.retain(|path| { !excludes .iter() - .map(|filter| filter.trim_end_matches('/')) - .any(|filter| path.contains(filter)) + .any(|filter| filter_matches_path(path, filter)) }); } @@ -574,6 +615,18 @@ mod tests { )); } + #[test] + fn test_windows_filter_match_key_equates_verbatim_and_ordinary_paths() { + assert_eq!( + windows_filter_match_key(r"\\?\C:\Users\Runner\crate"), + windows_filter_match_key(r"c:\users\runner\crate") + ); + assert_eq!( + windows_filter_match_key(r"\\?\UNC\server\share\crate"), + windows_filter_match_key(r"\\server\share\crate") + ); + } + #[cfg(not(windows))] #[test] fn test_shell_serialization_preserves_argument_boundaries() { From 6c7775538bfa6ef396a6efab05b6f8045816e707 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 10:40:37 -0700 Subject: [PATCH 03/10] fix: preserve percent arguments on Windows Implements [[tasks/meta-cargo-command-forwarding]] --- src/lib.rs | 61 ++++++++++++++++++++++++++++++++++++++------ tests/windows_cmd.rs | 25 ++++++++++++------ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9ccd15d..4ca3274 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -182,6 +182,8 @@ const WINDOWS_NEWLINE_ERROR: &str = #[cfg(any(windows, test))] const WINDOWS_NUL_ERROR: &str = "Cargo arguments containing NUL bytes cannot be transported through cmd.exe"; +#[cfg(any(windows, test))] +const WINDOWS_LITERAL_PERCENT_ENV: &str = "META_RUST_PCT"; #[cfg(any(windows, test))] fn quote_windows_cmd_token(token: &str) -> Result { @@ -190,10 +192,10 @@ fn quote_windows_cmd_token(token: &str) -> Result { // serializers, so embedded quotes must instead be doubled. Quote a broad // denylist of ASCII syntax to keep cmd metacharacters inside the argument. // - // cmd has no direct escape for a literal `%` on a `/C` command line. The - // zero-length `%cd:~,%` expansion prevents the two user-supplied percent - // signs in `%NAME%` from ever forming an environment-variable reference. - // Command extensions are enabled by default for a fresh cmd.exe process. + // cmd has no direct escape for a literal `%` on a `/C` command line. Use a + // controlled environment-variable expansion whose value is one percent. + // Expansion is a single pass, so a reconstructed `%NAME%` remains literal + // instead of being reinterpreted as another environment-variable reference. if token.contains('\0') { return Err(WINDOWS_NUL_ERROR); } @@ -229,7 +231,11 @@ fn quote_windows_cmd_token(token: &str) -> Result { escaped.extend(std::iter::repeat_n('\\', backslashes)); escaped.push('"'); } else if ch == '%' { - escaped.push_str("%%cd:~,%"); + escaped.push('%'); + escaped.push_str(WINDOWS_LITERAL_PERCENT_ENV); + escaped.push('%'); + backslashes = 0; + continue; } backslashes = 0; escaped.push(ch); @@ -244,6 +250,33 @@ fn quote_windows_cmd_token(token: &str) -> Result { Ok(escaped) } +#[cfg(any(windows, test))] +fn windows_shell_transport_environment( + tokens: &[String], +) -> Option> { + tokens.iter().any(|token| token.contains('%')).then(|| { + std::collections::HashMap::from([( + WINDOWS_LITERAL_PERCENT_ENV.to_string(), + "%".to_string(), + )]) + }) +} + +fn shell_transport_environment( + tokens: &[String], +) -> Option> { + #[cfg(windows)] + { + windows_shell_transport_environment(tokens) + } + + #[cfg(not(windows))] + { + let _ = tokens; + None + } +} + fn quote_shell_token(token: &str) -> Result { #[cfg(windows)] { @@ -332,6 +365,7 @@ pub fn execute_command_with_filters( Ok(command) => command, Err(error) => return CommandResult::Error(error.to_string()), }; + let cargo_env = shell_transport_environment(&cargo_tokens); // Build execution plan let commands: Vec = rust_dirs @@ -339,7 +373,7 @@ pub fn execute_command_with_filters( .map(|dir| PlannedCommand { dir: dir.clone(), cmd: cargo_cmd.clone(), - env: None, + env: cargo_env.clone(), }) .collect(); @@ -680,10 +714,20 @@ mod tests { "\"quoted\"\"&echo injected\"" ); - // Literal percent pairs must not become environment-variable syntax. + // Literal percent pairs are reconstructed from one controlled + // expansion and cannot become arbitrary environment-variable syntax. let percent = quote_windows_cmd_token("%PATH%").unwrap(); - assert_eq!(percent, "\"%%cd:~,%%PATH%%cd:~,%%\""); + assert_eq!(percent, "\"%META_RUST_PCT%PATH%META_RUST_PCT%\""); assert_ne!(percent, "\"%PATH%\""); + + let tokens = vec!["cargo".to_string(), "%PATH%".to_string()]; + let environment = windows_shell_transport_environment(&tokens).unwrap(); + assert_eq!( + environment + .get(WINDOWS_LITERAL_PERCENT_ENV) + .map(String::as_str), + Some("%") + ); } #[test] @@ -716,6 +760,7 @@ mod tests { ); #[cfg(windows)] assert_eq!(commands[0].cmd, "cargo test -- --recursive --help"); + assert!(commands[0].env.is_none()); } #[test] diff --git a/tests/windows_cmd.rs b/tests/windows_cmd.rs index edacd79..69e9155 100644 --- a/tests/windows_cmd.rs +++ b/tests/windows_cmd.rs @@ -71,6 +71,10 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { "prefix%PATH%suffix".to_string(), "%hello".to_string(), "%%cd:~,%".to_string(), + "%META_RUST_PCT%".to_string(), + "%CD%".to_string(), + "%0".to_string(), + "%%%%".to_string(), "%PATH%PATH%".to_string(), "amp&ersand".to_string(), "pipe|value".to_string(), @@ -87,8 +91,8 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { ]; let projects = vec![temp.path().to_string_lossy().into_owned()]; - let command = match execute_command("cargo", &args, false, &projects, temp.path()) { - CommandResult::Plan(commands, _) => commands.into_iter().next().unwrap().cmd, + let planned = match execute_command("cargo", &args, false, &projects, temp.path()) { + CommandResult::Plan(commands, _) => commands.into_iter().next().unwrap(), _ => panic!("expected a Cargo execution plan"), }; @@ -97,19 +101,24 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { &std::env::var_os("PATH").unwrap_or_default(), )); let path: OsString = std::env::join_paths(path_entries).unwrap(); - let output = Command::new("cmd.exe") + let mut process = Command::new("cmd.exe"); + process .arg("/c") - .arg(&command) + .arg(&planned.cmd) .current_dir(temp.path()) .env("PATH", path) - .output() - .unwrap(); + .env("META_RUST_PCT", &injection); + if let Some(environment) = &planned.env { + process.envs(environment); + } + let output = process.output().unwrap(); assert!( output.status.success(), - "cmd failed:\nstdout: {}\nstderr: {}\ncommand: {command}", + "cmd failed:\nstdout: {}\nstderr: {}\ncommand: {}", String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + String::from_utf8_lossy(&output.stderr), + planned.cmd ); assert!(!marker.exists(), "hostile argument escaped into cmd syntax"); From 67d98cb976717ee41ae87658c9ce4ef4a5878395 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 10:42:50 -0700 Subject: [PATCH 04/10] fix: retain Windows command probe input Implements [[tasks/meta-cargo-command-forwarding]] --- tests/windows_cmd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/windows_cmd.rs b/tests/windows_cmd.rs index 69e9155..1648b98 100644 --- a/tests/windows_cmd.rs +++ b/tests/windows_cmd.rs @@ -87,7 +87,7 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { "semi;colon".to_string(), "unicode-λ".to_string(), String::new(), - injection, + injection.clone(), ]; let projects = vec![temp.path().to_string_lossy().into_owned()]; From e31b8a5e24ad1d82dd98a8d2b5d5e0eeb07fd612 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 10:46:29 -0700 Subject: [PATCH 05/10] fix: preserve quoted Windows Cargo arguments Implements [[tasks/meta-cargo-command-forwarding]] --- src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 4ca3274..a72c7e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -294,7 +294,19 @@ fn serialize_shell_command(tokens: &[String]) -> Result { .iter() .map(|token| quote_shell_token(token)) .collect::, _>>()?; - Ok(quoted.join(" ")) + let command = quoted.join(" "); + + #[cfg(windows)] + { + // loop_lib passes the complete plan as one `cmd.exe /c` argument. + // When that argument contains inner quotes, cmd needs one additional + // outer pair to strip before it interprets the inner argument quotes. + if command.contains('"') { + return Ok(format!("\"{command}\"")); + } + } + + Ok(command) } /// Execute a Rust/Cargo command and return the result @@ -728,6 +740,13 @@ mod tests { .map(String::as_str), Some("%") ); + + #[cfg(windows)] + assert_eq!( + serialize_shell_command(&["cargo".to_string(), "value with spaces".to_string()]) + .unwrap(), + "\"cargo \"value with spaces\"\"" + ); } #[test] From af59614a7bdb73dc9de4003c2aeb72522c759273 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 10:52:27 -0700 Subject: [PATCH 06/10] fix: defer Windows quote expansion Implements [[tasks/meta-cargo-command-forwarding]] --- src/lib.rs | 99 +++++++++++++++++++++++++++----------------- tests/windows_cmd.rs | 9 +++- 2 files changed, 70 insertions(+), 38 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a72c7e9..d030c09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -184,6 +184,21 @@ const WINDOWS_NUL_ERROR: &str = "Cargo arguments containing NUL bytes cannot be transported through cmd.exe"; #[cfg(any(windows, test))] const WINDOWS_LITERAL_PERCENT_ENV: &str = "META_RUST_PCT"; +#[cfg(any(windows, test))] +const WINDOWS_LITERAL_QUOTE_ENV: &str = "META_RUST_Q"; + +#[cfg(any(windows, test))] +fn windows_cmd_token_needs_quotes(token: &str) -> bool { + const UNQUOTED: &str = r"#$*+-./:?@\_"; + + token.is_empty() + || token.ends_with('\\') + || token.chars().any(|ch| { + let ascii_needs_quotes = + ch.is_ascii() && !(ch.is_ascii_alphanumeric() || UNQUOTED.contains(ch)); + ascii_needs_quotes || ch.is_control() + }) +} #[cfg(any(windows, test))] fn quote_windows_cmd_token(token: &str) -> Result { @@ -203,18 +218,13 @@ fn quote_windows_cmd_token(token: &str) -> Result { return Err(WINDOWS_NEWLINE_ERROR); } - const UNQUOTED: &str = r"#$*+-./:?@\_"; - let quote = token.is_empty() - || token.ends_with('\\') - || token.chars().any(|ch| { - let ascii_needs_quotes = - ch.is_ascii() && !(ch.is_ascii_alphanumeric() || UNQUOTED.contains(ch)); - ascii_needs_quotes || ch.is_control() - }); + let quote = windows_cmd_token_needs_quotes(token); let mut escaped = String::with_capacity(token.len() + 2); if quote { - escaped.push('"'); + escaped.push('%'); + escaped.push_str(WINDOWS_LITERAL_QUOTE_ENV); + escaped.push('%'); } let mut backslashes = 0; @@ -227,9 +237,16 @@ fn quote_windows_cmd_token(token: &str) -> Result { if ch == '"' { // Add n backslashes to the n already emitted, then add the first - // of a doubled quote pair. The ordinary push below adds the second. + // of a doubled quote pair. Fixed environment references defer the + // actual quote characters until cmd parses the command string. escaped.extend(std::iter::repeat_n('\\', backslashes)); - escaped.push('"'); + for _ in 0..2 { + escaped.push('%'); + escaped.push_str(WINDOWS_LITERAL_QUOTE_ENV); + escaped.push('%'); + } + backslashes = 0; + continue; } else if ch == '%' { escaped.push('%'); escaped.push_str(WINDOWS_LITERAL_PERCENT_ENV); @@ -245,7 +262,9 @@ fn quote_windows_cmd_token(token: &str) -> Result { // A quoted argument's trailing backslashes must be doubled so they do // not consume the closing quote in the receiving CRT argv parser. escaped.extend(std::iter::repeat_n('\\', backslashes)); - escaped.push('"'); + escaped.push('%'); + escaped.push_str(WINDOWS_LITERAL_QUOTE_ENV); + escaped.push('%'); } Ok(escaped) } @@ -254,11 +273,20 @@ fn quote_windows_cmd_token(token: &str) -> Result { fn windows_shell_transport_environment( tokens: &[String], ) -> Option> { - tokens.iter().any(|token| token.contains('%')).then(|| { - std::collections::HashMap::from([( - WINDOWS_LITERAL_PERCENT_ENV.to_string(), - "%".to_string(), - )]) + let needs_percent = tokens.iter().any(|token| token.contains('%')); + let needs_quote = tokens + .iter() + .any(|token| windows_cmd_token_needs_quotes(token)); + + (needs_percent || needs_quote).then(|| { + let mut environment = std::collections::HashMap::new(); + if needs_percent { + environment.insert(WINDOWS_LITERAL_PERCENT_ENV.to_string(), "%".to_string()); + } + if needs_quote { + environment.insert(WINDOWS_LITERAL_QUOTE_ENV.to_string(), "\"".to_string()); + } + environment }) } @@ -294,19 +322,7 @@ fn serialize_shell_command(tokens: &[String]) -> Result { .iter() .map(|token| quote_shell_token(token)) .collect::, _>>()?; - let command = quoted.join(" "); - - #[cfg(windows)] - { - // loop_lib passes the complete plan as one `cmd.exe /c` argument. - // When that argument contains inner quotes, cmd needs one additional - // outer pair to strip before it interprets the inner argument quotes. - if command.contains('"') { - return Ok(format!("\"{command}\"")); - } - } - - Ok(command) + Ok(quoted.join(" ")) } /// Execute a Rust/Cargo command and return the result @@ -708,29 +724,32 @@ mod tests { assert_eq!(quote_windows_cmd_token("plain").unwrap(), "plain"); assert_eq!( quote_windows_cmd_token("amp&ersand").unwrap(), - "\"amp&ersand\"" + "%META_RUST_Q%amp&ersand%META_RUST_Q%" ); assert_eq!( quote_windows_cmd_token("pipe|value").unwrap(), - "\"pipe|value\"" + "%META_RUST_Q%pipe|value%META_RUST_Q%" ); assert_eq!( quote_windows_cmd_token("trailing&\\").unwrap(), - "\"trailing&\\\\\"" + "%META_RUST_Q%trailing&\\\\%META_RUST_Q%" ); // cmd.exe does not use backslash to escape a quote. Doubling keeps the // quote in the argument and leaves the following operator quoted. assert_eq!( quote_windows_cmd_token("quoted\"&echo injected").unwrap(), - "\"quoted\"\"&echo injected\"" + "%META_RUST_Q%quoted%META_RUST_Q%%META_RUST_Q%&echo injected%META_RUST_Q%" ); // Literal percent pairs are reconstructed from one controlled // expansion and cannot become arbitrary environment-variable syntax. let percent = quote_windows_cmd_token("%PATH%").unwrap(); - assert_eq!(percent, "\"%META_RUST_PCT%PATH%META_RUST_PCT%\""); - assert_ne!(percent, "\"%PATH%\""); + assert_eq!( + percent, + "%META_RUST_Q%%META_RUST_PCT%PATH%META_RUST_PCT%%META_RUST_Q%" + ); + assert_ne!(percent, "%META_RUST_Q%%PATH%%META_RUST_Q%"); let tokens = vec!["cargo".to_string(), "%PATH%".to_string()]; let environment = windows_shell_transport_environment(&tokens).unwrap(); @@ -740,12 +759,18 @@ mod tests { .map(String::as_str), Some("%") ); + assert_eq!( + environment + .get(WINDOWS_LITERAL_QUOTE_ENV) + .map(String::as_str), + Some("\"") + ); #[cfg(windows)] assert_eq!( serialize_shell_command(&["cargo".to_string(), "value with spaces".to_string()]) .unwrap(), - "\"cargo \"value with spaces\"\"" + "cargo %META_RUST_Q%value with spaces%META_RUST_Q%" ); } diff --git a/tests/windows_cmd.rs b/tests/windows_cmd.rs index 1648b98..9b5c798 100644 --- a/tests/windows_cmd.rs +++ b/tests/windows_cmd.rs @@ -72,6 +72,7 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { "%hello".to_string(), "%%cd:~,%".to_string(), "%META_RUST_PCT%".to_string(), + "%META_RUST_Q%".to_string(), "%CD%".to_string(), "%0".to_string(), "%%%%".to_string(), @@ -82,6 +83,7 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { "caret^value".to_string(), "paren(value)".to_string(), "quoted\"&echo injected".to_string(), + "quoted\"%PATH%&value".to_string(), "trailing\\".to_string(), "bang!PATH!".to_string(), "semi;colon".to_string(), @@ -95,6 +97,10 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { CommandResult::Plan(commands, _) => commands.into_iter().next().unwrap(), _ => panic!("expected a Cargo execution plan"), }; + assert!( + !planned.cmd.contains('"'), + "literal quotes must be deferred until cmd expansion" + ); let mut path_entries = vec![cargo.parent().unwrap().to_path_buf()]; path_entries.extend(std::env::split_paths( @@ -107,7 +113,8 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { .arg(&planned.cmd) .current_dir(temp.path()) .env("PATH", path) - .env("META_RUST_PCT", &injection); + .env("META_RUST_PCT", &injection) + .env("META_RUST_Q", &injection); if let Some(environment) = &planned.env { process.envs(environment); } From eb38a1dec5918aa8600b73535ff9dceac3d289aa Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 13:33:48 -0700 Subject: [PATCH 07/10] fix: preserve Cargo project filter paths Implements [[tasks/meta-cargo-command-forwarding]] --- src/lib.rs | 208 ++++++++++++++++++++++++++++++++++--------- tests/windows_cmd.rs | 2 +- 2 files changed, 168 insertions(+), 42 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d030c09..524bc7d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,6 @@ pub use meta_plugin_protocol::{ }; #[cfg(not(windows))] use std::borrow::Cow; -use std::collections::HashSet; use std::path::{Component, Path, PathBuf}; /// Normalize project paths without requiring them to exist. @@ -30,8 +29,14 @@ fn normalize_path(path: &Path) -> PathBuf { normalized } -/// Normalize and deduplicate paths while preserving their first-seen order. -fn normalize_project_directories(paths: &[String], cwd: &Path) -> Vec { +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProjectDirectory { + execution_path: String, + filter_paths: Vec, +} + +/// Preserve original path spellings while deduplicating canonical execution paths. +fn normalize_project_directories(paths: &[String], cwd: &Path) -> Vec { let base = if cwd.is_absolute() { cwd.to_path_buf() } else { @@ -39,30 +44,36 @@ fn normalize_project_directories(paths: &[String], cwd: &Path) -> Vec { .unwrap_or_else(|_| PathBuf::from(".")) .join(cwd) }; - let mut seen = HashSet::new(); + let mut normalized = IndexMap::>::new(); + + for path in paths { + let path = Path::new(path); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + let filter_path = absolute.to_string_lossy().into_owned(); + + // Resolve the original path before lexically collapsing `..`. + // Filesystem traversal through `symlink/..` is not necessarily + // equivalent to removing both components textually. + let execution_path = std::fs::canonicalize(&absolute).unwrap_or_else(|_| { + let lexical = normalize_path(&absolute); + std::fs::canonicalize(&lexical).unwrap_or(lexical) + }); - paths - .iter() - .filter_map(|path| { - let path = Path::new(path); - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - base.join(path) - }; - // Resolve the original path before lexically collapsing `..`. - // Filesystem traversal through `symlink/..` is not necessarily - // equivalent to removing both components textually. - let normalized = std::fs::canonicalize(&absolute).unwrap_or_else(|_| { - let lexical = normalize_path(&absolute); - std::fs::canonicalize(&lexical).unwrap_or(lexical) - }); - - if seen.insert(normalized.clone()) { - Some(normalized.to_string_lossy().into_owned()) - } else { - None - } + let filter_paths = normalized.entry(execution_path).or_default(); + if !filter_paths.contains(&filter_path) { + filter_paths.push(filter_path); + } + } + + normalized + .into_iter() + .map(|(execution_path, filter_paths)| ProjectDirectory { + execution_path: execution_path.to_string_lossy().into_owned(), + filter_paths, }) .collect() } @@ -73,7 +84,7 @@ fn normalize_project_directories(paths: &[String], cwd: &Path) -> Vec { fn get_project_directories( provided_projects: &[String], cwd: &Path, -) -> anyhow::Result> { +) -> anyhow::Result> { // A host-supplied project list already includes the Meta root and reflects // recursion, worktree selection, and tag filtering. Treat it as authoritative; // include/exclude filters are applied below before Rust-project detection. @@ -96,10 +107,10 @@ fn get_project_directories( } /// Filter directories to only those with Cargo.toml -fn filter_rust_projects(dirs: &[String]) -> Vec { +fn filter_rust_projects(dirs: &[ProjectDirectory]) -> Vec { dirs.iter() - .filter(|dir| Path::new(dir).join("Cargo.toml").is_file()) - .cloned() + .filter(|dir| Path::new(&dir.execution_path).join("Cargo.toml").is_file()) + .map(|dir| dir.execution_path.clone()) .collect() } @@ -114,12 +125,15 @@ fn windows_filter_match_key(value: &str) -> String { normalized }; - normalized.to_ascii_lowercase() + normalized.trim_end_matches('/').to_ascii_lowercase() } -fn filter_matches_path(path: &str, filter: &str) -> bool { - let filter = filter.trim_end_matches('/'); +#[cfg(any(windows, test))] +fn windows_filter_matches_path(path: &str, filter: &str) -> bool { + windows_filter_match_key(path).contains(&windows_filter_match_key(filter)) +} +fn filter_matches_path(path: &str, filter: &str) -> bool { #[cfg(windows)] { // canonicalize() returns verbatim paths on Windows and may also expand @@ -132,30 +146,38 @@ fn filter_matches_path(path: &str, filter: &str) -> bool { .map(|path| path.to_string_lossy().into_owned()); let filter = canonical_filter.as_deref().unwrap_or(filter); - windows_filter_match_key(path).contains(&windows_filter_match_key(filter)) + windows_filter_matches_path(path, filter) } #[cfg(not(windows))] { - path.contains(filter) + path.contains(filter.trim_end_matches('/')) } } +fn project_matches_filter(project: &ProjectDirectory, filter: &str) -> bool { + project + .filter_paths + .iter() + .any(|path| filter_matches_path(path, filter)) + || filter_matches_path(&project.execution_path, filter) +} + /// Apply the host's directory selection before deciding whether the scope has /// any Rust projects. loop_lib applies the same filters during execution, but /// planning must see the selected scope to produce a clear empty result. fn filter_selected_projects( - dirs: &[String], + dirs: &[ProjectDirectory], include_filters: Option<&[String]>, exclude_filters: Option<&[String]>, -) -> Vec { +) -> Vec { let mut selected = dirs.to_vec(); if let Some(includes) = include_filters.filter(|filters| !filters.is_empty()) { selected.retain(|path| { includes .iter() - .any(|filter| filter_matches_path(path, filter)) + .any(|filter| project_matches_filter(path, filter)) }); } @@ -163,7 +185,7 @@ fn filter_selected_projects( selected.retain(|path| { !excludes .iter() - .any(|filter| filter_matches_path(path, filter)) + .any(|filter| project_matches_filter(path, filter)) }); } @@ -625,13 +647,64 @@ mod tests { symlink(&anchor, root.join("link")).unwrap(); let apparent = root.join("link/../crate").to_string_lossy().into_owned(); - let normalized = normalize_project_directories(&[apparent], &root); + let normalized = normalize_project_directories(std::slice::from_ref(&apparent), &root); assert_eq!(normalized.len(), 1); assert_eq!( - PathBuf::from(&normalized[0]), + PathBuf::from(&normalized[0].execution_path), std::fs::canonicalize(rust_project).unwrap() ); + assert_eq!(normalized[0].filter_paths, vec![apparent]); + } + + #[cfg(unix)] + #[test] + fn test_symlink_alias_filters_survive_canonical_deduplication() { + use std::os::unix::fs::symlink; + + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let project = root.join("project"); + let alias = root.join("project-alias"); + create_rust_project(&project); + symlink(&project, &alias).unwrap(); + + // Put the canonical spelling first so the alias would be lost by + // first-seen canonical deduplication unless match spellings are grouped. + let projects = vec![ + project.to_string_lossy().into_owned(), + alias.to_string_lossy().into_owned(), + ]; + let alias_filter = vec![alias.to_string_lossy().into_owned()]; + + let (commands, _) = expect_plan(execute_command_with_filters( + "cargo", + &strings(&["check"]), + false, + &projects, + root, + Some(&alias_filter), + None, + )); + assert_eq!(commands.len(), 1); + assert_eq!( + PathBuf::from(&commands[0].dir), + std::fs::canonicalize(&project).unwrap() + ); + + let excluded = execute_command_with_filters( + "cargo", + &strings(&["check"]), + false, + &projects, + root, + None, + Some(&alias_filter), + ); + assert!(matches!( + excluded, + CommandResult::Message(message) if message.contains("No Rust projects found") + )); } #[test] @@ -687,6 +760,59 @@ mod tests { windows_filter_match_key(r"\\?\UNC\server\share\crate"), windows_filter_match_key(r"\\server\share\crate") ); + assert_eq!( + windows_filter_match_key(r"C:\Users\Runner\crate\"), + windows_filter_match_key(r"C:\Users\Runner\crate") + ); + assert!(windows_filter_matches_path( + r"C:\Users\Runner\crate", + r"runner\crate\" + )); + } + + #[cfg(windows)] + #[test] + fn test_windows_trailing_backslash_filters_select_projects() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let child = root.join("child"); + create_rust_project(root); + create_rust_project(&child); + let projects = vec![ + root.to_string_lossy().into_owned(), + child.to_string_lossy().into_owned(), + ]; + let child_filter = strings(&["child\\"]); + + let (included, _) = expect_plan(execute_command_with_filters( + "cargo", + &strings(&["check"]), + false, + &projects, + root, + Some(&child_filter), + None, + )); + assert_eq!(included.len(), 1); + assert_eq!( + PathBuf::from(&included[0].dir), + std::fs::canonicalize(&child).unwrap() + ); + + let (excluded, _) = expect_plan(execute_command_with_filters( + "cargo", + &strings(&["check"]), + false, + &projects, + root, + None, + Some(&child_filter), + )); + assert_eq!(excluded.len(), 1); + assert_eq!( + PathBuf::from(&excluded[0].dir), + std::fs::canonicalize(root).unwrap() + ); } #[cfg(not(windows))] diff --git a/tests/windows_cmd.rs b/tests/windows_cmd.rs index 9b5c798..9691bf3 100644 --- a/tests/windows_cmd.rs +++ b/tests/windows_cmd.rs @@ -109,7 +109,7 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { let path: OsString = std::env::join_paths(path_entries).unwrap(); let mut process = Command::new("cmd.exe"); process - .arg("/c") + .args(["/d", "/v:off", "/c"]) .arg(&planned.cmd) .current_dir(temp.path()) .env("PATH", path) From 928a03205bed9d78fdcc2b0e589f1d12a6a82b33 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 14:14:12 -0700 Subject: [PATCH 08/10] fix: honor plan policy [[tasks/meta-cargo-command-forwarding]] --- src/lib.rs | 179 +++++++++++++++++++++++++++++++++++++------ src/main.rs | 3 +- tests/windows_cmd.rs | 24 +++++- 3 files changed, 178 insertions(+), 28 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 524bc7d..730532e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,8 @@ use indexmap::IndexMap; pub use meta_plugin_protocol::{ - output_execution_plan, CommandResult, ExecutionPlan, PlanResponse, PlannedCommand, PluginHelp, + output_execution_plan, CommandResult, ExecutionPlan, PlanExecutionPolicy, PlanResponse, + PlannedCommand, PluginHelp, HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1, }; #[cfg(not(windows))] use std::borrow::Cow; @@ -351,6 +352,8 @@ fn serialize_shell_command(tokens: &[String]) -> Result { /// /// If `provided_projects` is not empty, it will be used instead of reading from .meta file. /// This allows meta_cli to pass in the full project list when --recursive is used. +/// Operational commands fail closed because this legacy entry point cannot +/// negotiate the host's plan execution policy; plugin help remains available. pub fn execute_command( command: &str, args: &[String], @@ -358,10 +361,41 @@ pub fn execute_command( provided_projects: &[String], cwd: &Path, ) -> CommandResult { - execute_command_with_filters(command, args, parallel, provided_projects, cwd, None, None) + execute_command_with_filters( + command, + args, + parallel, + provided_projects, + cwd, + None, + None, + &[], + ) } -/// Execute a Rust/Cargo command with the host's selected directory filters. +/// Execute a Rust/Cargo command after negotiating host execution behavior. +pub fn execute_command_with_host_capabilities( + command: &str, + args: &[String], + parallel: bool, + provided_projects: &[String], + cwd: &Path, + host_capabilities: &[String], +) -> CommandResult { + execute_command_with_filters( + command, + args, + parallel, + provided_projects, + cwd, + None, + None, + host_capabilities, + ) +} + +/// Execute a Rust/Cargo command with the host's negotiated behavior and filters. +#[allow(clippy::too_many_arguments)] pub fn execute_command_with_filters( command: &str, args: &[String], @@ -370,6 +404,7 @@ pub fn execute_command_with_filters( cwd: &Path, include_filters: Option<&[String]>, exclude_filters: Option<&[String]>, + host_capabilities: &[String], ) -> CommandResult { let mut command_parts = command.split_whitespace(); let namespace = command_parts.next().unwrap_or_default(); @@ -390,6 +425,15 @@ pub fn execute_command_with_filters( return CommandResult::ShowHelp(None); } + if !host_capabilities + .iter() + .any(|capability| capability == HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1) + { + return CommandResult::Error(format!( + "Cargo operations require host capability '{HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1}'" + )); + } + // Get all project directories let dirs = match get_project_directories(provided_projects, cwd) { Ok(d) => d, @@ -427,7 +471,14 @@ pub fn execute_command_with_filters( }) .collect(); - CommandResult::Plan(commands, Some(parallel)) + CommandResult::PlanWithPolicy( + commands, + Some(parallel), + PlanExecutionPolicy { + expand_loop_aliases: false, + apply_host_filters: false, + }, + ) } /// Build the structured runtime help advertised by the plugin. @@ -454,7 +505,7 @@ pub fn plugin_help() -> PluginHelp { "meta cargo nextest run".to_string(), ], note: Some( - "Meta selects and loops over directories containing Cargo.toml; Cargo validates the command and its arguments. Put Meta controls before cargo/rust. Use `cargo help ` for command-specific Cargo options." + "The Rust plugin selects, filters, and deduplicates directories containing Cargo.toml; Cargo validates the command and its arguments. Put Meta controls before cargo/rust. Use `cargo help ` for command-specific Cargo options." .to_string(), ), } @@ -478,9 +529,61 @@ mod tests { values.iter().map(|value| (*value).to_string()).collect() } + fn plan_execution_policy_capability() -> Vec { + vec![HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1.to_string()] + } + + fn execute_capable_command( + command: &str, + args: &[String], + parallel: bool, + provided_projects: &[String], + cwd: &Path, + ) -> CommandResult { + execute_command_with_host_capabilities( + command, + args, + parallel, + provided_projects, + cwd, + &plan_execution_policy_capability(), + ) + } + + #[allow(clippy::too_many_arguments)] + fn execute_capable_command_with_filters( + command: &str, + args: &[String], + parallel: bool, + provided_projects: &[String], + cwd: &Path, + include_filters: Option<&[String]>, + exclude_filters: Option<&[String]>, + ) -> CommandResult { + execute_command_with_filters( + command, + args, + parallel, + provided_projects, + cwd, + include_filters, + exclude_filters, + &plan_execution_policy_capability(), + ) + } + fn expect_plan(result: CommandResult) -> (Vec, Option) { match result { - CommandResult::Plan(commands, parallel) => (commands, parallel), + CommandResult::PlanWithPolicy(commands, parallel, execution_policy) => { + assert_eq!( + execution_policy, + PlanExecutionPolicy { + expand_loop_aliases: false, + apply_host_filters: false, + } + ); + (commands, parallel) + } _ => panic!("Expected Plan result"), } } @@ -495,7 +598,7 @@ mod tests { } #[test] - fn test_plugin_help_describes_general_pass_through() { + fn test_plugin_help_describes_arbitrary_commands() { let help = plugin_help(); assert!(help.usage.contains("cargo ")); assert!(help.usage.contains("rust ")); @@ -523,6 +626,27 @@ mod tests { )); } + #[test] + fn test_operational_commands_require_plan_execution_policy_capability() { + let temp_dir = TempDir::new().unwrap(); + create_rust_project(temp_dir.path()); + let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; + + let result = execute_command( + "cargo", + &strings(&["check"]), + false, + &projects, + temp_dir.path(), + ); + + assert!(matches!( + result, + CommandResult::Error(message) + if message.contains(HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1) + )); + } + #[test] fn test_no_rust_projects() { let temp_dir = TempDir::new().unwrap(); @@ -530,7 +654,8 @@ mod tests { // Create .meta with no Rust projects std::fs::write(temp_dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); - let result = execute_command("cargo", &strings(&["build"]), false, &[], temp_dir.path()); + let result = + execute_capable_command("cargo", &strings(&["build"]), false, &[], temp_dir.path()); match result { CommandResult::Message(msg) => assert!(msg.contains("No Rust projects")), @@ -539,7 +664,7 @@ mod tests { } #[test] - fn test_cargo_commands_pass_through_without_a_catalog() { + fn test_arbitrary_cargo_commands_need_no_catalog() { let temp_dir = TempDir::new().unwrap(); create_rust_project(temp_dir.path()); let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; @@ -552,7 +677,7 @@ mod tests { strings(&["cargo", "nextest", "run"]), strings(&["cargo", "definitely-not-a-built-in"]), ] { - let (commands, parallel) = expect_plan(execute_command( + let (commands, parallel) = expect_plan(execute_capable_command( "cargo", &expected[1..], true, @@ -575,14 +700,14 @@ mod tests { let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; let args = strings(&["check", "--all-targets"]); - let cargo = expect_plan(execute_command( + let cargo = expect_plan(execute_capable_command( "cargo", &args, false, &projects, temp_dir.path(), )); - let rust = expect_plan(execute_command( + let rust = expect_plan(execute_capable_command( "rust", &args, false, @@ -612,7 +737,7 @@ mod tests { root.join("other/../child").to_string_lossy().into_owned(), non_rust.to_string_lossy().into_owned(), ]; - let (commands, _) = expect_plan(execute_command( + let (commands, _) = expect_plan(execute_capable_command( "cargo", &strings(&["clean"]), false, @@ -677,7 +802,7 @@ mod tests { ]; let alias_filter = vec![alias.to_string_lossy().into_owned()]; - let (commands, _) = expect_plan(execute_command_with_filters( + let (commands, _) = expect_plan(execute_capable_command_with_filters( "cargo", &strings(&["check"]), false, @@ -692,7 +817,7 @@ mod tests { std::fs::canonicalize(&project).unwrap() ); - let excluded = execute_command_with_filters( + let excluded = execute_capable_command_with_filters( "cargo", &strings(&["check"]), false, @@ -720,7 +845,7 @@ mod tests { ]; let include_filters = strings(&["docs"]); - let include_docs = execute_command_with_filters( + let include_docs = execute_capable_command_with_filters( "cargo", &strings(&["check"]), false, @@ -735,7 +860,7 @@ mod tests { )); let exclude_filters = vec![root.to_string_lossy().into_owned()]; - let exclude_all = execute_command_with_filters( + let exclude_all = execute_capable_command_with_filters( "cargo", &strings(&["check"]), false, @@ -784,7 +909,7 @@ mod tests { ]; let child_filter = strings(&["child\\"]); - let (included, _) = expect_plan(execute_command_with_filters( + let (included, _) = expect_plan(execute_capable_command_with_filters( "cargo", &strings(&["check"]), false, @@ -799,7 +924,7 @@ mod tests { std::fs::canonicalize(&child).unwrap() ); - let (excluded, _) = expect_plan(execute_command_with_filters( + let (excluded, _) = expect_plan(execute_capable_command_with_filters( "cargo", &strings(&["check"]), false, @@ -832,7 +957,7 @@ mod tests { "", ]); - let (commands, _) = expect_plan(execute_command( + let (commands, _) = expect_plan(execute_capable_command( "cargo", &args, false, @@ -915,7 +1040,7 @@ mod tests { let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; let args = strings(&["test", "--", "--recursive", "--help"]); - let (commands, _) = expect_plan(execute_command( + let (commands, _) = expect_plan(execute_capable_command( "cargo", &args, false, @@ -939,7 +1064,7 @@ mod tests { create_rust_project(temp_dir.path()); let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; - let (commands, _) = expect_plan(execute_command( + let (commands, _) = expect_plan(execute_capable_command( "rust build", &strings(&["--release"]), false, @@ -965,10 +1090,18 @@ mod tests { max_parallel: None, spawn_stagger_ms: None, }; - let response = PlanResponse { plan }; + let response = PlanResponse { + plan, + execution_policy: PlanExecutionPolicy { + expand_loop_aliases: false, + apply_host_filters: false, + }, + }; let json = serde_json::to_string(&response).unwrap(); assert!(json.contains("\"plan\"")); assert!(json.contains("\"commands\"")); assert!(json.contains("cargo test")); + assert!(json.contains("\"expand_loop_aliases\":false")); + assert!(json.contains("\"apply_host_filters\":false")); } } diff --git a/src/main.rs b/src/main.rs index 6312ed8..dcbff12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ fn main() { name: "rust".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), commands: vec!["cargo".to_string(), "rust".to_string()], - description: Some("Cargo command pass-through for Meta workspaces".to_string()), + description: Some("Cargo commands for Meta workspaces".to_string()), help: Some(meta_rust_cli::plugin_help()), }, execute, @@ -36,5 +36,6 @@ fn execute(request: PluginRequest) -> CommandResult { &cwd, request.options.include_filters.as_deref(), request.options.exclude_filters.as_deref(), + &request.host_capabilities, ) } diff --git a/tests/windows_cmd.rs b/tests/windows_cmd.rs index 9691bf3..265fef9 100644 --- a/tests/windows_cmd.rs +++ b/tests/windows_cmd.rs @@ -1,6 +1,8 @@ #![cfg(windows)] -use meta_rust_cli::{execute_command, CommandResult}; +use meta_rust_cli::{ + execute_command_with_host_capabilities, CommandResult, HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1, +}; use std::ffi::OsString; use std::process::Command; use tempfile::TempDir; @@ -93,8 +95,20 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { ]; let projects = vec![temp.path().to_string_lossy().into_owned()]; - let planned = match execute_command("cargo", &args, false, &projects, temp.path()) { - CommandResult::Plan(commands, _) => commands.into_iter().next().unwrap(), + let capabilities = vec![HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1.to_string()]; + let planned = match execute_command_with_host_capabilities( + "cargo", + &args, + false, + &projects, + temp.path(), + &capabilities, + ) { + CommandResult::PlanWithPolicy(commands, _, execution_policy) => { + assert!(!execution_policy.expand_loop_aliases); + assert!(!execution_policy.apply_host_filters); + commands.into_iter().next().unwrap() + } _ => panic!("expected a Cargo execution plan"), }; assert!( @@ -148,12 +162,14 @@ fn planned_command_rejects_cmd_line_breaks() { .unwrap(); let projects = vec![temp.path().to_string_lossy().into_owned()]; - let result = execute_command( + let capabilities = vec![HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1.to_string()]; + let result = execute_command_with_host_capabilities( "cargo", &["check".to_string(), "line\nbreak".to_string()], false, &projects, temp.path(), + &capabilities, ); assert!(matches!(result, CommandResult::Error(message) if message.contains("cmd.exe"))); } From 2989cb3cce203c431f17b9f4e8660a40003d4f31 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 14:37:40 -0700 Subject: [PATCH 09/10] fix: preserve Rust plugin library compatibility [[tasks/meta-cargo-command-forwarding]] --- src/lib.rs | 112 +++++++++++++++++++++++++++++---------- src/main.rs | 4 +- tests/plugin_protocol.rs | 51 ++++++++++++++++++ tests/windows_cmd.rs | 7 +-- 4 files changed, 143 insertions(+), 31 deletions(-) create mode 100644 tests/plugin_protocol.rs diff --git a/src/lib.rs b/src/lib.rs index 730532e..4eeb326 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,9 @@ use indexmap::IndexMap; pub use meta_plugin_protocol::{ - output_execution_plan, CommandResult, ExecutionPlan, PlanExecutionPolicy, PlanResponse, - PlannedCommand, PluginHelp, HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1, + output_execution_plan, CommandResult, CommandResultWithPolicy as PolicyCommandResult, + ExecutionPlan, PlanExecutionPolicy, PlanResponse, PlanResponseWithPolicy, PlannedCommand, + PluginHelp, HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1, }; #[cfg(not(windows))] use std::borrow::Cow; @@ -352,8 +353,8 @@ fn serialize_shell_command(tokens: &[String]) -> Result { /// /// If `provided_projects` is not empty, it will be used instead of reading from .meta file. /// This allows meta_cli to pass in the full project list when --recursive is used. -/// Operational commands fail closed because this legacy entry point cannot -/// negotiate the host's plan execution policy; plugin help remains available. +/// This compatibility entry point returns the legacy result shape. Capability- +/// aware plugin hosts should use [`execute_command_with_host_capabilities`]. pub fn execute_command( command: &str, args: &[String], @@ -361,7 +362,7 @@ pub fn execute_command( provided_projects: &[String], cwd: &Path, ) -> CommandResult { - execute_command_with_filters( + into_legacy_result(execute_command_with_filters( command, args, parallel, @@ -369,8 +370,23 @@ pub fn execute_command( cwd, None, None, - &[], - ) + &[HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1.to_string()], + )) +} + +fn into_legacy_result(result: PolicyCommandResult) -> CommandResult { + match result { + PolicyCommandResult::Plan(commands, parallel) + | PolicyCommandResult::PlanWithPolicy(commands, parallel, _) => { + CommandResult::Plan(commands, parallel) + } + PolicyCommandResult::FullPlan(plan) | PolicyCommandResult::FullPlanWithPolicy(plan, _) => { + CommandResult::FullPlan(plan) + } + PolicyCommandResult::Message(message) => CommandResult::Message(message), + PolicyCommandResult::Error(error) => CommandResult::Error(error), + PolicyCommandResult::ShowHelp(error) => CommandResult::ShowHelp(error), + } } /// Execute a Rust/Cargo command after negotiating host execution behavior. @@ -381,7 +397,7 @@ pub fn execute_command_with_host_capabilities( provided_projects: &[String], cwd: &Path, host_capabilities: &[String], -) -> CommandResult { +) -> PolicyCommandResult { execute_command_with_filters( command, args, @@ -405,11 +421,11 @@ pub fn execute_command_with_filters( include_filters: Option<&[String]>, exclude_filters: Option<&[String]>, host_capabilities: &[String], -) -> CommandResult { +) -> PolicyCommandResult { let mut command_parts = command.split_whitespace(); let namespace = command_parts.next().unwrap_or_default(); if !matches!(namespace, "cargo" | "rust") { - return CommandResult::ShowHelp(Some(format!( + return PolicyCommandResult::ShowHelp(Some(format!( "unrecognized Rust plugin namespace '{command}'" ))); } @@ -422,14 +438,14 @@ pub fn execute_command_with_filters( // Help is Meta-aware and deliberately side-effect-free. Cargo/test-binary // payload after `--` is opaque and must never be interpreted here. if cargo_args.is_empty() || help_requested(&cargo_args) { - return CommandResult::ShowHelp(None); + return PolicyCommandResult::ShowHelp(None); } if !host_capabilities .iter() .any(|capability| capability == HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1) { - return CommandResult::Error(format!( + return PolicyCommandResult::Error(format!( "Cargo operations require host capability '{HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1}'" )); } @@ -437,7 +453,9 @@ pub fn execute_command_with_filters( // Get all project directories let dirs = match get_project_directories(provided_projects, cwd) { Ok(d) => d, - Err(e) => return CommandResult::Error(format!("Failed to get project directories: {e}")), + Err(e) => { + return PolicyCommandResult::Error(format!("Failed to get project directories: {e}")); + } }; // Apply the host-selected scope before filtering to Rust projects so an @@ -446,7 +464,9 @@ pub fn execute_command_with_filters( let rust_dirs = filter_rust_projects(&selected_dirs); if rust_dirs.is_empty() { - return CommandResult::Message("No Rust projects found (no Cargo.toml files)".to_string()); + return PolicyCommandResult::Message( + "No Rust projects found (no Cargo.toml files)".to_string(), + ); } // Cargo owns subcommand validation, aliases, and installed cargo-* tools. @@ -457,7 +477,7 @@ pub fn execute_command_with_filters( cargo_tokens.extend(cargo_args); let cargo_cmd = match serialize_shell_command(&cargo_tokens) { Ok(command) => command, - Err(error) => return CommandResult::Error(error.to_string()), + Err(error) => return PolicyCommandResult::Error(error.to_string()), }; let cargo_env = shell_transport_environment(&cargo_tokens); @@ -471,7 +491,7 @@ pub fn execute_command_with_filters( }) .collect(); - CommandResult::PlanWithPolicy( + PolicyCommandResult::PlanWithPolicy( commands, Some(parallel), PlanExecutionPolicy { @@ -511,6 +531,19 @@ pub fn plugin_help() -> PluginHelp { } } +/// Get the legacy plain-text help representation for library callers. +pub fn get_help_text() -> &'static str { + r#"meta rust - Rust/Cargo Plugin + +Commands: + meta cargo Run any Cargo command across selected Rust projects + meta rust Alias for the cargo namespace + +The plugin selects directories containing Cargo.toml. Cargo validates the +command and its arguments. +"# +} + #[cfg(test)] mod tests { use super::*; @@ -539,7 +572,7 @@ mod tests { parallel: bool, provided_projects: &[String], cwd: &Path, - ) -> CommandResult { + ) -> PolicyCommandResult { execute_command_with_host_capabilities( command, args, @@ -559,7 +592,7 @@ mod tests { cwd: &Path, include_filters: Option<&[String]>, exclude_filters: Option<&[String]>, - ) -> CommandResult { + ) -> PolicyCommandResult { execute_command_with_filters( command, args, @@ -572,9 +605,9 @@ mod tests { ) } - fn expect_plan(result: CommandResult) -> (Vec, Option) { + fn expect_plan(result: PolicyCommandResult) -> (Vec, Option) { match result { - CommandResult::PlanWithPolicy(commands, parallel, execution_policy) => { + PolicyCommandResult::PlanWithPolicy(commands, parallel, execution_policy) => { assert_eq!( execution_policy, PlanExecutionPolicy { @@ -611,6 +644,30 @@ mod tests { } assert!(help.note.as_deref().unwrap().contains("Cargo validates")); assert!(!help.note.as_deref().unwrap().contains("meta exec")); + assert!(get_help_text().contains("meta cargo ")); + } + + #[test] + fn test_legacy_execute_command_remains_operational() { + let temp_dir = TempDir::new().unwrap(); + create_rust_project(temp_dir.path()); + let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; + + let result = execute_command( + "cargo", + &strings(&["clean"]), + false, + &projects, + temp_dir.path(), + ); + + match result { + CommandResult::Plan(commands, Some(false)) => { + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].cmd, "cargo clean"); + } + _ => panic!("Expected legacy Plan result"), + } } #[test] @@ -632,17 +689,18 @@ mod tests { create_rust_project(temp_dir.path()); let projects = vec![temp_dir.path().to_string_lossy().into_owned()]; - let result = execute_command( + let result = execute_command_with_host_capabilities( "cargo", &strings(&["check"]), false, &projects, temp_dir.path(), + &[], ); assert!(matches!( result, - CommandResult::Error(message) + PolicyCommandResult::Error(message) if message.contains(HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1) )); } @@ -658,7 +716,7 @@ mod tests { execute_capable_command("cargo", &strings(&["build"]), false, &[], temp_dir.path()); match result { - CommandResult::Message(msg) => assert!(msg.contains("No Rust projects")), + PolicyCommandResult::Message(msg) => assert!(msg.contains("No Rust projects")), _ => panic!("Expected Message result"), } } @@ -828,7 +886,7 @@ mod tests { ); assert!(matches!( excluded, - CommandResult::Message(message) if message.contains("No Rust projects found") + PolicyCommandResult::Message(message) if message.contains("No Rust projects found") )); } @@ -856,7 +914,7 @@ mod tests { ); assert!(matches!( include_docs, - CommandResult::Message(message) if message.contains("No Rust projects found") + PolicyCommandResult::Message(message) if message.contains("No Rust projects found") )); let exclude_filters = vec![root.to_string_lossy().into_owned()]; @@ -871,7 +929,7 @@ mod tests { ); assert!(matches!( exclude_all, - CommandResult::Message(message) if message.contains("No Rust projects found") + PolicyCommandResult::Message(message) if message.contains("No Rust projects found") )); } @@ -1090,7 +1148,7 @@ mod tests { max_parallel: None, spawn_stagger_ms: None, }; - let response = PlanResponse { + let response = PlanResponseWithPolicy { plan, execution_policy: PlanExecutionPolicy { expand_loop_aliases: false, diff --git a/src/main.rs b/src/main.rs index dcbff12..fd33a5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ //! meta-rust subprocess plugin use meta_plugin_protocol::{ - run_plugin, CommandResult, PluginDefinition, PluginInfo, PluginRequest, + run_plugin_with_capabilities as run_plugin, CommandResultWithPolicy as CommandResult, + PluginDefinitionWithCapabilities as PluginDefinition, PluginInfo, + PluginRequestWithCapabilities as PluginRequest, }; use std::path::PathBuf; diff --git a/tests/plugin_protocol.rs b/tests/plugin_protocol.rs new file mode 100644 index 0000000..f6a5798 --- /dev/null +++ b/tests/plugin_protocol.rs @@ -0,0 +1,51 @@ +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +fn invoke_plugin(request: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_meta-rust")) + .arg("--meta-plugin-exec") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + child + .stdin + .take() + .unwrap() + .write_all(request.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn capability_free_operations_fail_closed_but_help_remains_available() { + let operation = invoke_plugin( + r#"{ + "command": "cargo", + "args": ["check"], + "projects": [], + "cwd": ".", + "options": {} + }"#, + ); + assert!(!operation.status.success()); + assert!(String::from_utf8_lossy(&operation.stderr).contains("plan-execution-policy-v1")); + + let help = invoke_plugin( + r#"{ + "command": "cargo", + "args": ["--help"], + "projects": [], + "cwd": ".", + "options": {} + }"#, + ); + assert!( + help.status.success(), + "stderr: {}", + String::from_utf8_lossy(&help.stderr) + ); + assert!(String::from_utf8_lossy(&help.stdout).contains("meta [META OPTIONS] cargo")); +} diff --git a/tests/windows_cmd.rs b/tests/windows_cmd.rs index 265fef9..8cf381f 100644 --- a/tests/windows_cmd.rs +++ b/tests/windows_cmd.rs @@ -1,7 +1,8 @@ #![cfg(windows)] use meta_rust_cli::{ - execute_command_with_host_capabilities, CommandResult, HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1, + execute_command_with_host_capabilities, PolicyCommandResult, + HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1, }; use std::ffi::OsString; use std::process::Command; @@ -104,7 +105,7 @@ fn planned_command_survives_the_real_cmd_boundary_without_injection() { temp.path(), &capabilities, ) { - CommandResult::PlanWithPolicy(commands, _, execution_policy) => { + PolicyCommandResult::PlanWithPolicy(commands, _, execution_policy) => { assert!(!execution_policy.expand_loop_aliases); assert!(!execution_policy.apply_host_filters); commands.into_iter().next().unwrap() @@ -171,5 +172,5 @@ fn planned_command_rejects_cmd_line_breaks() { temp.path(), &capabilities, ); - assert!(matches!(result, CommandResult::Error(message) if message.contains("cmd.exe"))); + assert!(matches!(result, PolicyCommandResult::Error(message) if message.contains("cmd.exe"))); } From c540315f0c4c9217cb63818441af4857d7e7eea6 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 14:42:55 -0700 Subject: [PATCH 10/10] feat: declare Rust namespace help roots [[tasks/meta-cargo-command-forwarding]] --- src/main.rs | 1 + tests/plugin_protocol.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/main.rs b/src/main.rs index fd33a5e..3d39f10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ fn main() { description: Some("Cargo commands for Meta workspaces".to_string()), help: Some(meta_rust_cli::plugin_help()), }, + bare_help_commands: vec!["cargo".to_string(), "rust".to_string()], execute, }); } diff --git a/tests/plugin_protocol.rs b/tests/plugin_protocol.rs index f6a5798..8d82e0f 100644 --- a/tests/plugin_protocol.rs +++ b/tests/plugin_protocol.rs @@ -1,6 +1,21 @@ use std::io::Write; use std::process::{Command, Output, Stdio}; +#[test] +fn discovery_declares_bare_namespace_help() { + let output = Command::new(env!("CARGO_BIN_EXE_meta-rust")) + .arg("--meta-plugin-info") + .output() + .unwrap(); + assert!(output.status.success()); + + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + info["bare_help_commands"], + serde_json::json!(["cargo", "rust"]) + ); +} + fn invoke_plugin(request: &str) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_meta-rust")) .arg("--meta-plugin-exec")