From 565d7fb16329af83acca133056bda3fc78214a67 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Thu, 13 Aug 2026 19:17:20 +0700 Subject: [PATCH] fix(cli): ignore the executable suffix in argv0 dispatch --- crates/irtt-cli/src/applet.rs | 77 ++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/irtt-cli/src/applet.rs b/crates/irtt-cli/src/applet.rs index b5aa83f..34a8d38 100644 --- a/crates/irtt-cli/src/applet.rs +++ b/crates/irtt-cli/src/applet.rs @@ -141,10 +141,37 @@ fn dispatcher_after_help() -> String { } fn applet_basename(argv0: &str) -> &str { - Path::new(argv0) + let name = Path::new(argv0) .file_name() .and_then(|name| name.to_str()) - .unwrap_or(argv0) + .unwrap_or(argv0); + strip_suffix_ignoring_case(name, std::env::consts::EXE_SUFFIX) +} + +/// Removes the platform executable suffix from a file name. +/// +/// The suffix is part of the file name on Windows and empty everywhere else, so +/// this is a no-op on Unix. Without it, every applet name — including the +/// `irtt-rs` dispatcher itself — reaches argv0 detection as `.exe` and is +/// rejected as an unknown applet. The applet names themselves are still matched +/// exactly; only this platform artifact is case-insensitive, because it is not +/// a name the user chose. +/// +/// The suffix is a parameter so that Windows behavior is assertable from any +/// host, not only where `EXE_SUFFIX` happens to be non-empty. +fn strip_suffix_ignoring_case<'a>(name: &'a str, suffix: &str) -> &'a str { + if suffix.is_empty() || name.len() <= suffix.len() { + return name; + } + let split = name.len() - suffix.len(); + let (Some(stem), Some(tail)) = (name.get(..split), name.get(split..)) else { + return name; + }; + if tail.eq_ignore_ascii_case(suffix) { + stem + } else { + name + } } fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec { @@ -176,6 +203,52 @@ mod tests { assert_eq!(detect_applet_from_argv0("custom-name"), None); } + #[test] + fn applet_detection_ignores_the_platform_executable_suffix() { + let exe = std::env::consts::EXE_SUFFIX; + + assert_eq!( + detect_applet_from_argv0(&format!("/opt/irtt/irtt-server{exe}")), + Some(RequestedApplet::Server) + ); + assert_eq!( + detect_applet_from_argv0(&format!("irtt-cli{exe}")), + Some(RequestedApplet::Client) + ); + + // The dispatcher's own name must not be taken for an unknown applet. + let dispatcher = format!("irtt-rs{exe}"); + assert_eq!(detect_applet_from_argv0(&dispatcher), None); + assert!(dispatch_from_argv(vec![dispatcher.into(), "--help".into()]).is_ok()); + + // A name that merely ends in the suffix letters keeps them. + assert!(detect_applet_from_argv0("irtt-serverexe").is_none()); + } + + #[test] + fn windows_executable_suffix_handling_is_assertable_from_any_host() { + assert_eq!( + strip_suffix_ignoring_case("irtt-server.exe", ".exe"), + "irtt-server" + ); + assert_eq!(strip_suffix_ignoring_case("IRTT-RS.EXE", ".exe"), "IRTT-RS"); + assert_eq!( + strip_suffix_ignoring_case("irtt-server", ".exe"), + "irtt-server" + ); + assert_eq!( + strip_suffix_ignoring_case("irtt-serverexe", ".exe"), + "irtt-serverexe" + ); + assert_eq!(strip_suffix_ignoring_case(".exe", ".exe"), ".exe"); + assert_eq!( + strip_suffix_ignoring_case("irtt-server.exe", ""), + "irtt-server.exe" + ); + // A multi-byte tail must not split mid-character. + assert_eq!(strip_suffix_ignoring_case("irtt-så", ".exe"), "irtt-så"); + } + #[test] fn canonical_help_renders_dispatcher_help() { let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();