new: validate wizard defaults like typed answers

ask_text accepted its default on Enter without running the question's
validator, so a probed upstream version like 1.0-2 or v1.0 sailed
through the whole questionnaire and crashed resolve() at the end, and
an invalid git-derived maintainer default (e.g. 'Name <>') was accepted
verbatim. ask_text now takes the validator and applies it to both typed
answers and the offered default — a default that fails validation is
withheld and an invalid answer re-asks — and all question call sites
(incl. the maintainer loop) route through it.
This commit is contained in:
2026-09-17 17:00:22 +02:00
parent 70e375a34d
commit d5b76ec8d8
+168 -65
View File
@@ -250,7 +250,10 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
let mut origin = packaged_dir.as_deref().and_then(GitOrigin::detect); let mut origin = packaged_dir.as_deref().and_then(GitOrigin::detect);
// 4. Upstream version: probed project version, then the tag HEAD sits // 4. Upstream version: probed project version, then the tag HEAD sits
// on, then `<lasttag>+git<YYYYMMDD>.<hash>`, then 0.1.0. // on, then `<lasttag>+git<YYYYMMDD>.<hash>`, then 0.1.0. A raw probe
// that fails `validate_upstream_version` (e.g. `1.0-2` or `v1.0`) is
// not offered as the default — the same validator `resolve` applies
// decides, so Enter can never accept it and crash late.
if cli.upstream_version.is_none() { if cli.upstream_version.is_none() {
let default = probe let default = probe
.as_ref() .as_ref()
@@ -259,9 +262,8 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version)) .or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
.unwrap_or_else(|| "0.1.0".to_string()); .unwrap_or_else(|| "0.1.0".to_string());
let revision = cli.revision.unwrap_or(1); let revision = cli.revision.unwrap_or(1);
let answer = ask_text("Upstream version", &default, move |version: &str| { let validate = move |version: &str| options::validate_upstream_version(version, revision);
options::validate_upstream_version(version, revision) let answer = ask_text("Upstream version", &default, validate)?;
})?;
cli.upstream_version = Some(answer.clone()); cli.upstream_version = Some(answer.clone());
// The typed version names an existing tag HEAD is not on: offer to // The typed version names an existing tag HEAD is not on: offer to
@@ -334,31 +336,25 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.as_ref() .as_ref()
.and_then(|p| p.description.clone()) .and_then(|p| p.description.clone())
.unwrap_or_default(); .unwrap_or_default();
loop { let validate = required_answer("the description");
let answer = ask_text( let answer = ask_text("One-line description", &default, validate)?;
"One-line description",
&default,
required_answer("the description"),
)?;
if !answer.trim().is_empty() {
cli.description = Some(answer); cli.description = Some(answer);
break;
}
log::warn!("A one-line description is required to scaffold a package");
}
} }
// 7. Homepage. // 7. Homepage (blank skips; a probed default must still be a valid URL).
if cli.homepage.is_none() { if cli.homepage.is_none() {
let default = probe let default = probe
.as_ref() .as_ref()
.and_then(|p| p.homepage.clone()) .and_then(|p| p.homepage.clone())
.unwrap_or_default(); .unwrap_or_default();
let answer = ask_text( let validate = |url: &str| {
"Homepage (blank to skip)", if url.is_empty() {
&default, Ok(())
options::validate_homepage, } else {
)?; options::validate_homepage(url)
}
};
let answer = ask_text("Homepage (blank to skip)", &default, validate)?;
if !answer.is_empty() { if !answer.is_empty() {
cli.homepage = Some(answer); cli.homepage = Some(answer);
} }
@@ -378,17 +374,8 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
options.contains(&answer.to_string()) options.contains(&answer.to_string())
})?; })?;
if answer == LICENSE_OTHER { if answer == LICENSE_OTHER {
let license = loop { let validate = required_answer("the license identifier");
let candidate = ask_text( let license = ask_text("License (SPDX identifier)", &custom_default, validate)?;
"License (SPDX identifier)",
&custom_default,
required_answer("the license identifier"),
)?;
if !candidate.trim().is_empty() {
break candidate;
}
log::warn!("A license identifier is required when picking the free-text entry");
};
cli.license = Some(license); cli.license = Some(license);
} else { } else {
cli.license = Some(answer); cli.license = Some(answer);
@@ -396,43 +383,41 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} }
// 9. Command name (skipped for the empty template, where nothing is // 9. Command name (skipped for the empty template, where nothing is
// installed). // installed). The probed default must pass the required-answer check
// too, or the question is asked without one.
if cli.command.is_none() && template != TemplateId::Empty { if cli.command.is_none() && template != TemplateId::Empty {
let default = probe let default = probe
.as_ref() .as_ref()
.and_then(|p| p.command.clone()) .and_then(|p| p.command.clone())
.unwrap_or_else(|| cli.name.clone().unwrap_or_default()); .unwrap_or_else(|| cli.name.clone().unwrap_or_default());
let command = ask_text( let validate = required_answer("the command name");
"Command name", let command = ask_text("Command name", &default, validate)?;
&default, cli.command = Some(command);
required_answer("the command name"),
)?;
cli.command = Some(if command.is_empty() {
cli.name.clone().unwrap_or_default()
} else {
command
});
} }
// 10. Maintainer, defaulting to the DEBEMAIL/git-config identity (an // 10. Maintainer, defaulting to the DEBEMAIL/git-config identity. The
// empty answer re-asks). // git-derived default goes through `parse_maintainer` like typed
// input: an empty git user.email yields a `Name <>` default that is
// ignored (the question is asked without one) instead of accepted
// verbatim and only failing late in `resolve`. An empty answer
// re-asks.
if cli.maintainer.is_none() { if cli.maintainer.is_none() {
let default = crate::changelog::get_maintainer_info() let candidate = crate::changelog::get_maintainer_info()
.map(|(name, email)| format!("{name} <{email}>")) .map(|(name, email)| format!("{name} <{email}>"))
.unwrap_or_default(); .unwrap_or_default();
let maintainer = loop { let default = if options::parse_maintainer(&candidate).is_ok() {
let answer = ask_text("Maintainer", &default, |answer: &str| { candidate
options::parse_maintainer(answer).map(|_| ()) } else {
})?; String::new()
if !answer.is_empty() { };
break answer; if default.is_empty() {
}
log::warn!( log::warn!(
"Could not determine a maintainer default (no DEBFULLNAME/\ "Could not determine a maintainer default (no DEBFULLNAME/\
DEBEMAIL and no git user config): answer as 'Name <email>'" DEBEMAIL and no git user config): answer as 'Name <email>'"
); );
}; }
cli.maintainer = Some(maintainer); let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
cli.maintainer = Some(ask_text("Maintainer", &default, validate)?);
} }
// 11. Target distribution. // 11. Target distribution.
@@ -472,10 +457,11 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// 13. Metapackage Depends (empty template only). // 13. Metapackage Depends (empty template only).
if template == TemplateId::Empty && cli.depends.is_empty() { if template == TemplateId::Empty && cli.depends.is_empty() {
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
let answer = ask_text( let answer = ask_text(
"Depends (metapackage, comma-separated, blank for an empty base)", "Depends (metapackage, comma-separated, blank for an empty base)",
"", "",
|answer: &str| options::validate_depends(answer).map(|_| ()), validate,
)?; )?;
if !answer.trim().is_empty() { if !answer.trim().is_empty() {
cli.depends = vec![answer]; cli.depends = vec![answer];
@@ -799,17 +785,54 @@ fn select_from(
} }
} }
/// The default a question is offered with: a probed default must clear the
/// same `validate` bar as typed input, so an unusable probe (e.g. an
/// upstream version like `1.0-2` carrying a Debian revision) is withheld —
/// the question is asked without a default instead of offering one that
/// Enter would accept verbatim.
fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a str {
if default.is_empty() || validate(default).is_ok() {
default
} else {
""
}
}
/// One round of [`ask_text`]: an empty answer takes the (pre-validated)
/// `default`, and whatever answer is finally proposed — typed or the
/// default — must pass `validate`. `Err` carries the validation error so the
/// caller re-asks with it.
fn accept_answer(
answer: &str,
default: &str,
validate: &prompt::Validator,
) -> Result<String, String> {
let answer = if answer.is_empty() { default } else { answer };
validate(answer).map(|_| answer.to_string())
}
/// One free-text question implementing the spec's "Enter accepts the /// One free-text question implementing the spec's "Enter accepts the
/// default": an empty answer falls back to `default` (`Esc` keeps its /// default": an empty answer falls back to the default (`Esc` keeps its
/// prompt-level meaning of restoring the default). `validate` only ever /// prompt-level meaning of restoring it). Both the typed answer and the
/// sees non-empty answers — the empty one is accepted by the prompt loop so /// default go through `validate`: a probed default that fails validation is
/// it can take the default path; callers that require an answer re-check /// never offered ([`offered_default`]), and an empty answer without a valid
/// the result. /// default is treated like any other invalid answer (re-ask with the
/// validation error) — probe data can never bypass validation and only blow
/// up later in [`options::resolve`].
fn ask_text( fn ask_text(
label: &str, label: &str,
default: &str, default: &str,
validate: impl Fn(&str) -> Result<(), String> + 'static, validate: impl Fn(&str) -> Result<(), String> + 'static,
) -> Result<String, Box<dyn Error>> { ) -> Result<String, Box<dyn Error>> {
// The offered default must clear the same bar as typed input: an
// unusable probe is withheld and the question is asked without one.
let default = offered_default(default, &validate);
// Enter/Esc (and the non-TTY fallback) return the default without
// validation, so pre-decide what an empty answer yields: the default
// itself (it passed the bar above), or the rejection it shares with any
// invalid answer when no valid default exists. Computed here because
// `validate` moves into the prompt wrapper below.
let empty_answer = accept_answer("", default, &validate);
let accept_empty = move |answer: &str| { let accept_empty = move |answer: &str| {
if answer.is_empty() { if answer.is_empty() {
Ok(()) Ok(())
@@ -817,12 +840,20 @@ fn ask_text(
validate(answer) validate(answer)
} }
}; };
loop {
let answer = prompt::text(label, default, Some(&accept_empty))?; let answer = prompt::text(label, default, Some(&accept_empty))?;
Ok(if answer.is_empty() { // Typed non-empty answers were already validated by the prompt; only
default.to_string() // an empty one resolves to the default, pre-decided above.
let answer = if answer.is_empty() {
empty_answer.clone()
} else { } else {
answer Ok(answer)
}) };
match answer {
Ok(answer) => return Ok(answer),
Err(error) => log::warn!("{error}"),
}
}
} }
/// A validator requiring a non-empty answer. /// A validator requiring a non-empty answer.
@@ -1404,6 +1435,78 @@ mod tests {
assert!(required_answer("x")("ok").is_ok()); assert!(required_answer("x")("ok").is_ok());
} }
/// A probed upstream-version default must clear the same
/// [`options::validate_upstream_version`] bar that `resolve` applies: a
/// raw probe carrying a Debian revision or a `v` prefix fails it, so the
/// wizard withholds it instead of offering it for blind Enter-acceptance
/// and crashing late in `resolve` (regression).
#[test]
fn probed_version_defaults_fail_upstream_validation() {
assert!(options::validate_upstream_version("1.0-2", 1).is_err());
assert!(options::validate_upstream_version("v1.0", 1).is_err());
assert!(options::validate_upstream_version("1.0.0", 1).is_ok());
let validate = |version: &str| options::validate_upstream_version(version, 1);
// Invalid probes are not offered as the default...
assert_eq!(offered_default("1.0-2", &validate), "");
assert_eq!(offered_default("v1.0", &validate), "");
// ...clean probes keep theirs, and no default stays none.
assert_eq!(offered_default("1.0.0", &validate), "1.0.0");
assert_eq!(offered_default("", &validate), "");
}
/// [`accept_answer`] routes typed answers and Enter-taken defaults
/// through the same validation: valid typed input accepted verbatim,
/// invalid typed input rejected (re-ask), an empty answer taking a valid
/// default, and an empty answer with an invalid or missing default
/// rejected like any other invalid answer.
#[test]
fn accept_answer_validates_typed_answers_and_defaults() {
let validate = |answer: &str| {
if answer == "ok" {
Ok(())
} else {
Err(format!("not ok: {answer}"))
}
};
assert_eq!(accept_answer("ok", "ok", &validate), Ok("ok".to_string()));
assert!(accept_answer("bad", "ok", &validate).is_err());
// An empty answer takes the default, which must itself validate: an
// invalid (or missing) default is rejected, not taken.
assert_eq!(accept_answer("", "ok", &validate), Ok("ok".to_string()));
assert!(accept_answer("", "bad", &validate).is_err());
assert!(accept_answer("", "", &validate).is_err());
// The upstream-version scenario end to end: Enter on an invalid
// probed default (offered as none) fails instead of taking it
// verbatim, while a valid default is taken.
let version = |v: &str| options::validate_upstream_version(v, 1);
assert!(accept_answer("", "1.0-2", &version).is_err());
assert_eq!(
accept_answer("", "1.0.0", &version),
Ok("1.0.0".to_string())
);
}
/// The git-derived maintainer default goes through
/// [`options::parse_maintainer`] before it is offered: an empty git
/// email yields a `Name <>` default that parses to an error and is
/// ignored, instead of being accepted verbatim and only failing late in
/// `resolve` (regression).
#[test]
fn maintainer_default_validated_by_parse_maintainer() {
assert!(
options::parse_maintainer("Jane Doe <jane@example.com>").is_ok(),
"a well-formed identity is offered as the default"
);
// Empty git user.email (or DEBEMAIL): `Name <>` must not parse.
assert!(options::parse_maintainer("Jane Doe <>").is_err());
// An empty git user.name: `<>` (or `<email>`) must not parse either.
assert!(options::parse_maintainer(" <jane@example.com>").is_err());
}
#[test] #[test]
fn select_labels_carry_their_own_separator() { fn select_labels_carry_their_own_separator() {
// prompt::select renders `> <label><answer>` verbatim; a label // prompt::select renders `> <label><answer>` verbatim; a label