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:
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user