From dd006f7b809b3e072139a8e57b2ce142320437dd Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 17 Sep 2026 17:03:43 +0200 Subject: [PATCH] new: validate the command name before generating files The command/binary name was accepted verbatim and interpolated into debian/install, debian/rules, debian/tests/smoke, automake variables, meson.build and [project.scripts]: a value with a space or quote broke the install lines and shell snippets, 'my.tool' parsed as a nested TOML table (silently dropping the console script) and produced non-canonical automake variable names. Both --command and the wizard answer now go through a shared validator (lowercase identifier: letters, digits, + - . _). --- src/new/options.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++ src/new/questions.rs | 9 ++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/new/options.rs b/src/new/options.rs index ee418f5..6a878df 100644 --- a/src/new/options.rs +++ b/src/new/options.rs @@ -473,6 +473,29 @@ pub fn sanitize_name(input: &str) -> Option { Some(out) } +/// Validate the installed command (binary) name: a non-empty, ASCII-only, +/// lowercase identifier (`^[a-z0-9][a-z0-9+.\-_]*$`), matching what +/// dpkg/devscripts accept for executable names in practice. The name is +/// interpolated verbatim into `debian/install`, `debian/rules`, the +/// autopkgtest smoke test, automake variables (`{command}_SOURCES`), +/// `meson.build` and the `[project.scripts]` table, so uppercase letters, +/// whitespace, quotes and shell metacharacters are rejected here instead of +/// generating broken install lines and build files. +pub fn validate_command(command: &str) -> Result<(), String> { + static COMMAND_REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + let regex = COMMAND_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-_]*$").unwrap()); + if command.is_empty() { + return Err("the command name must not be empty".to_string()); + } + if !regex.is_match(command) { + return Err(format!( + "'{command}' is not a valid command name: commands must be a \ + lowercase identifier (letters, digits, + - . _), e.g. 'mytool'" + )); + } + Ok(()) +} + /// Validate an upstream version: it must start with a digit (dpkg /// recommendation, enforced here) and survive [`DebianVersion::parse`] once /// composed with the Debian revision. It must not contain `-` (the revision @@ -763,6 +786,9 @@ pub async fn resolve(cli: NewCli) -> Result { let license = License::parse(cli.license.as_deref().unwrap_or("unknown")); let command = cli.command.unwrap_or_else(|| name.clone()); + // The default is the already-validated package name (a subset of the + // command charset), so only an explicit --command/wizard answer can fail. + validate_command(&command)?; let maintainer = match &cli.maintainer { Some(m) => parse_maintainer(m)?, @@ -988,6 +1014,39 @@ mod tests { } } + /// The command name is interpolated verbatim into debian/install, + /// debian/rules, the smoke test, automake variables, meson.build and + /// [project.scripts], so only the safe identifier charset passes. + #[test] + fn command_validator() { + for valid in [ + "mytool", + "my.tool", // automake/TOML-friendly spellings in use in Debian + "my+tool", + "my_tool", + "my-tool", + "2ping", // leading digit + "a", // single character + "a1.b+c-d_e", + ] { + assert!(validate_command(valid).is_ok(), "{valid} must pass"); + } + for invalid in [ + "my tool", // whitespace breaks install lines + "a\"b", // quotes break shell snippets + "MyTool", // uppercase + "-lead", // bad first char + "_lead", // bad first char + ".dot", // bad first char + "café", // non-ASCII + "a:b", // shell metacharacter + "a/b", // path separator + "", // empty + ] { + assert!(validate_command(invalid).is_err(), "{invalid} must fail"); + } + } + #[test] fn sanitize_name_derives_valid_names() { assert_eq!(sanitize_name("My Tool"), Some("my-tool".to_string())); diff --git a/src/new/questions.rs b/src/new/questions.rs index d64736c..1c41771 100644 --- a/src/new/questions.rs +++ b/src/new/questions.rs @@ -383,15 +383,16 @@ async fn run_wizard(mut cli: NewCli) -> Result> { } // 9. Command name (skipped for the empty template, where nothing is - // installed). The probed default must pass the required-answer check - // too, or the question is asked without one. + // installed). Typed answers and the offered default go through the + // same `validate_command` bar as `resolve` applies (which also + // requires a non-empty answer), so an unusable probe is withheld and + // invalid input re-asks here instead of failing late in `resolve`. if cli.command.is_none() && template != TemplateId::Empty { let default = probe .as_ref() .and_then(|p| p.command.clone()) .unwrap_or_else(|| cli.name.clone().unwrap_or_default()); - let validate = required_answer("the command name"); - let command = ask_text("Command name", &default, validate)?; + let command = ask_text("Command name", &default, options::validate_command)?; cli.command = Some(command); }