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,
+ - . _).
This commit is contained in:
2026-09-17 17:03:43 +02:00
parent d5b76ec8d8
commit dd006f7b80
2 changed files with 64 additions and 4 deletions
+59
View File
@@ -473,6 +473,29 @@ pub fn sanitize_name(input: &str) -> Option<String> {
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<Regex> = 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<NewOptions, String> {
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()));
+5 -4
View File
@@ -383,15 +383,16 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
}
// 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);
}