From 1762d973308eaee1602bdcdbee0d7652289da160 Mon Sep 17 00:00:00 2001 From: Xiaoguang Sun Date: Sun, 8 Mar 2026 22:45:25 -0700 Subject: [PATCH 1/4] Add shim to make rtk work with Codex RTK is great, I really want to use it with Codex as well. Unfortunately, Codex does not have a PreToolUse hook. This commit implements a shim to override supported tools within a customized directory in the PATH to make it work with Codex. Signed-off-by: Xiaoguang Sun --- src/main.rs | 484 ++++++++++++++++++++++++++++++++++------- src/metadata.rs | 393 +++++++++++++++++++++++++++++++++ src/shim.rs | 118 ++++++++++ src/utils.rs | 562 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1475 insertions(+), 82 deletions(-) create mode 100644 src/metadata.rs create mode 100644 src/shim.rs diff --git a/src/main.rs b/src/main.rs index 7a1c015919..53175450ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod lint_cmd; mod local_llm; mod log_cmd; mod ls; +mod metadata; mod mypy_cmd; mod next_cmd; mod npm_cmd; @@ -45,6 +46,7 @@ mod read; mod rewrite_cmd; mod ruff_cmd; mod runner; +mod shim; mod summary; mod tee; mod telemetry; @@ -418,6 +420,12 @@ enum Commands { create: bool, }, + /// Install operational_command shims + Shim { + #[command(subcommand)] + command: ShimCommands, + }, + /// Vitest commands with compact output Vitest { #[command(subcommand)] @@ -811,6 +819,27 @@ enum KubectlCommands { Other(Vec), } +#[derive(Subcommand)] +enum ShimCommands { + /// Install operational_command shims that point to the rtk binary + Install { + /// Target directory for operational_command shims + #[arg(long)] + bin_dir: Option, + /// Path to rtk binary (default: current executable) + #[arg(long)] + rtk_bin: Option, + /// Replace existing entries + #[arg(long)] + force: bool, + /// Replace arbitrary existing files (not just existing shim symlinks) + #[arg(long)] + force_all: bool, + /// Explicit operational_commands to install (default: all Shim-eligible operational_commands) + operational_commands: Vec, + }, +} + #[derive(Subcommand)] enum VitestCommands { /// Run tests with filtered output (90% token reduction) @@ -937,19 +966,18 @@ enum GoCommands { /// RTK-only subcommands that should never fall back to raw execution. /// If Clap fails to parse these, show the Clap error directly. -const RTK_META_COMMANDS: &[&str] = &[ - "gain", - "discover", - "learn", - "init", - "config", - "proxy", - "hook-audit", - "cc-economics", -]; - -fn run_fallback(parse_error: clap::Error) -> Result<()> { - let args: Vec = std::env::args().skip(1).collect(); +fn run_fallback(parse_error: clap::Error, parse_argv: &[OsString]) -> Result<()> { + // If argv[0] is a known-but-excluded operational_command (e.g. manually symlinked `gain -> rtk`), + // do not treat positional args as fallback executable names. + if shim::should_block_fallback_for_excluded_shim_command(parse_argv) { + parse_error.exit(); + } + + let args: Vec = parse_argv + .iter() + .skip(1) + .map(|s| s.to_string_lossy().into_owned()) + .collect(); // No args → show Clap's error (user ran just "rtk" with bad syntax) if args.is_empty() { @@ -958,7 +986,7 @@ fn run_fallback(parse_error: clap::Error) -> Result<()> { // RTK meta-commands should never fall back to raw execution. // e.g. `rtk gain --badtypo` should show Clap's error, not try to run `gain` from $PATH. - if RTK_META_COMMANDS.contains(&args[0].as_str()) { + if metadata::is_rtk_meta_command(&args[0]) { parse_error.exit(); } @@ -968,7 +996,16 @@ fn run_fallback(parse_error: clap::Error) -> Result<()> { // Start timer before execution to capture actual command runtime let timer = tracking::TimedExecution::start(); - let status = std::process::Command::new(&args[0]) + let mut fallback_cmd = match utils::native_command(&args[0]) { + Ok(cmd) => cmd, + Err(e) => { + tracking::record_parse_failure_silent(&raw_command, &error_message, false); + eprintln!("[rtk: fallback failed: {}]", e); + parse_error.exit(); + } + }; + + let status = fallback_cmd .args(&args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) @@ -1040,20 +1077,24 @@ fn main() -> Result<()> { // Warn if installed hook is outdated (1/day, non-blocking) hook_check::maybe_warn(); - let cli = match Cli::try_parse() { + let Some(parse_argv) = shim::prepare_runtime_parse_argv()? else { + return Ok(()); + }; + + let cli = match Cli::try_parse_from(parse_argv.clone()) { Ok(cli) => cli, Err(e) => { if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) { e.exit(); } - return run_fallback(e); + return run_fallback(e, &parse_argv); } }; // Runtime integrity check for operational commands. // Meta commands (init, gain, verify, config, etc.) skip the check // because they don't go through the hook pipeline. - if is_operational_command(&cli.command) { + if metadata::is_operational_command_from_parse_argv(&parse_argv) { integrity::runtime_check()?; } @@ -1490,6 +1531,31 @@ fn main() -> Result<()> { } } + Commands::Shim { command } => match command { + ShimCommands::Install { + bin_dir, + rtk_bin, + force, + force_all, + operational_commands, + } => { + let allowed = metadata::shim_eligible_top_level_commands(); + let operational_commands = + utils::resolve_shim_operational_commands(&operational_commands, &allowed)?; + if operational_commands.is_empty() { + anyhow::bail!("no operational_commands to shim"); + } + + utils::install_operational_command_shims( + bin_dir, + rtk_bin, + force, + force_all, + &operational_commands, + )?; + } + }, + Commands::Vitest { command } => match command { VitestCommands::Run { args } => { vitest_cmd::run(vitest_cmd::VitestCommand::Run, &args, cli.verbose)?; @@ -1879,64 +1945,252 @@ fn main() -> Result<()> { Ok(()) } -/// Returns true for commands that are invoked via the hook pipeline -/// (i.e., commands that process rewritten shell commands). -/// Meta commands (init, gain, verify, etc.) are excluded because -/// they are run directly by the user, not through the hook. -/// Returns true for commands that go through the hook pipeline -/// and therefore require integrity verification. -/// -/// SECURITY: whitelist pattern — new commands are NOT integrity-checked -/// until explicitly added here. A forgotten command fails open (no check) -/// rather than creating false confidence about what's protected. -fn is_operational_command(cmd: &Commands) -> bool { - matches!( - cmd, - Commands::Ls { .. } - | Commands::Tree { .. } - | Commands::Read { .. } - | Commands::Smart { .. } - | Commands::Git { .. } - | Commands::Gh { .. } - | Commands::Pnpm { .. } - | Commands::Err { .. } - | Commands::Test { .. } - | Commands::Json { .. } - | Commands::Deps { .. } - | Commands::Env { .. } - | Commands::Find { .. } - | Commands::Diff { .. } - | Commands::Log { .. } - | Commands::Docker { .. } - | Commands::Kubectl { .. } - | Commands::Summary { .. } - | Commands::Grep { .. } - | Commands::Wget { .. } - | Commands::Vitest { .. } - | Commands::Prisma { .. } - | Commands::Tsc { .. } - | Commands::Next { .. } - | Commands::Lint { .. } - | Commands::Prettier { .. } - | Commands::Playwright { .. } - | Commands::Cargo { .. } - | Commands::Npm { .. } - | Commands::Npx { .. } - | Commands::Curl { .. } - | Commands::Ruff { .. } - | Commands::Pytest { .. } - | Commands::Pip { .. } - | Commands::Go { .. } - | Commands::GolangciLint { .. } - | Commands::Gt { .. } - ) -} - #[cfg(test)] mod tests { use super::*; use clap::Parser; + fn os_argv(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + #[test] + fn test_build_parse_argv_rewrites_known_operational_command() { + let raw = vec![ + OsString::from("/tmp/git"), + OsString::from("status"), + OsString::from("-s"), + ]; + let rewritten = shim::build_parse_argv(&raw); + assert_eq!( + rewritten, + vec![ + OsString::from("rtk"), + OsString::from("git"), + OsString::from("status"), + OsString::from("-s") + ] + ); + } + + #[test] + fn test_build_parse_argv_rewrites_hyphenated_operational_command() { + let raw = vec![ + OsString::from("/usr/local/bin/golangci-lint"), + OsString::from("run"), + ]; + let rewritten = shim::build_parse_argv(&raw); + assert_eq!( + rewritten, + vec![ + OsString::from("rtk"), + OsString::from("golangci-lint"), + OsString::from("run") + ] + ); + } + + #[test] + fn test_build_parse_argv_does_not_rewrite_rtk_binary() { + let raw = vec![ + OsString::from("/usr/local/bin/rtk"), + OsString::from("git"), + OsString::from("status"), + ]; + assert_eq!(shim::build_parse_argv(&raw), raw); + } + + #[test] + fn test_build_parse_argv_does_not_rewrite_unknown_operational_command() { + let raw = vec![OsString::from("/tmp/random-tool"), OsString::from("status")]; + assert_eq!(shim::build_parse_argv(&raw), raw); + } + + #[test] + fn test_fallback_guard_blocks_excluded_shim_command_symlink() { + let gain_like = vec![OsString::from("/tmp/gain"), OsString::from("--bad-flag")]; + assert!(shim::should_block_fallback_for_excluded_shim_command( + &gain_like + )); + + let git_like = vec![OsString::from("/tmp/git"), OsString::from("--bad-flag")]; + assert!(!shim::should_block_fallback_for_excluded_shim_command( + &git_like + )); + } + + #[test] + fn test_operational_command_name_from_argv0_strips_exe_suffix() { + let name = shim::operational_command_name_from_argv0(std::ffi::OsStr::new("git.exe")); + assert_eq!(name.as_deref(), Some("git")); + } + + #[test] + fn test_supported_top_level_commands_contains_known_items() { + let commands = metadata::supported_top_level_commands(); + assert!(commands.iter().any(|c| c == "git")); + assert!(commands.iter().any(|c| c == "curl")); + assert!(commands.iter().any(|c| c == "shim")); + } + + #[test] + fn test_shim_eligible_commands_excludes_meta_and_rtk_native() { + let commands = metadata::shim_eligible_top_level_commands(); + assert!(commands.iter().any(|c| c == "git")); + assert!(commands.iter().any(|c| c == "curl")); + assert!(commands.iter().any(|c| c == "aws")); + assert!(commands.iter().any(|c| c == "psql")); + assert!(commands.iter().any(|c| c == "wc")); + assert!(commands.iter().any(|c| c == "mypy")); + assert!(!commands.iter().any(|c| c == "gain")); + assert!(!commands.iter().any(|c| c == "read")); + assert!(!commands.iter().any(|c| c == "shim")); + } + + #[test] + fn test_shim_eligible_commands_are_operational() { + for cmd in metadata::shim_eligible_top_level_commands() { + assert!( + metadata::top_level_command_metadata(&cmd) + .map(|meta| meta.operational) + .unwrap_or(false), + "Expected '{}' to be operational", + cmd + ); + } + } + + #[test] + fn test_top_level_command_metadata_covers_supported_commands() { + for cmd in metadata::supported_top_level_commands() { + assert!( + metadata::top_level_command_metadata(&cmd).is_some(), + "Missing top-level metadata for command '{}'", + cmd + ); + } + } + + #[test] + fn test_top_level_command_metadata_has_no_unknown_commands() { + for meta in metadata::TOP_LEVEL_COMMAND_METADATA { + assert!( + metadata::is_supported_top_level_command(meta.name), + "Unknown command '{}' exists in top-level metadata", + meta.name + ); + } + } + + #[test] + fn test_top_level_command_metadata_commands_match_expected_set() { + let actual: std::collections::BTreeSet<&str> = metadata::TOP_LEVEL_COMMAND_METADATA + .iter() + .filter(|meta| meta.metadata) + .map(|meta| meta.name) + .collect(); + + let expected: std::collections::BTreeSet<&str> = [ + "cc-economics", + "config", + "discover", + "gain", + "hook-audit", + "init", + "learn", + "proxy", + "rewrite", + "shim", + "verify", + ] + .into_iter() + .collect(); + + assert_eq!(actual, expected, "metadata command set drifted"); + } + + #[test] + fn test_metadata_commands_are_not_shim_eligible() { + for meta in metadata::TOP_LEVEL_COMMAND_METADATA + .iter() + .filter(|meta| meta.metadata) + { + assert!( + !meta.shim, + "metadata command '{}' must not be shim-eligible", + meta.name + ); + assert!( + !metadata::is_shim_eligible_top_level_command(meta.name), + "metadata command '{}' must not be shim-eligible at runtime", + meta.name + ); + } + } + + #[test] + fn test_shim_install_parses() { + let cli = Cli::try_parse_from([ + "rtk", + "shim", + "install", + "--force", + "--force-all", + "--bin-dir", + "/tmp/rtk-shims", + "--rtk-bin", + "/usr/local/bin/rtk", + "git", + "curl", + ]) + .unwrap(); + match cli.command { + Commands::Shim { command } => match command { + ShimCommands::Install { + bin_dir, + rtk_bin, + force, + force_all, + operational_commands, + } => { + assert!(force); + assert!(force_all); + assert_eq!(bin_dir.as_deref(), Some(Path::new("/tmp/rtk-shims"))); + assert_eq!(rtk_bin.as_deref(), Some(Path::new("/usr/local/bin/rtk"))); + assert_eq!(operational_commands, vec!["git", "curl"]); + } + }, + _ => panic!("Expected Shim command"), + } + } + + #[test] + fn test_link_command_removed() { + let result = Cli::try_parse_from(["rtk", "link", "install", "git"]); + assert!( + result.is_err(), + "legacy 'link' command should not parse after shim rename" + ); + } + + #[test] + fn test_resolve_shim_operational_commands_rejects_non_eligible() { + let allowed = metadata::shim_eligible_top_level_commands(); + let err = + utils::resolve_shim_operational_commands(&["gain".to_string()], &allowed).unwrap_err(); + assert!( + err.to_string().contains("not Shim-eligible"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_build_parse_argv_does_not_rewrite_excluded_shim_command() { + let raw = vec![OsString::from("/tmp/gain"), OsString::from("--graph")]; + assert_eq!(shim::build_parse_argv(&raw), raw); + } + #[test] fn test_git_commit_single_message() { let cli = Cli::try_parse_from(["rtk", "git", "commit", "-m", "fix: typo"]).unwrap(); @@ -2140,41 +2394,107 @@ mod tests { #[test] fn test_meta_commands_reject_bad_flags() { - // RTK meta-commands should produce parse errors (not fall through to raw execution). + // RTK-native commands should produce parse errors (not fall through to raw execution). // Skip "proxy" because it uses trailing_var_arg (accepts any args by design). - for cmd in RTK_META_COMMANDS { - if *cmd == "proxy" { - continue; - } + let guarded = [ + "gain", + "discover", + "learn", + "init", + "config", + "shim", + "hook-audit", + "cc-economics", + "verify", + ]; + for cmd in guarded { let result = Cli::try_parse_from(["rtk", cmd, "--nonexistent-flag-xyz"]); assert!( result.is_err(), - "Meta-command '{}' with bad flag should fail to parse", + "Guarded command '{}' with bad flag should fail to parse", cmd ); } } #[test] - fn test_meta_command_list_is_complete() { - // Verify all meta-commands are in the guard list by checking they parse with valid syntax - let meta_cmds_that_parse = [ + fn test_guarded_command_list_parses_valid_invocations() { + let guarded_cmds_that_parse = [ vec!["rtk", "gain"], vec!["rtk", "discover"], vec!["rtk", "learn"], vec!["rtk", "init"], vec!["rtk", "config"], + vec!["rtk", "shim", "install", "git"], vec!["rtk", "proxy", "echo", "hi"], vec!["rtk", "hook-audit"], vec!["rtk", "cc-economics"], + vec!["rtk", "verify"], + vec!["rtk", "rewrite", "git status"], ]; - for args in &meta_cmds_that_parse { + for args in &guarded_cmds_that_parse { let result = Cli::try_parse_from(args.iter()); assert!( result.is_ok(), - "Meta-command {:?} should parse successfully", + "Guarded command {:?} should parse successfully", args ); } } + + #[test] + fn test_shim_exclusion_policy_covers_guarded_commands() { + for cmd in [ + "gain", + "discover", + "learn", + "init", + "config", + "shim", + "proxy", + "hook-audit", + "cc-economics", + "rewrite", + "verify", + "read", + "smart", + ] { + assert!(metadata::is_supported_top_level_command(cmd)); + assert!( + !metadata::is_shim_eligible_top_level_command(cmd), + "Expected '{}' to be excluded from Shim operational_command mode", + cmd + ); + } + } + + #[test] + fn test_shim_is_not_operational() { + assert!(!metadata::is_operational_command_from_parse_argv(&os_argv( + &["rtk", "shim", "install", "git",] + ))); + } + + #[test] + fn test_git_is_operational() { + assert!(metadata::is_operational_command_from_parse_argv(&os_argv( + &["rtk", "git", "status",] + ))); + } + + #[test] + fn test_aws_psql_wc_mypy_are_operational() { + assert!(metadata::is_operational_command_from_parse_argv(&os_argv( + &["rtk", "aws", "sts",] + ))); + assert!(metadata::is_operational_command_from_parse_argv(&os_argv( + &["rtk", "psql",] + ))); + assert!(metadata::is_operational_command_from_parse_argv(&os_argv( + &["rtk", "wc",] + ))); + assert!(metadata::is_operational_command_from_parse_argv(&os_argv( + &["rtk", "mypy",] + ))); + } } diff --git a/src/metadata.rs b/src/metadata.rs new file mode 100644 index 0000000000..d4542460a5 --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,393 @@ +use clap::CommandFactory; +use std::ffi::OsString; + +use crate::Cli; + +#[derive(Clone, Copy)] +pub(crate) struct TopLevelCommandMetadata { + pub(crate) name: &'static str, + pub(crate) operational: bool, + pub(crate) shim: bool, + pub(crate) metadata: bool, +} + +pub(crate) const TOP_LEVEL_COMMAND_METADATA: &[TopLevelCommandMetadata] = &[ + TopLevelCommandMetadata { + name: "aws", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "cargo", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "cc-economics", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "config", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "curl", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "deps", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "diff", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "discover", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "docker", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "env", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "err", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "find", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "format", + operational: false, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "gain", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "gh", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "git", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "go", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "golangci-lint", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "grep", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "gt", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "hook-audit", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "init", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "json", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "kubectl", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "learn", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "lint", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "log", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "ls", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "mypy", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "next", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "npm", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "npx", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "pip", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "playwright", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "pnpm", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "prettier", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "prisma", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "proxy", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "psql", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "pytest", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "read", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "rewrite", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "ruff", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "shim", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "smart", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "summary", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "test", + operational: true, + shim: false, + metadata: false, + }, + TopLevelCommandMetadata { + name: "tree", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "tsc", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "verify", + operational: false, + shim: false, + metadata: true, + }, + TopLevelCommandMetadata { + name: "vitest", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "wc", + operational: true, + shim: true, + metadata: false, + }, + TopLevelCommandMetadata { + name: "wget", + operational: true, + shim: true, + metadata: false, + }, +]; + +pub(crate) fn is_rtk_meta_command(name: &str) -> bool { + top_level_command_metadata(name) + .map(|meta| meta.metadata) + .unwrap_or(false) +} + +pub(crate) fn supported_top_level_commands() -> Vec { + let mut names: Vec = Cli::command() + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + names.sort(); + names +} + +pub(crate) fn is_supported_top_level_command(name: &str) -> bool { + Cli::command() + .get_subcommands() + .any(|sub| sub.get_name() == name) +} + +pub(crate) fn top_level_command_metadata(name: &str) -> Option<&'static TopLevelCommandMetadata> { + TOP_LEVEL_COMMAND_METADATA + .iter() + .find(|meta| meta.name == name) +} + +pub(crate) fn is_operational_command_from_parse_argv(parse_argv: &[OsString]) -> bool { + let Ok(matches) = Cli::command().try_get_matches_from(parse_argv.iter().cloned()) else { + return false; + }; + let Some(command_name) = matches.subcommand_name() else { + return false; + }; + + top_level_command_metadata(command_name) + .map(|meta| meta.operational) + .unwrap_or(false) +} + +pub(crate) fn shim_eligible_top_level_commands() -> Vec { + let mut names: Vec = supported_top_level_commands() + .into_iter() + .filter(|name| { + top_level_command_metadata(name) + .map(|meta| meta.shim) + .unwrap_or(false) + }) + .collect(); + names.sort(); + names +} + +pub(crate) fn is_shim_eligible_top_level_command(name: &str) -> bool { + is_supported_top_level_command(name) + && top_level_command_metadata(name) + .map(|meta| meta.shim) + .unwrap_or(false) +} diff --git a/src/shim.rs b/src/shim.rs new file mode 100644 index 0000000000..2ebccefbc7 --- /dev/null +++ b/src/shim.rs @@ -0,0 +1,118 @@ +use anyhow::{Context, Result}; +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +use crate::{metadata, utils}; + +const RTK_OPERATIONAL_COMMAND_BYPASS_ENV: &str = "RTK_BYPASS_OPERATIONAL_COMMAND_SHIMS"; +const RTK_RECURSION_DEPTH_ENV: &str = "RTK_RECURSION_DEPTH"; +const RTK_RECURSION_DEPTH_LIMIT: u32 = 32; + +pub(crate) fn operational_command_name_from_argv0(argv0: &OsStr) -> Option { + let basename = Path::new(argv0).file_name()?.to_string_lossy(); + let trimmed = basename.strip_suffix(".exe").unwrap_or(&basename); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub(crate) fn build_parse_argv(raw_argv: &[OsString]) -> Vec { + if raw_argv.is_empty() { + return vec![OsString::from("rtk")]; + } + + let Some(operational_command) = operational_command_name_from_argv0(&raw_argv[0]) else { + return raw_argv.to_vec(); + }; + + if !metadata::is_shim_eligible_top_level_command(&operational_command) { + return raw_argv.to_vec(); + } + + let mut parse_argv = Vec::with_capacity(raw_argv.len() + 1); + parse_argv.push(OsString::from("rtk")); + parse_argv.push(OsString::from(operational_command)); + parse_argv.extend(raw_argv.iter().skip(1).cloned()); + parse_argv +} + +fn current_recursion_depth() -> u32 { + std::env::var(RTK_RECURSION_DEPTH_ENV) + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) +} + +fn install_child_bypass_env(depth: u32) { + std::env::set_var(RTK_OPERATIONAL_COMMAND_BYPASS_ENV, "1"); + std::env::set_var(RTK_RECURSION_DEPTH_ENV, (depth + 1).to_string()); +} + +fn maybe_exec_native_operational_command(raw_argv: &[OsString]) -> Result { + if std::env::var(RTK_OPERATIONAL_COMMAND_BYPASS_ENV).unwrap_or_default() != "1" { + return Ok(false); + } + if raw_argv.is_empty() { + return Ok(false); + } + + let Some(operational_command) = operational_command_name_from_argv0(&raw_argv[0]) else { + return Ok(false); + }; + if !metadata::is_shim_eligible_top_level_command(&operational_command) { + return Ok(false); + } + + let mut cmd = utils::native_command(&operational_command).with_context(|| { + format!( + "Failed to resolve native command for '{}'", + operational_command + ) + })?; + cmd.args(raw_argv.iter().skip(1)) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + let status = cmd + .status() + .with_context(|| format!("Failed to execute native '{}'", operational_command))?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(true) +} + +pub(crate) fn prepare_runtime_parse_argv() -> Result>> { + let raw_argv: Vec = std::env::args_os().collect(); + let recursion_depth = current_recursion_depth(); + if recursion_depth >= RTK_RECURSION_DEPTH_LIMIT { + anyhow::bail!( + "Detected recursive operational_command-shim invocation (depth={}). Refusing to continue.", + recursion_depth + ); + } + + if maybe_exec_native_operational_command(&raw_argv)? { + return Ok(None); + } + + // Child subprocesses should bypass operational_command rewrite and resolve native commands directly. + install_child_bypass_env(recursion_depth); + + Ok(Some(build_parse_argv(&raw_argv))) +} + +pub(crate) fn should_block_fallback_for_excluded_shim_command(parse_argv: &[OsString]) -> bool { + let Some(operational_command) = parse_argv + .first() + .and_then(|s| operational_command_name_from_argv0(s)) + else { + return false; + }; + + metadata::is_supported_top_level_command(&operational_command) + && !metadata::is_shim_eligible_top_level_command(&operational_command) +} diff --git a/src/utils.rs b/src/utils.rs index afd39334ab..53298f3890 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,8 +7,16 @@ use anyhow::{Context, Result}; use regex::Regex; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + /// Truncates a string to `max_len` characters, appending "..." if needed. /// /// # Arguments @@ -80,6 +88,338 @@ pub fn execute_command(cmd: &str, args: &[&str]) -> Result<(String, String, i32) Ok((stdout, stderr, exit_code)) } +fn is_executable(path: &Path) -> bool { + let meta = match fs::metadata(path) { + Ok(m) => m, + Err(_) => return false, + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +fn is_single_normal_component(name: &str) -> bool { + let mut components = Path::new(name).components(); + matches!( + (components.next(), components.next()), + (Some(std::path::Component::Normal(_)), None) + ) +} + +fn is_same_executable(candidate: &Path, current_exe: &Path) -> bool { + let candidate_canon = fs::canonicalize(candidate).ok(); + let current_canon = fs::canonicalize(current_exe).ok(); + + if let (Some(a), Some(b)) = (&candidate_canon, ¤t_canon) { + if a == b { + return true; + } + #[cfg(unix)] + { + if let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) { + if ma.dev() == mb.dev() && ma.ino() == mb.ino() { + return true; + } + } + } + } + + #[cfg(unix)] + { + if let (Ok(ma), Ok(mb)) = (fs::metadata(candidate), fs::metadata(current_exe)) { + if ma.dev() == mb.dev() && ma.ino() == mb.ino() { + return true; + } + } + } + + false +} + +#[cfg(windows)] +fn command_candidates(program: &str) -> Vec { + let has_ext = Path::new(program).extension().is_some(); + if has_ext { + return vec![program.to_string()]; + } + let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + pathext + .split(';') + .filter(|s| !s.trim().is_empty()) + .map(|ext| format!("{program}{ext}")) + .collect() +} + +#[cfg(not(windows))] +fn command_candidates(program: &str) -> Vec { + vec![program.to_string()] +} + +fn resolve_non_self_from_paths( + program: &str, + paths: &[PathBuf], + current_exe: &Path, +) -> Option { + let candidates = command_candidates(program); + for dir in paths { + for candidate_name in &candidates { + let candidate = dir.join(candidate_name); + if !is_executable(&candidate) { + continue; + } + if is_same_executable(&candidate, current_exe) { + continue; + } + return Some(candidate); + } + } + None +} + +/// Resolve a command from PATH while skipping any entry that points to current rtk executable. +pub fn resolve_non_self_command(program: &str) -> Result { + let current_exe = env::current_exe().context("Failed to resolve current executable path")?; + + if program.contains('/') || program.contains('\\') { + let candidate = PathBuf::from(program); + if is_same_executable(&candidate, ¤t_exe) { + anyhow::bail!( + "Resolved command '{}' points to current rtk executable; refusing recursive invocation", + program + ); + } + return Ok(candidate); + } + + let path_env = env::var_os("PATH").unwrap_or_default(); + let paths: Vec = env::split_paths(&path_env).collect(); + if let Some(path) = resolve_non_self_from_paths(program, &paths, ¤t_exe) { + return Ok(path); + } + + anyhow::bail!( + "Unable to find native command '{}' in PATH without matching current rtk executable", + program + ) +} + +/// Build a command using the resolved native executable path (skips self-recursive links). +pub fn native_command(program: &str) -> Result { + let resolved = resolve_non_self_command(program)?; + Ok(Command::new(resolved)) +} + +pub fn default_shim_bin_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".local") + .join("rtk-shims") + .join("bin") +} + +pub fn resolve_shim_operational_commands( + requested: &[String], + allowed: &[String], +) -> Result> { + if requested.is_empty() { + return Ok(allowed.to_vec()); + } + + let mut operational_commands = Vec::with_capacity(requested.len()); + for operational_command in requested { + if !is_single_normal_component(operational_command) { + anyhow::bail!( + "invalid operational_command name: '{}'", + operational_command + ); + } + if !allowed + .iter() + .any(|allowed_operational_command| allowed_operational_command == operational_command) + { + anyhow::bail!( + "operational_command '{}' is not Shim-eligible in this rtk binary", + operational_command + ); + } + operational_commands.push(operational_command.clone()); + } + + Ok(operational_commands) +} + +fn create_operational_command_shim(target: &Path, link: &Path) -> Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link).with_context(|| { + format!( + "failed to create symlink '{}' -> '{}'", + link.display(), + target.display() + ) + })?; + Ok(()) + } + + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(target, link).with_context(|| { + format!( + "failed to create symlink '{}' -> '{}'", + link.display(), + target.display() + ) + })?; + Ok(()) + } + + #[cfg(not(any(unix, windows)))] + { + let _ = (target, link); + anyhow::bail!("symlink creation is not supported on this platform"); + } +} + +fn is_rtk_named_path(path: &Path) -> bool { + matches!( + path.file_name().and_then(|s| s.to_str()), + Some("rtk") | Some("rtk.exe") + ) +} + +fn is_rtk_shim_symlink(link_path: &Path, target_rtk: &Path) -> bool { + let Ok(link_target) = fs::read_link(link_path) else { + return false; + }; + + let resolved_target = if link_target.is_absolute() { + link_target + } else { + link_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(link_target) + }; + + if is_same_executable(&resolved_target, target_rtk) { + return true; + } + + if is_rtk_named_path(&resolved_target) { + return true; + } + + fs::canonicalize(&resolved_target) + .map(|p| is_rtk_named_path(&p)) + .unwrap_or(false) +} + +pub fn install_operational_command_shims( + bin_dir: Option, + rtk_bin: Option, + force: bool, + force_all: bool, + operational_commands: &[String], +) -> Result<()> { + let bin_dir = bin_dir.unwrap_or_else(default_shim_bin_dir); + fs::create_dir_all(&bin_dir).with_context(|| { + format!( + "failed to create operational_command-shim bin dir '{}'", + bin_dir.display() + ) + })?; + + let rtk_target = match rtk_bin { + Some(path) => path, + None => env::current_exe().context("failed to resolve current executable")?, + }; + + if !rtk_target.exists() { + anyhow::bail!("rtk binary does not exist: {}", rtk_target.display()); + } + if !rtk_target.is_file() { + anyhow::bail!("rtk binary is not a file: {}", rtk_target.display()); + } + if !is_executable(&rtk_target) { + anyhow::bail!("rtk binary is not executable: {}", rtk_target.display()); + } + + let rtk_abs = fs::canonicalize(&rtk_target).unwrap_or(rtk_target); + let mut created = 0usize; + let mut replaced = 0usize; + let mut skipped = 0usize; + + for operational_command in operational_commands { + let link_path = bin_dir.join(operational_command); + match fs::symlink_metadata(&link_path) { + Ok(meta) => { + if force_all { + if meta.file_type().is_dir() { + anyhow::bail!( + "refusing to replace existing directory: {}", + link_path.display() + ); + } + fs::remove_file(&link_path).with_context(|| { + format!("failed to remove existing entry '{}'", link_path.display()) + })?; + create_operational_command_shim(&rtk_abs, &link_path)?; + println!("replaced: {} -> {}", link_path.display(), rtk_abs.display()); + replaced += 1; + } else if force { + if meta.file_type().is_dir() { + anyhow::bail!( + "refusing to replace existing directory: {}", + link_path.display() + ); + } + if !meta.file_type().is_symlink() || !is_rtk_shim_symlink(&link_path, &rtk_abs) + { + anyhow::bail!( + "refusing to replace non-rtk shim '{}'; rerun with --force-all to replace arbitrary files", + link_path.display() + ); + } + fs::remove_file(&link_path).with_context(|| { + format!("failed to remove existing entry '{}'", link_path.display()) + })?; + create_operational_command_shim(&rtk_abs, &link_path)?; + println!("replaced: {} -> {}", link_path.display(), rtk_abs.display()); + replaced += 1; + } else { + println!("skipped (exists): {}", link_path.display()); + skipped += 1; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + create_operational_command_shim(&rtk_abs, &link_path)?; + println!("created: {} -> {}", link_path.display(), rtk_abs.display()); + created += 1; + } + Err(e) => { + return Err(e).with_context(|| { + format!("failed to inspect existing path '{}'", link_path.display()) + }); + } + } + } + + println!( + "done: created={} replaced={} skipped={}", + created, replaced, skipped + ); + + Ok(()) +} + /// Format a token count with K/M suffixes for readability. /// /// # Arguments @@ -262,6 +602,8 @@ pub fn package_manager_exec(tool: &str) -> Command { #[cfg(test)] mod tests { use super::*; + use std::io::Write; + use tempfile::tempdir; #[test] fn test_truncate_short_string() { @@ -429,4 +771,224 @@ mod tests { let result = truncate(cjk, 6); assert!(result.ends_with("...")); } + + #[test] + fn test_resolve_shim_operational_commands_rejects_non_normal_component() { + let allowed = vec![ + "git".to_string(), + ".".to_string(), + "..".to_string(), + "foo/bar".to_string(), + ]; + assert!(resolve_shim_operational_commands(&["git".to_string()], &allowed).is_ok()); + + for invalid in [".", "..", "foo/bar"] { + let err = resolve_shim_operational_commands(&[invalid.to_string()], &allowed) + .expect_err("expected invalid operational_command name to fail"); + assert!( + err.to_string() + .contains(&format!("invalid operational_command name: '{}'", invalid)), + "unexpected error: {}", + err + ); + } + } + + #[cfg(unix)] + fn create_executable_file(path: &std::path::Path) { + let mut f = fs::File::create(path).unwrap(); + writeln!(f, "#!/bin/sh").unwrap(); + writeln!(f, "exit 0").unwrap(); + drop(f); + + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); + } + + #[cfg(unix)] + #[test] + fn test_resolve_non_self_from_paths_skips_self_symlink() { + let current_exe = env::current_exe().unwrap(); + + let first = tempdir().unwrap(); + let second = tempdir().unwrap(); + + let fake_self = first.path().join("git"); + std::os::unix::fs::symlink(¤t_exe, &fake_self).unwrap(); + + let native_git = second.path().join("git"); + let mut f = fs::File::create(&native_git).unwrap(); + writeln!(f, "#!/bin/sh").unwrap(); + writeln!(f, "echo native").unwrap(); + drop(f); + + let mut perms = fs::metadata(&native_git).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&native_git, perms).unwrap(); + + let paths = vec![first.path().to_path_buf(), second.path().to_path_buf()]; + let resolved = resolve_non_self_from_paths("git", &paths, ¤t_exe).unwrap(); + assert_eq!(resolved, native_git); + } + + #[cfg(unix)] + #[test] + fn test_resolve_non_self_command_errors_when_only_self_exists() { + let current_exe = env::current_exe().unwrap(); + let dir = tempdir().unwrap(); + let fake_self = dir.path().join("ls"); + std::os::unix::fs::symlink(¤t_exe, &fake_self).unwrap(); + + let resolved = resolve_non_self_from_paths("ls", &[dir.path().to_path_buf()], ¤t_exe); + assert!(resolved.is_none()); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_create_skip_replace() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + let primary_dir = dir.path().join("primary"); + let secondary_dir = dir.path().join("secondary"); + fs::create_dir_all(&primary_dir).unwrap(); + fs::create_dir_all(&secondary_dir).unwrap(); + + let primary_rtk = primary_dir.join("rtk"); + let secondary_rtk = secondary_dir.join("rtk"); + create_executable_file(&primary_rtk); + create_executable_file(&secondary_rtk); + let primary_abs = fs::canonicalize(&primary_rtk).unwrap(); + + let commands = vec!["git".to_string(), "curl".to_string()]; + install_operational_command_shims( + Some(bin_dir.clone()), + Some(primary_rtk.clone()), + false, + false, + &commands, + ) + .unwrap(); + + let git_link = bin_dir.join("git"); + assert!(fs::symlink_metadata(&git_link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); + + install_operational_command_shims( + Some(bin_dir.clone()), + Some(primary_rtk.clone()), + false, + false, + &commands, + ) + .unwrap(); + assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); + + fs::remove_file(&git_link).unwrap(); + std::os::unix::fs::symlink(&secondary_rtk, &git_link).unwrap(); + + install_operational_command_shims( + Some(bin_dir.clone()), + Some(primary_rtk), + true, + false, + &["git".to_string()], + ) + .unwrap(); + assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_force_refuses_directory() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + fs::create_dir_all(bin_dir.join("git")).unwrap(); + + let rtk_bin = dir.path().join("rtk"); + create_executable_file(&rtk_bin); + + let err = install_operational_command_shims( + Some(bin_dir), + Some(rtk_bin), + true, + false, + &["git".to_string()], + ) + .expect_err("expected replacing directory to fail"); + assert!( + err.to_string() + .contains("refusing to replace existing directory"), + "unexpected error: {}", + err + ); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_force_refuses_non_shim_without_force_all() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("git"), "plain file").unwrap(); + + let rtk_bin = dir.path().join("rtk"); + create_executable_file(&rtk_bin); + + let err = install_operational_command_shims( + Some(bin_dir.clone()), + Some(rtk_bin.clone()), + true, + false, + &["git".to_string()], + ) + .expect_err("expected non-shim replacement to require --force-all"); + assert!( + err.to_string().contains("--force-all"), + "unexpected error: {}", + err + ); + + install_operational_command_shims( + Some(bin_dir.clone()), + Some(rtk_bin), + false, + true, + &["git".to_string()], + ) + .unwrap(); + assert!(fs::symlink_metadata(bin_dir.join("git")) + .unwrap() + .file_type() + .is_symlink()); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_rejects_non_executable_rtk_bin() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + let non_exec_rtk = dir.path().join("rtk"); + fs::write(&non_exec_rtk, "#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = fs::metadata(&non_exec_rtk).unwrap().permissions(); + perms.set_mode(0o644); + fs::set_permissions(&non_exec_rtk, perms).unwrap(); + + let err = install_operational_command_shims( + Some(bin_dir), + Some(non_exec_rtk), + false, + false, + &["git".to_string()], + ) + .expect_err("expected non-executable rtk bin to fail"); + assert!( + err.to_string().contains("not executable"), + "unexpected error: {}", + err + ); + } } From 4edaf9f02b8524352d9996b99552a3100f792764 Mon Sep 17 00:00:00 2001 From: Xiaoguang Sun Date: Sun, 8 Mar 2026 23:11:34 -0700 Subject: [PATCH 2/4] Update manual Signed-off-by: Xiaoguang Sun --- INSTALL.md | 46 ++++ src/main.rs | 8 +- src/shim.rs | 604 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/utils.rs | 562 ----------------------------------------------- 4 files changed, 651 insertions(+), 569 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 98457d09a9..843f7a885e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -238,6 +238,52 @@ rtk pnpm list rtk vitest run ``` +## Optional: Shim Mode for Codex (Unix/macOS) + +If you want Codex to run common tool names (`git`, `curl`, `docker`, `kubectl`, etc.) through RTK automatically, install Shim entrypoints: + +```bash +# Install all Shim-eligible commands to ~/.local/rtk-shims/bin +rtk shim install + +# Or install a focused subset while testing: +rtk shim install git gh curl +``` + +Notes: +- `rtk shim install` currently fails fast on Windows. +- Default shim location: `~/.local/rtk-shims/bin` + +### PATH strategies for Codex + +Use one of these depending on how aggressively you want to route commands: + +```bash +# 1) One-shot (recommended for experiments): +# Only this Codex process gets shim-first PATH. +PATH="$HOME/.local/rtk-shims/bin:$PATH" codex + +# 2) Current shell session: +# Useful when launching Codex multiple times in one terminal. +export PATH="$HOME/.local/rtk-shims/bin:$PATH" +codex + +# 3) Persistent (every new shell): +# Add shim path in your shell rc, then reopen terminal. +echo 'export PATH="$HOME/.local/rtk-shims/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc +codex +``` + +Quick sanity check before launching Codex: + +```bash +command -v git +# Expected (shim-first): ~/.local/rtk-shims/bin/git +``` + +To disable shim-first behavior, remove the shim path from `PATH` (or start Codex without that PATH override). + ## Uninstalling ### Complete Removal (Global Installations Only) diff --git a/src/main.rs b/src/main.rs index 53175450ff..9d0891da9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -996,7 +996,7 @@ fn run_fallback(parse_error: clap::Error, parse_argv: &[OsString]) -> Result<()> // Start timer before execution to capture actual command runtime let timer = tracking::TimedExecution::start(); - let mut fallback_cmd = match utils::native_command(&args[0]) { + let mut fallback_cmd = match shim::native_command(&args[0]) { Ok(cmd) => cmd, Err(e) => { tracking::record_parse_failure_silent(&raw_command, &error_message, false); @@ -1541,12 +1541,12 @@ fn main() -> Result<()> { } => { let allowed = metadata::shim_eligible_top_level_commands(); let operational_commands = - utils::resolve_shim_operational_commands(&operational_commands, &allowed)?; + shim::resolve_shim_operational_commands(&operational_commands, &allowed)?; if operational_commands.is_empty() { anyhow::bail!("no operational_commands to shim"); } - utils::install_operational_command_shims( + shim::install_operational_command_shims( bin_dir, rtk_bin, force, @@ -2177,7 +2177,7 @@ mod tests { fn test_resolve_shim_operational_commands_rejects_non_eligible() { let allowed = metadata::shim_eligible_top_level_commands(); let err = - utils::resolve_shim_operational_commands(&["gain".to_string()], &allowed).unwrap_err(); + shim::resolve_shim_operational_commands(&["gain".to_string()], &allowed).unwrap_err(); assert!( err.to_string().contains("not Shim-eligible"), "unexpected error: {}", diff --git a/src/shim.rs b/src/shim.rs index 2ebccefbc7..2e90c9a22e 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -1,13 +1,366 @@ use anyhow::{Context, Result}; +use std::env; use std::ffi::{OsStr, OsString}; -use std::path::Path; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; -use crate::{metadata, utils}; +use crate::metadata; const RTK_OPERATIONAL_COMMAND_BYPASS_ENV: &str = "RTK_BYPASS_OPERATIONAL_COMMAND_SHIMS"; const RTK_RECURSION_DEPTH_ENV: &str = "RTK_RECURSION_DEPTH"; const RTK_RECURSION_DEPTH_LIMIT: u32 = 32; +fn is_executable(path: &Path) -> bool { + let meta = match fs::metadata(path) { + Ok(m) => m, + Err(_) => return false, + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +fn is_single_normal_component(name: &str) -> bool { + let mut components = Path::new(name).components(); + matches!( + (components.next(), components.next()), + (Some(std::path::Component::Normal(_)), None) + ) +} + +fn is_same_executable(candidate: &Path, current_exe: &Path) -> bool { + let candidate_canon = fs::canonicalize(candidate).ok(); + let current_canon = fs::canonicalize(current_exe).ok(); + + if let (Some(a), Some(b)) = (&candidate_canon, ¤t_canon) { + if a == b { + return true; + } + #[cfg(unix)] + { + if let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) { + if ma.dev() == mb.dev() && ma.ino() == mb.ino() { + return true; + } + } + } + } + + #[cfg(unix)] + { + if let (Ok(ma), Ok(mb)) = (fs::metadata(candidate), fs::metadata(current_exe)) { + if ma.dev() == mb.dev() && ma.ino() == mb.ino() { + return true; + } + } + } + + false +} + +#[cfg(windows)] +fn command_candidates(program: &str) -> Vec { + let has_ext = Path::new(program).extension().is_some(); + if has_ext { + return vec![program.to_string()]; + } + let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + pathext + .split(';') + .filter(|s| !s.trim().is_empty()) + .map(|ext| format!("{program}{ext}")) + .collect() +} + +#[cfg(not(windows))] +fn command_candidates(program: &str) -> Vec { + vec![program.to_string()] +} + +fn resolve_non_self_from_paths( + program: &str, + paths: &[PathBuf], + current_exe: &Path, +) -> Option { + let candidates = command_candidates(program); + for dir in paths { + for candidate_name in &candidates { + let candidate = dir.join(candidate_name); + if !is_executable(&candidate) { + continue; + } + if is_same_executable(&candidate, current_exe) { + continue; + } + return Some(candidate); + } + } + None +} + +fn resolve_non_self_command(program: &str) -> Result { + let current_exe = env::current_exe().context("Failed to resolve current executable path")?; + + if program.contains('/') || program.contains('\\') { + let candidate = PathBuf::from(program); + if is_same_executable(&candidate, ¤t_exe) { + anyhow::bail!( + "Resolved command '{}' points to current rtk executable; refusing recursive invocation", + program + ); + } + return Ok(candidate); + } + + let path_env = env::var_os("PATH").unwrap_or_default(); + let paths: Vec = env::split_paths(&path_env).collect(); + if let Some(path) = resolve_non_self_from_paths(program, &paths, ¤t_exe) { + return Ok(path); + } + + anyhow::bail!( + "Unable to find native command '{}' in PATH without matching current rtk executable", + program + ) +} + +pub(crate) fn native_command(program: &str) -> Result { + let resolved = resolve_non_self_command(program)?; + Ok(Command::new(resolved)) +} + +fn default_shim_bin_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".local") + .join("rtk-shims") + .join("bin") +} + +fn ensure_shim_install_supported_platform() -> Result<()> { + #[cfg(windows)] + { + anyhow::bail!( + "rtk shim install is currently unsupported on Windows; symlink-based shim installation requires elevated OS policy/privileges. Use a Unix-like environment for now." + ); + } + + #[cfg(not(windows))] + { + Ok(()) + } +} + +pub(crate) fn resolve_shim_operational_commands( + requested: &[String], + allowed: &[String], +) -> Result> { + if requested.is_empty() { + return Ok(allowed.to_vec()); + } + + let mut operational_commands = Vec::with_capacity(requested.len()); + for operational_command in requested { + if !is_single_normal_component(operational_command) { + anyhow::bail!( + "invalid operational_command name: '{}'", + operational_command + ); + } + if !allowed + .iter() + .any(|allowed_operational_command| allowed_operational_command == operational_command) + { + anyhow::bail!( + "operational_command '{}' is not Shim-eligible in this rtk binary", + operational_command + ); + } + operational_commands.push(operational_command.clone()); + } + + Ok(operational_commands) +} + +fn create_operational_command_shim(target: &Path, link: &Path) -> Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link).with_context(|| { + format!( + "failed to create symlink '{}' -> '{}'", + link.display(), + target.display() + ) + })?; + Ok(()) + } + + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(target, link).with_context(|| { + format!( + "failed to create symlink '{}' -> '{}'", + link.display(), + target.display() + ) + })?; + Ok(()) + } + + #[cfg(not(any(unix, windows)))] + { + let _ = (target, link); + anyhow::bail!("symlink creation is not supported on this platform"); + } +} + +fn is_rtk_named_path(path: &Path) -> bool { + matches!( + path.file_name().and_then(|s| s.to_str()), + Some("rtk") | Some("rtk.exe") + ) +} + +fn is_rtk_shim_symlink(link_path: &Path, target_rtk: &Path) -> bool { + let Ok(link_target) = fs::read_link(link_path) else { + return false; + }; + + let resolved_target = if link_target.is_absolute() { + link_target + } else { + link_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(link_target) + }; + + if is_same_executable(&resolved_target, target_rtk) { + return true; + } + + if is_rtk_named_path(&resolved_target) { + return true; + } + + fs::canonicalize(&resolved_target) + .map(|p| is_rtk_named_path(&p)) + .unwrap_or(false) +} + +pub(crate) fn install_operational_command_shims( + bin_dir: Option, + rtk_bin: Option, + force: bool, + force_all: bool, + operational_commands: &[String], +) -> Result<()> { + ensure_shim_install_supported_platform()?; + + let bin_dir = bin_dir.unwrap_or_else(default_shim_bin_dir); + fs::create_dir_all(&bin_dir).with_context(|| { + format!( + "failed to create operational_command-shim bin dir '{}'", + bin_dir.display() + ) + })?; + + let rtk_target = match rtk_bin { + Some(path) => path, + None => env::current_exe().context("failed to resolve current executable")?, + }; + + if !rtk_target.exists() { + anyhow::bail!("rtk binary does not exist: {}", rtk_target.display()); + } + if !rtk_target.is_file() { + anyhow::bail!("rtk binary is not a file: {}", rtk_target.display()); + } + if !is_executable(&rtk_target) { + anyhow::bail!("rtk binary is not executable: {}", rtk_target.display()); + } + + let rtk_abs = fs::canonicalize(&rtk_target).unwrap_or(rtk_target); + let mut created = 0usize; + let mut replaced = 0usize; + let mut skipped = 0usize; + + for operational_command in operational_commands { + let link_path = bin_dir.join(operational_command); + match fs::symlink_metadata(&link_path) { + Ok(meta) => { + if force_all { + if meta.file_type().is_dir() { + anyhow::bail!( + "refusing to replace existing directory: {}", + link_path.display() + ); + } + fs::remove_file(&link_path).with_context(|| { + format!("failed to remove existing entry '{}'", link_path.display()) + })?; + create_operational_command_shim(&rtk_abs, &link_path)?; + println!("replaced: {} -> {}", link_path.display(), rtk_abs.display()); + replaced += 1; + } else if force { + if meta.file_type().is_dir() { + anyhow::bail!( + "refusing to replace existing directory: {}", + link_path.display() + ); + } + if !meta.file_type().is_symlink() || !is_rtk_shim_symlink(&link_path, &rtk_abs) + { + anyhow::bail!( + "refusing to replace non-rtk shim '{}'; rerun with --force-all to replace arbitrary files", + link_path.display() + ); + } + fs::remove_file(&link_path).with_context(|| { + format!("failed to remove existing entry '{}'", link_path.display()) + })?; + create_operational_command_shim(&rtk_abs, &link_path)?; + println!("replaced: {} -> {}", link_path.display(), rtk_abs.display()); + replaced += 1; + } else { + println!("skipped (exists): {}", link_path.display()); + skipped += 1; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + create_operational_command_shim(&rtk_abs, &link_path)?; + println!("created: {} -> {}", link_path.display(), rtk_abs.display()); + created += 1; + } + Err(e) => { + return Err(e).with_context(|| { + format!("failed to inspect existing path '{}'", link_path.display()) + }); + } + } + } + + println!( + "done: created={} replaced={} skipped={}", + created, replaced, skipped + ); + + Ok(()) +} + pub(crate) fn operational_command_name_from_argv0(argv0: &OsStr) -> Option { let basename = Path::new(argv0).file_name()?.to_string_lossy(); let trimmed = basename.strip_suffix(".exe").unwrap_or(&basename); @@ -65,7 +418,7 @@ fn maybe_exec_native_operational_command(raw_argv: &[OsString]) -> Result return Ok(false); } - let mut cmd = utils::native_command(&operational_command).with_context(|| { + let mut cmd = native_command(&operational_command).with_context(|| { format!( "Failed to resolve native command for '{}'", operational_command @@ -116,3 +469,248 @@ pub(crate) fn should_block_fallback_for_excluded_shim_command(parse_argv: &[OsSt metadata::is_supported_top_level_command(&operational_command) && !metadata::is_shim_eligible_top_level_command(&operational_command) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn test_resolve_shim_operational_commands_rejects_non_normal_component() { + let allowed = vec![ + "git".to_string(), + ".".to_string(), + "..".to_string(), + "foo/bar".to_string(), + ]; + assert!(resolve_shim_operational_commands(&["git".to_string()], &allowed).is_ok()); + + for invalid in [".", "..", "foo/bar"] { + let err = resolve_shim_operational_commands(&[invalid.to_string()], &allowed) + .expect_err("expected invalid operational_command name to fail"); + assert!( + err.to_string() + .contains(&format!("invalid operational_command name: '{}'", invalid)), + "unexpected error: {}", + err + ); + } + } + + #[cfg(not(windows))] + #[test] + fn test_ensure_shim_install_supported_platform_non_windows() { + ensure_shim_install_supported_platform().expect("non-Windows should support shim install"); + } + + #[cfg(windows)] + #[test] + fn test_ensure_shim_install_supported_platform_windows_fails_fast() { + let err = ensure_shim_install_supported_platform() + .expect_err("Windows shim install should fail fast"); + assert!( + err.to_string().contains("unsupported on Windows"), + "unexpected error: {}", + err + ); + } + + #[cfg(unix)] + fn create_executable_file(path: &std::path::Path) { + let mut f = fs::File::create(path).unwrap(); + writeln!(f, "#!/bin/sh").unwrap(); + writeln!(f, "exit 0").unwrap(); + drop(f); + + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); + } + + #[cfg(unix)] + #[test] + fn test_resolve_non_self_from_paths_skips_self_symlink() { + let current_exe = env::current_exe().unwrap(); + + let first = tempdir().unwrap(); + let second = tempdir().unwrap(); + + let fake_self = first.path().join("git"); + std::os::unix::fs::symlink(¤t_exe, &fake_self).unwrap(); + + let native_git = second.path().join("git"); + let mut f = fs::File::create(&native_git).unwrap(); + writeln!(f, "#!/bin/sh").unwrap(); + writeln!(f, "echo native").unwrap(); + drop(f); + + let mut perms = fs::metadata(&native_git).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&native_git, perms).unwrap(); + + let paths = vec![first.path().to_path_buf(), second.path().to_path_buf()]; + let resolved = resolve_non_self_from_paths("git", &paths, ¤t_exe).unwrap(); + assert_eq!(resolved, native_git); + } + + #[cfg(unix)] + #[test] + fn test_resolve_non_self_command_errors_when_only_self_exists() { + let current_exe = env::current_exe().unwrap(); + let dir = tempdir().unwrap(); + let fake_self = dir.path().join("ls"); + std::os::unix::fs::symlink(¤t_exe, &fake_self).unwrap(); + + let resolved = resolve_non_self_from_paths("ls", &[dir.path().to_path_buf()], ¤t_exe); + assert!(resolved.is_none()); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_create_skip_replace() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + let primary_dir = dir.path().join("primary"); + let secondary_dir = dir.path().join("secondary"); + fs::create_dir_all(&primary_dir).unwrap(); + fs::create_dir_all(&secondary_dir).unwrap(); + + let primary_rtk = primary_dir.join("rtk"); + let secondary_rtk = secondary_dir.join("rtk"); + create_executable_file(&primary_rtk); + create_executable_file(&secondary_rtk); + let primary_abs = fs::canonicalize(&primary_rtk).unwrap(); + + let commands = vec!["git".to_string(), "curl".to_string()]; + install_operational_command_shims( + Some(bin_dir.clone()), + Some(primary_rtk.clone()), + false, + false, + &commands, + ) + .unwrap(); + + let git_link = bin_dir.join("git"); + assert!(fs::symlink_metadata(&git_link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); + + install_operational_command_shims( + Some(bin_dir.clone()), + Some(primary_rtk.clone()), + false, + false, + &commands, + ) + .unwrap(); + assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); + + fs::remove_file(&git_link).unwrap(); + std::os::unix::fs::symlink(&secondary_rtk, &git_link).unwrap(); + + install_operational_command_shims( + Some(bin_dir.clone()), + Some(primary_rtk), + true, + false, + &["git".to_string()], + ) + .unwrap(); + assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_force_refuses_directory() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + fs::create_dir_all(bin_dir.join("git")).unwrap(); + + let rtk_bin = dir.path().join("rtk"); + create_executable_file(&rtk_bin); + + let err = install_operational_command_shims( + Some(bin_dir), + Some(rtk_bin), + true, + false, + &["git".to_string()], + ) + .expect_err("expected replacing directory to fail"); + assert!( + err.to_string() + .contains("refusing to replace existing directory"), + "unexpected error: {}", + err + ); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_force_refuses_non_shim_without_force_all() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("git"), "plain file").unwrap(); + + let rtk_bin = dir.path().join("rtk"); + create_executable_file(&rtk_bin); + + let err = install_operational_command_shims( + Some(bin_dir.clone()), + Some(rtk_bin.clone()), + true, + false, + &["git".to_string()], + ) + .expect_err("expected non-shim replacement to require --force-all"); + assert!( + err.to_string().contains("--force-all"), + "unexpected error: {}", + err + ); + + install_operational_command_shims( + Some(bin_dir.clone()), + Some(rtk_bin), + false, + true, + &["git".to_string()], + ) + .unwrap(); + assert!(fs::symlink_metadata(bin_dir.join("git")) + .unwrap() + .file_type() + .is_symlink()); + } + + #[cfg(unix)] + #[test] + fn test_install_operational_command_shims_rejects_non_executable_rtk_bin() { + let dir = tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + let non_exec_rtk = dir.path().join("rtk"); + fs::write(&non_exec_rtk, "#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = fs::metadata(&non_exec_rtk).unwrap().permissions(); + perms.set_mode(0o644); + fs::set_permissions(&non_exec_rtk, perms).unwrap(); + + let err = install_operational_command_shims( + Some(bin_dir), + Some(non_exec_rtk), + false, + false, + &["git".to_string()], + ) + .expect_err("expected non-executable rtk bin to fail"); + assert!( + err.to_string().contains("not executable"), + "unexpected error: {}", + err + ); + } +} diff --git a/src/utils.rs b/src/utils.rs index 53298f3890..afd39334ab 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,16 +7,8 @@ use anyhow::{Context, Result}; use regex::Regex; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; use std::process::Command; -#[cfg(unix)] -use std::os::unix::fs::MetadataExt; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; - /// Truncates a string to `max_len` characters, appending "..." if needed. /// /// # Arguments @@ -88,338 +80,6 @@ pub fn execute_command(cmd: &str, args: &[&str]) -> Result<(String, String, i32) Ok((stdout, stderr, exit_code)) } -fn is_executable(path: &Path) -> bool { - let meta = match fs::metadata(path) { - Ok(m) => m, - Err(_) => return false, - }; - if !meta.is_file() { - return false; - } - #[cfg(unix)] - { - meta.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - true - } -} - -fn is_single_normal_component(name: &str) -> bool { - let mut components = Path::new(name).components(); - matches!( - (components.next(), components.next()), - (Some(std::path::Component::Normal(_)), None) - ) -} - -fn is_same_executable(candidate: &Path, current_exe: &Path) -> bool { - let candidate_canon = fs::canonicalize(candidate).ok(); - let current_canon = fs::canonicalize(current_exe).ok(); - - if let (Some(a), Some(b)) = (&candidate_canon, ¤t_canon) { - if a == b { - return true; - } - #[cfg(unix)] - { - if let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) { - if ma.dev() == mb.dev() && ma.ino() == mb.ino() { - return true; - } - } - } - } - - #[cfg(unix)] - { - if let (Ok(ma), Ok(mb)) = (fs::metadata(candidate), fs::metadata(current_exe)) { - if ma.dev() == mb.dev() && ma.ino() == mb.ino() { - return true; - } - } - } - - false -} - -#[cfg(windows)] -fn command_candidates(program: &str) -> Vec { - let has_ext = Path::new(program).extension().is_some(); - if has_ext { - return vec![program.to_string()]; - } - let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); - pathext - .split(';') - .filter(|s| !s.trim().is_empty()) - .map(|ext| format!("{program}{ext}")) - .collect() -} - -#[cfg(not(windows))] -fn command_candidates(program: &str) -> Vec { - vec![program.to_string()] -} - -fn resolve_non_self_from_paths( - program: &str, - paths: &[PathBuf], - current_exe: &Path, -) -> Option { - let candidates = command_candidates(program); - for dir in paths { - for candidate_name in &candidates { - let candidate = dir.join(candidate_name); - if !is_executable(&candidate) { - continue; - } - if is_same_executable(&candidate, current_exe) { - continue; - } - return Some(candidate); - } - } - None -} - -/// Resolve a command from PATH while skipping any entry that points to current rtk executable. -pub fn resolve_non_self_command(program: &str) -> Result { - let current_exe = env::current_exe().context("Failed to resolve current executable path")?; - - if program.contains('/') || program.contains('\\') { - let candidate = PathBuf::from(program); - if is_same_executable(&candidate, ¤t_exe) { - anyhow::bail!( - "Resolved command '{}' points to current rtk executable; refusing recursive invocation", - program - ); - } - return Ok(candidate); - } - - let path_env = env::var_os("PATH").unwrap_or_default(); - let paths: Vec = env::split_paths(&path_env).collect(); - if let Some(path) = resolve_non_self_from_paths(program, &paths, ¤t_exe) { - return Ok(path); - } - - anyhow::bail!( - "Unable to find native command '{}' in PATH without matching current rtk executable", - program - ) -} - -/// Build a command using the resolved native executable path (skips self-recursive links). -pub fn native_command(program: &str) -> Result { - let resolved = resolve_non_self_command(program)?; - Ok(Command::new(resolved)) -} - -pub fn default_shim_bin_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".local") - .join("rtk-shims") - .join("bin") -} - -pub fn resolve_shim_operational_commands( - requested: &[String], - allowed: &[String], -) -> Result> { - if requested.is_empty() { - return Ok(allowed.to_vec()); - } - - let mut operational_commands = Vec::with_capacity(requested.len()); - for operational_command in requested { - if !is_single_normal_component(operational_command) { - anyhow::bail!( - "invalid operational_command name: '{}'", - operational_command - ); - } - if !allowed - .iter() - .any(|allowed_operational_command| allowed_operational_command == operational_command) - { - anyhow::bail!( - "operational_command '{}' is not Shim-eligible in this rtk binary", - operational_command - ); - } - operational_commands.push(operational_command.clone()); - } - - Ok(operational_commands) -} - -fn create_operational_command_shim(target: &Path, link: &Path) -> Result<()> { - #[cfg(unix)] - { - std::os::unix::fs::symlink(target, link).with_context(|| { - format!( - "failed to create symlink '{}' -> '{}'", - link.display(), - target.display() - ) - })?; - Ok(()) - } - - #[cfg(windows)] - { - std::os::windows::fs::symlink_file(target, link).with_context(|| { - format!( - "failed to create symlink '{}' -> '{}'", - link.display(), - target.display() - ) - })?; - Ok(()) - } - - #[cfg(not(any(unix, windows)))] - { - let _ = (target, link); - anyhow::bail!("symlink creation is not supported on this platform"); - } -} - -fn is_rtk_named_path(path: &Path) -> bool { - matches!( - path.file_name().and_then(|s| s.to_str()), - Some("rtk") | Some("rtk.exe") - ) -} - -fn is_rtk_shim_symlink(link_path: &Path, target_rtk: &Path) -> bool { - let Ok(link_target) = fs::read_link(link_path) else { - return false; - }; - - let resolved_target = if link_target.is_absolute() { - link_target - } else { - link_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(link_target) - }; - - if is_same_executable(&resolved_target, target_rtk) { - return true; - } - - if is_rtk_named_path(&resolved_target) { - return true; - } - - fs::canonicalize(&resolved_target) - .map(|p| is_rtk_named_path(&p)) - .unwrap_or(false) -} - -pub fn install_operational_command_shims( - bin_dir: Option, - rtk_bin: Option, - force: bool, - force_all: bool, - operational_commands: &[String], -) -> Result<()> { - let bin_dir = bin_dir.unwrap_or_else(default_shim_bin_dir); - fs::create_dir_all(&bin_dir).with_context(|| { - format!( - "failed to create operational_command-shim bin dir '{}'", - bin_dir.display() - ) - })?; - - let rtk_target = match rtk_bin { - Some(path) => path, - None => env::current_exe().context("failed to resolve current executable")?, - }; - - if !rtk_target.exists() { - anyhow::bail!("rtk binary does not exist: {}", rtk_target.display()); - } - if !rtk_target.is_file() { - anyhow::bail!("rtk binary is not a file: {}", rtk_target.display()); - } - if !is_executable(&rtk_target) { - anyhow::bail!("rtk binary is not executable: {}", rtk_target.display()); - } - - let rtk_abs = fs::canonicalize(&rtk_target).unwrap_or(rtk_target); - let mut created = 0usize; - let mut replaced = 0usize; - let mut skipped = 0usize; - - for operational_command in operational_commands { - let link_path = bin_dir.join(operational_command); - match fs::symlink_metadata(&link_path) { - Ok(meta) => { - if force_all { - if meta.file_type().is_dir() { - anyhow::bail!( - "refusing to replace existing directory: {}", - link_path.display() - ); - } - fs::remove_file(&link_path).with_context(|| { - format!("failed to remove existing entry '{}'", link_path.display()) - })?; - create_operational_command_shim(&rtk_abs, &link_path)?; - println!("replaced: {} -> {}", link_path.display(), rtk_abs.display()); - replaced += 1; - } else if force { - if meta.file_type().is_dir() { - anyhow::bail!( - "refusing to replace existing directory: {}", - link_path.display() - ); - } - if !meta.file_type().is_symlink() || !is_rtk_shim_symlink(&link_path, &rtk_abs) - { - anyhow::bail!( - "refusing to replace non-rtk shim '{}'; rerun with --force-all to replace arbitrary files", - link_path.display() - ); - } - fs::remove_file(&link_path).with_context(|| { - format!("failed to remove existing entry '{}'", link_path.display()) - })?; - create_operational_command_shim(&rtk_abs, &link_path)?; - println!("replaced: {} -> {}", link_path.display(), rtk_abs.display()); - replaced += 1; - } else { - println!("skipped (exists): {}", link_path.display()); - skipped += 1; - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - create_operational_command_shim(&rtk_abs, &link_path)?; - println!("created: {} -> {}", link_path.display(), rtk_abs.display()); - created += 1; - } - Err(e) => { - return Err(e).with_context(|| { - format!("failed to inspect existing path '{}'", link_path.display()) - }); - } - } - } - - println!( - "done: created={} replaced={} skipped={}", - created, replaced, skipped - ); - - Ok(()) -} - /// Format a token count with K/M suffixes for readability. /// /// # Arguments @@ -602,8 +262,6 @@ pub fn package_manager_exec(tool: &str) -> Command { #[cfg(test)] mod tests { use super::*; - use std::io::Write; - use tempfile::tempdir; #[test] fn test_truncate_short_string() { @@ -771,224 +429,4 @@ mod tests { let result = truncate(cjk, 6); assert!(result.ends_with("...")); } - - #[test] - fn test_resolve_shim_operational_commands_rejects_non_normal_component() { - let allowed = vec![ - "git".to_string(), - ".".to_string(), - "..".to_string(), - "foo/bar".to_string(), - ]; - assert!(resolve_shim_operational_commands(&["git".to_string()], &allowed).is_ok()); - - for invalid in [".", "..", "foo/bar"] { - let err = resolve_shim_operational_commands(&[invalid.to_string()], &allowed) - .expect_err("expected invalid operational_command name to fail"); - assert!( - err.to_string() - .contains(&format!("invalid operational_command name: '{}'", invalid)), - "unexpected error: {}", - err - ); - } - } - - #[cfg(unix)] - fn create_executable_file(path: &std::path::Path) { - let mut f = fs::File::create(path).unwrap(); - writeln!(f, "#!/bin/sh").unwrap(); - writeln!(f, "exit 0").unwrap(); - drop(f); - - let mut perms = fs::metadata(path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms).unwrap(); - } - - #[cfg(unix)] - #[test] - fn test_resolve_non_self_from_paths_skips_self_symlink() { - let current_exe = env::current_exe().unwrap(); - - let first = tempdir().unwrap(); - let second = tempdir().unwrap(); - - let fake_self = first.path().join("git"); - std::os::unix::fs::symlink(¤t_exe, &fake_self).unwrap(); - - let native_git = second.path().join("git"); - let mut f = fs::File::create(&native_git).unwrap(); - writeln!(f, "#!/bin/sh").unwrap(); - writeln!(f, "echo native").unwrap(); - drop(f); - - let mut perms = fs::metadata(&native_git).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&native_git, perms).unwrap(); - - let paths = vec![first.path().to_path_buf(), second.path().to_path_buf()]; - let resolved = resolve_non_self_from_paths("git", &paths, ¤t_exe).unwrap(); - assert_eq!(resolved, native_git); - } - - #[cfg(unix)] - #[test] - fn test_resolve_non_self_command_errors_when_only_self_exists() { - let current_exe = env::current_exe().unwrap(); - let dir = tempdir().unwrap(); - let fake_self = dir.path().join("ls"); - std::os::unix::fs::symlink(¤t_exe, &fake_self).unwrap(); - - let resolved = resolve_non_self_from_paths("ls", &[dir.path().to_path_buf()], ¤t_exe); - assert!(resolved.is_none()); - } - - #[cfg(unix)] - #[test] - fn test_install_operational_command_shims_create_skip_replace() { - let dir = tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - let primary_dir = dir.path().join("primary"); - let secondary_dir = dir.path().join("secondary"); - fs::create_dir_all(&primary_dir).unwrap(); - fs::create_dir_all(&secondary_dir).unwrap(); - - let primary_rtk = primary_dir.join("rtk"); - let secondary_rtk = secondary_dir.join("rtk"); - create_executable_file(&primary_rtk); - create_executable_file(&secondary_rtk); - let primary_abs = fs::canonicalize(&primary_rtk).unwrap(); - - let commands = vec!["git".to_string(), "curl".to_string()]; - install_operational_command_shims( - Some(bin_dir.clone()), - Some(primary_rtk.clone()), - false, - false, - &commands, - ) - .unwrap(); - - let git_link = bin_dir.join("git"); - assert!(fs::symlink_metadata(&git_link) - .unwrap() - .file_type() - .is_symlink()); - assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); - - install_operational_command_shims( - Some(bin_dir.clone()), - Some(primary_rtk.clone()), - false, - false, - &commands, - ) - .unwrap(); - assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); - - fs::remove_file(&git_link).unwrap(); - std::os::unix::fs::symlink(&secondary_rtk, &git_link).unwrap(); - - install_operational_command_shims( - Some(bin_dir.clone()), - Some(primary_rtk), - true, - false, - &["git".to_string()], - ) - .unwrap(); - assert_eq!(fs::read_link(&git_link).unwrap(), primary_abs); - } - - #[cfg(unix)] - #[test] - fn test_install_operational_command_shims_force_refuses_directory() { - let dir = tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - fs::create_dir_all(bin_dir.join("git")).unwrap(); - - let rtk_bin = dir.path().join("rtk"); - create_executable_file(&rtk_bin); - - let err = install_operational_command_shims( - Some(bin_dir), - Some(rtk_bin), - true, - false, - &["git".to_string()], - ) - .expect_err("expected replacing directory to fail"); - assert!( - err.to_string() - .contains("refusing to replace existing directory"), - "unexpected error: {}", - err - ); - } - - #[cfg(unix)] - #[test] - fn test_install_operational_command_shims_force_refuses_non_shim_without_force_all() { - let dir = tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write(bin_dir.join("git"), "plain file").unwrap(); - - let rtk_bin = dir.path().join("rtk"); - create_executable_file(&rtk_bin); - - let err = install_operational_command_shims( - Some(bin_dir.clone()), - Some(rtk_bin.clone()), - true, - false, - &["git".to_string()], - ) - .expect_err("expected non-shim replacement to require --force-all"); - assert!( - err.to_string().contains("--force-all"), - "unexpected error: {}", - err - ); - - install_operational_command_shims( - Some(bin_dir.clone()), - Some(rtk_bin), - false, - true, - &["git".to_string()], - ) - .unwrap(); - assert!(fs::symlink_metadata(bin_dir.join("git")) - .unwrap() - .file_type() - .is_symlink()); - } - - #[cfg(unix)] - #[test] - fn test_install_operational_command_shims_rejects_non_executable_rtk_bin() { - let dir = tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - let non_exec_rtk = dir.path().join("rtk"); - fs::write(&non_exec_rtk, "#!/bin/sh\nexit 0\n").unwrap(); - let mut perms = fs::metadata(&non_exec_rtk).unwrap().permissions(); - perms.set_mode(0o644); - fs::set_permissions(&non_exec_rtk, perms).unwrap(); - - let err = install_operational_command_shims( - Some(bin_dir), - Some(non_exec_rtk), - false, - false, - &["git".to_string()], - ) - .expect_err("expected non-executable rtk bin to fail"); - assert!( - err.to_string().contains("not executable"), - "unexpected error: {}", - err - ); - } } From f89d5027ecd462c4547b9886ab09656620e47c6c Mon Sep 17 00:00:00 2001 From: Xiaoguang Sun Date: Sun, 8 Mar 2026 23:16:15 -0700 Subject: [PATCH 3/4] Move tests to shim.rs Signed-off-by: Xiaoguang Sun --- src/main.rs | 166 ---------------------------------------------------- src/shim.rs | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 166 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9d0891da9b..0ed3f834a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1954,77 +1954,6 @@ mod tests { args.iter().map(OsString::from).collect() } - #[test] - fn test_build_parse_argv_rewrites_known_operational_command() { - let raw = vec![ - OsString::from("/tmp/git"), - OsString::from("status"), - OsString::from("-s"), - ]; - let rewritten = shim::build_parse_argv(&raw); - assert_eq!( - rewritten, - vec![ - OsString::from("rtk"), - OsString::from("git"), - OsString::from("status"), - OsString::from("-s") - ] - ); - } - - #[test] - fn test_build_parse_argv_rewrites_hyphenated_operational_command() { - let raw = vec![ - OsString::from("/usr/local/bin/golangci-lint"), - OsString::from("run"), - ]; - let rewritten = shim::build_parse_argv(&raw); - assert_eq!( - rewritten, - vec![ - OsString::from("rtk"), - OsString::from("golangci-lint"), - OsString::from("run") - ] - ); - } - - #[test] - fn test_build_parse_argv_does_not_rewrite_rtk_binary() { - let raw = vec![ - OsString::from("/usr/local/bin/rtk"), - OsString::from("git"), - OsString::from("status"), - ]; - assert_eq!(shim::build_parse_argv(&raw), raw); - } - - #[test] - fn test_build_parse_argv_does_not_rewrite_unknown_operational_command() { - let raw = vec![OsString::from("/tmp/random-tool"), OsString::from("status")]; - assert_eq!(shim::build_parse_argv(&raw), raw); - } - - #[test] - fn test_fallback_guard_blocks_excluded_shim_command_symlink() { - let gain_like = vec![OsString::from("/tmp/gain"), OsString::from("--bad-flag")]; - assert!(shim::should_block_fallback_for_excluded_shim_command( - &gain_like - )); - - let git_like = vec![OsString::from("/tmp/git"), OsString::from("--bad-flag")]; - assert!(!shim::should_block_fallback_for_excluded_shim_command( - &git_like - )); - } - - #[test] - fn test_operational_command_name_from_argv0_strips_exe_suffix() { - let name = shim::operational_command_name_from_argv0(std::ffi::OsStr::new("git.exe")); - assert_eq!(name.as_deref(), Some("git")); - } - #[test] fn test_supported_top_level_commands_contains_known_items() { let commands = metadata::supported_top_level_commands(); @@ -2033,33 +1962,6 @@ mod tests { assert!(commands.iter().any(|c| c == "shim")); } - #[test] - fn test_shim_eligible_commands_excludes_meta_and_rtk_native() { - let commands = metadata::shim_eligible_top_level_commands(); - assert!(commands.iter().any(|c| c == "git")); - assert!(commands.iter().any(|c| c == "curl")); - assert!(commands.iter().any(|c| c == "aws")); - assert!(commands.iter().any(|c| c == "psql")); - assert!(commands.iter().any(|c| c == "wc")); - assert!(commands.iter().any(|c| c == "mypy")); - assert!(!commands.iter().any(|c| c == "gain")); - assert!(!commands.iter().any(|c| c == "read")); - assert!(!commands.iter().any(|c| c == "shim")); - } - - #[test] - fn test_shim_eligible_commands_are_operational() { - for cmd in metadata::shim_eligible_top_level_commands() { - assert!( - metadata::top_level_command_metadata(&cmd) - .map(|meta| meta.operational) - .unwrap_or(false), - "Expected '{}' to be operational", - cmd - ); - } - } - #[test] fn test_top_level_command_metadata_covers_supported_commands() { for cmd in metadata::supported_top_level_commands() { @@ -2109,24 +2011,6 @@ mod tests { assert_eq!(actual, expected, "metadata command set drifted"); } - #[test] - fn test_metadata_commands_are_not_shim_eligible() { - for meta in metadata::TOP_LEVEL_COMMAND_METADATA - .iter() - .filter(|meta| meta.metadata) - { - assert!( - !meta.shim, - "metadata command '{}' must not be shim-eligible", - meta.name - ); - assert!( - !metadata::is_shim_eligible_top_level_command(meta.name), - "metadata command '{}' must not be shim-eligible at runtime", - meta.name - ); - } - } #[test] fn test_shim_install_parses() { @@ -2173,23 +2057,6 @@ mod tests { ); } - #[test] - fn test_resolve_shim_operational_commands_rejects_non_eligible() { - let allowed = metadata::shim_eligible_top_level_commands(); - let err = - shim::resolve_shim_operational_commands(&["gain".to_string()], &allowed).unwrap_err(); - assert!( - err.to_string().contains("not Shim-eligible"), - "unexpected error: {}", - err - ); - } - - #[test] - fn test_build_parse_argv_does_not_rewrite_excluded_shim_command() { - let raw = vec![OsString::from("/tmp/gain"), OsString::from("--graph")]; - assert_eq!(shim::build_parse_argv(&raw), raw); - } #[test] fn test_git_commit_single_message() { @@ -2442,39 +2309,6 @@ mod tests { } } - #[test] - fn test_shim_exclusion_policy_covers_guarded_commands() { - for cmd in [ - "gain", - "discover", - "learn", - "init", - "config", - "shim", - "proxy", - "hook-audit", - "cc-economics", - "rewrite", - "verify", - "read", - "smart", - ] { - assert!(metadata::is_supported_top_level_command(cmd)); - assert!( - !metadata::is_shim_eligible_top_level_command(cmd), - "Expected '{}' to be excluded from Shim operational_command mode", - cmd - ); - } - } - - #[test] - fn test_shim_is_not_operational() { - assert!(!metadata::is_operational_command_from_parse_argv(&os_argv( - &["rtk", "shim", "install", "git",] - ))); - } - #[test] fn test_git_is_operational() { assert!(metadata::is_operational_command_from_parse_argv(&os_argv( diff --git a/src/shim.rs b/src/shim.rs index 2e90c9a22e..a535cf2c58 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -473,9 +473,175 @@ pub(crate) fn should_block_fallback_for_excluded_shim_command(parse_argv: &[OsSt #[cfg(test)] mod tests { use super::*; + use crate::metadata; use std::io::Write; use tempfile::tempdir; + #[test] + fn test_build_parse_argv_rewrites_known_operational_command() { + let raw = vec![ + OsString::from("/tmp/git"), + OsString::from("status"), + OsString::from("-s"), + ]; + let rewritten = build_parse_argv(&raw); + assert_eq!( + rewritten, + vec![ + OsString::from("rtk"), + OsString::from("git"), + OsString::from("status"), + OsString::from("-s") + ] + ); + } + + #[test] + fn test_build_parse_argv_rewrites_hyphenated_operational_command() { + let raw = vec![ + OsString::from("/usr/local/bin/golangci-lint"), + OsString::from("run"), + ]; + let rewritten = build_parse_argv(&raw); + assert_eq!( + rewritten, + vec![ + OsString::from("rtk"), + OsString::from("golangci-lint"), + OsString::from("run") + ] + ); + } + + #[test] + fn test_build_parse_argv_does_not_rewrite_rtk_binary() { + let raw = vec![ + OsString::from("/usr/local/bin/rtk"), + OsString::from("git"), + OsString::from("status"), + ]; + assert_eq!(build_parse_argv(&raw), raw); + } + + #[test] + fn test_build_parse_argv_does_not_rewrite_unknown_operational_command() { + let raw = vec![OsString::from("/tmp/random-tool"), OsString::from("status")]; + assert_eq!(build_parse_argv(&raw), raw); + } + + #[test] + fn test_build_parse_argv_does_not_rewrite_excluded_shim_command() { + let raw = vec![OsString::from("/tmp/gain"), OsString::from("--graph")]; + assert_eq!(build_parse_argv(&raw), raw); + } + + #[test] + fn test_fallback_guard_blocks_excluded_shim_command_symlink() { + let gain_like = vec![OsString::from("/tmp/gain"), OsString::from("--bad-flag")]; + assert!(should_block_fallback_for_excluded_shim_command(&gain_like)); + + let git_like = vec![OsString::from("/tmp/git"), OsString::from("--bad-flag")]; + assert!(!should_block_fallback_for_excluded_shim_command(&git_like)); + } + + #[test] + fn test_operational_command_name_from_argv0_strips_exe_suffix() { + let name = operational_command_name_from_argv0(std::ffi::OsStr::new("git.exe")); + assert_eq!(name.as_deref(), Some("git")); + } + + #[test] + fn test_shim_eligible_commands_excludes_meta_and_rtk_native() { + let commands = metadata::shim_eligible_top_level_commands(); + assert!(commands.iter().any(|c| c == "git")); + assert!(commands.iter().any(|c| c == "curl")); + assert!(commands.iter().any(|c| c == "aws")); + assert!(commands.iter().any(|c| c == "psql")); + assert!(commands.iter().any(|c| c == "wc")); + assert!(commands.iter().any(|c| c == "mypy")); + assert!(!commands.iter().any(|c| c == "gain")); + assert!(!commands.iter().any(|c| c == "read")); + assert!(!commands.iter().any(|c| c == "shim")); + } + + #[test] + fn test_shim_eligible_commands_are_operational() { + for cmd in metadata::shim_eligible_top_level_commands() { + assert!( + metadata::top_level_command_metadata(&cmd) + .map(|meta| meta.operational) + .unwrap_or(false), + "Expected '{}' to be operational", + cmd + ); + } + } + + #[test] + fn test_metadata_commands_are_not_shim_eligible() { + for meta in metadata::TOP_LEVEL_COMMAND_METADATA + .iter() + .filter(|meta| meta.metadata) + { + assert!( + !meta.shim, + "metadata command '{}' must not be shim-eligible", + meta.name + ); + assert!( + !metadata::is_shim_eligible_top_level_command(meta.name), + "metadata command '{}' must not be shim-eligible at runtime", + meta.name + ); + } + } + + #[test] + fn test_resolve_shim_operational_commands_rejects_non_eligible() { + let allowed = metadata::shim_eligible_top_level_commands(); + let err = resolve_shim_operational_commands(&["gain".to_string()], &allowed).unwrap_err(); + assert!( + err.to_string().contains("not Shim-eligible"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_shim_exclusion_policy_covers_guarded_commands() { + for cmd in [ + "gain", + "discover", + "learn", + "init", + "config", + "shim", + "proxy", + "hook-audit", + "cc-economics", + "rewrite", + "verify", + "read", + "smart", + ] { + assert!(metadata::is_supported_top_level_command(cmd)); + assert!( + !metadata::is_shim_eligible_top_level_command(cmd), + "Expected '{}' to be excluded from Shim operational_command mode", + cmd + ); + } + } + + #[test] + fn test_shim_is_not_operational() { + let argv: Vec = ["rtk", "shim", "install", "git"] + .iter() + .map(OsString::from) + .collect(); + assert!(!metadata::is_operational_command_from_parse_argv(&argv)); + } + #[test] fn test_resolve_shim_operational_commands_rejects_non_normal_component() { let allowed = vec![ From afcf7db815661f56c58288239a15799be4b1173a Mon Sep 17 00:00:00 2001 From: Xiaoguang Sun Date: Sun, 8 Mar 2026 23:21:49 -0700 Subject: [PATCH 4/4] Move metadata tests to metadata.rs Signed-off-by: Xiaoguang Sun --- src/main.rs | 136 ---------------------------------------------- src/metadata.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 136 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0ed3f834a0..8c2155f0c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1950,68 +1950,6 @@ mod tests { use super::*; use clap::Parser; - fn os_argv(args: &[&str]) -> Vec { - args.iter().map(OsString::from).collect() - } - - #[test] - fn test_supported_top_level_commands_contains_known_items() { - let commands = metadata::supported_top_level_commands(); - assert!(commands.iter().any(|c| c == "git")); - assert!(commands.iter().any(|c| c == "curl")); - assert!(commands.iter().any(|c| c == "shim")); - } - - #[test] - fn test_top_level_command_metadata_covers_supported_commands() { - for cmd in metadata::supported_top_level_commands() { - assert!( - metadata::top_level_command_metadata(&cmd).is_some(), - "Missing top-level metadata for command '{}'", - cmd - ); - } - } - - #[test] - fn test_top_level_command_metadata_has_no_unknown_commands() { - for meta in metadata::TOP_LEVEL_COMMAND_METADATA { - assert!( - metadata::is_supported_top_level_command(meta.name), - "Unknown command '{}' exists in top-level metadata", - meta.name - ); - } - } - - #[test] - fn test_top_level_command_metadata_commands_match_expected_set() { - let actual: std::collections::BTreeSet<&str> = metadata::TOP_LEVEL_COMMAND_METADATA - .iter() - .filter(|meta| meta.metadata) - .map(|meta| meta.name) - .collect(); - - let expected: std::collections::BTreeSet<&str> = [ - "cc-economics", - "config", - "discover", - "gain", - "hook-audit", - "init", - "learn", - "proxy", - "rewrite", - "shim", - "verify", - ] - .into_iter() - .collect(); - - assert_eq!(actual, expected, "metadata command set drifted"); - } - - #[test] fn test_shim_install_parses() { let cli = Cli::try_parse_from([ @@ -2057,7 +1995,6 @@ mod tests { ); } - #[test] fn test_git_commit_single_message() { let cli = Cli::try_parse_from(["rtk", "git", "commit", "-m", "fix: typo"]).unwrap(); @@ -2258,77 +2195,4 @@ mod tests { } } } - - #[test] - fn test_meta_commands_reject_bad_flags() { - // RTK-native commands should produce parse errors (not fall through to raw execution). - // Skip "proxy" because it uses trailing_var_arg (accepts any args by design). - let guarded = [ - "gain", - "discover", - "learn", - "init", - "config", - "shim", - "hook-audit", - "cc-economics", - "verify", - ]; - for cmd in guarded { - let result = Cli::try_parse_from(["rtk", cmd, "--nonexistent-flag-xyz"]); - assert!( - result.is_err(), - "Guarded command '{}' with bad flag should fail to parse", - cmd - ); - } - } - - #[test] - fn test_guarded_command_list_parses_valid_invocations() { - let guarded_cmds_that_parse = [ - vec!["rtk", "gain"], - vec!["rtk", "discover"], - vec!["rtk", "learn"], - vec!["rtk", "init"], - vec!["rtk", "config"], - vec!["rtk", "shim", "install", "git"], - vec!["rtk", "proxy", "echo", "hi"], - vec!["rtk", "hook-audit"], - vec!["rtk", "cc-economics"], - vec!["rtk", "verify"], - vec!["rtk", "rewrite", "git status"], - ]; - for args in &guarded_cmds_that_parse { - let result = Cli::try_parse_from(args.iter()); - assert!( - result.is_ok(), - "Guarded command {:?} should parse successfully", - args - ); - } - } - - #[test] - fn test_git_is_operational() { - assert!(metadata::is_operational_command_from_parse_argv(&os_argv( - &["rtk", "git", "status",] - ))); - } - - #[test] - fn test_aws_psql_wc_mypy_are_operational() { - assert!(metadata::is_operational_command_from_parse_argv(&os_argv( - &["rtk", "aws", "sts",] - ))); - assert!(metadata::is_operational_command_from_parse_argv(&os_argv( - &["rtk", "psql",] - ))); - assert!(metadata::is_operational_command_from_parse_argv(&os_argv( - &["rtk", "wc",] - ))); - assert!(metadata::is_operational_command_from_parse_argv(&os_argv( - &["rtk", "mypy",] - ))); - } } diff --git a/src/metadata.rs b/src/metadata.rs index d4542460a5..73f023bc66 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -391,3 +391,143 @@ pub(crate) fn is_shim_eligible_top_level_command(name: &str) -> bool { .map(|meta| meta.shim) .unwrap_or(false) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn os_argv(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + #[test] + fn test_supported_top_level_commands_contains_known_items() { + let commands = supported_top_level_commands(); + assert!(commands.iter().any(|c| c == "git")); + assert!(commands.iter().any(|c| c == "curl")); + assert!(commands.iter().any(|c| c == "shim")); + } + + #[test] + fn test_top_level_command_metadata_covers_supported_commands() { + for cmd in supported_top_level_commands() { + assert!( + top_level_command_metadata(&cmd).is_some(), + "Missing top-level metadata for command '{}'", + cmd + ); + } + } + + #[test] + fn test_top_level_command_metadata_has_no_unknown_commands() { + for meta in TOP_LEVEL_COMMAND_METADATA { + assert!( + is_supported_top_level_command(meta.name), + "Unknown command '{}' exists in top-level metadata", + meta.name + ); + } + } + + #[test] + fn test_top_level_command_metadata_commands_match_expected_set() { + let actual: std::collections::BTreeSet<&str> = TOP_LEVEL_COMMAND_METADATA + .iter() + .filter(|meta| meta.metadata) + .map(|meta| meta.name) + .collect(); + + let expected: std::collections::BTreeSet<&str> = [ + "cc-economics", + "config", + "discover", + "gain", + "hook-audit", + "init", + "learn", + "proxy", + "rewrite", + "shim", + "verify", + ] + .into_iter() + .collect(); + + assert_eq!(actual, expected, "metadata command set drifted"); + } + + #[test] + fn test_meta_commands_reject_bad_flags() { + // RTK-native commands should produce parse errors (not fall through to raw execution). + // Skip "proxy" because it uses trailing_var_arg (accepts any args by design). + let guarded = [ + "gain", + "discover", + "learn", + "init", + "config", + "shim", + "hook-audit", + "cc-economics", + "verify", + ]; + for cmd in guarded { + let result = Cli::try_parse_from(["rtk", cmd, "--nonexistent-flag-xyz"]); + assert!( + result.is_err(), + "Guarded command '{}' with bad flag should fail to parse", + cmd + ); + } + } + + #[test] + fn test_guarded_command_list_parses_valid_invocations() { + let guarded_cmds_that_parse = [ + vec!["rtk", "gain"], + vec!["rtk", "discover"], + vec!["rtk", "learn"], + vec!["rtk", "init"], + vec!["rtk", "config"], + vec!["rtk", "shim", "install", "git"], + vec!["rtk", "proxy", "echo", "hi"], + vec!["rtk", "hook-audit"], + vec!["rtk", "cc-economics"], + vec!["rtk", "verify"], + vec!["rtk", "rewrite", "git status"], + ]; + for args in &guarded_cmds_that_parse { + let result = Cli::try_parse_from(args.iter()); + assert!( + result.is_ok(), + "Guarded command {:?} should parse successfully", + args + ); + } + } + + #[test] + fn test_git_is_operational() { + assert!(is_operational_command_from_parse_argv(&os_argv(&[ + "rtk", "git", "status", + ]))); + } + + #[test] + fn test_aws_psql_wc_mypy_are_operational() { + assert!(is_operational_command_from_parse_argv(&os_argv(&[ + "rtk", "aws", "sts", + ]))); + assert!(is_operational_command_from_parse_argv(&os_argv(&[ + "rtk", "psql", + ]))); + assert!(is_operational_command_from_parse_argv(&os_argv(&[ + "rtk", "wc", + ]))); + assert!(is_operational_command_from_parse_argv(&os_argv(&[ + "rtk", "mypy", + ]))); + } +}