diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 84ff854a..b077b979 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -147,6 +147,31 @@ pub async fn is_present() -> bool { stdout.lines().any(|l| l.trim() == FORGE_CONTAINER) } +/// Name a `forgejo admin` invocation for an error message without +/// reproducing its arguments: keep the leading verb path, stop at the +/// first flag. `["user", "create", "--username", "iris", "--password", +/// "…"]` becomes `forgejo admin user create`. +/// +/// The verbs are an **allowlist**, and that is the whole point. Some +/// callers pass a live secret as an argument value (`--password`), so a +/// message built from the raw vector puts it in the log. Listing the +/// flags to *hide* instead would repeat the bug this guards against: a +/// newly added secret-bearing flag would leak until someone remembered +/// to extend the list. Verbs are a closed set this crate chooses +/// itself; argument values never are. +fn describe_forge_admin(args: &[&str]) -> String { + let verbs: Vec<&str> = args + .iter() + .take_while(|a| !a.starts_with('-')) + .copied() + .collect(); + if verbs.is_empty() { + "forgejo admin".to_owned() + } else { + format!("forgejo admin {}", verbs.join(" ")) + } +} + /// Run `forgejo admin ` inside the hive-forge container as the /// forgejo user (the only uid with write access to the state dir). /// Returns stdout on success; bails with stderr context on failure. @@ -158,7 +183,7 @@ async fn forge_admin(args: &[&str]) -> Result { // nsenter: stat of /proc//ns/user failed: Permission denied let (stdout, _stderr) = crate::priv_client::run_forge_admin(args) .await - .with_context(|| format!("forgejo admin {} (via hive-priv)", args.join(" ")))?; + .with_context(|| format!("{} (via hive-priv)", describe_forge_admin(args)))?; Ok(stdout) } @@ -561,3 +586,44 @@ pub async fn ensure_config_pr_webhook( tracing::info!(%target_url, "forge: config-pr webhook created on org {CONFIG_ORG}"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::describe_forge_admin; + + #[test] + fn describe_keeps_the_verb_path_and_drops_every_value() { + assert_eq!( + describe_forge_admin(&[ + "user", + "create", + "--username", + "iris", + "--email", + "iris@hyperhive.local", + "--random-password", + ]), + "forgejo admin user create" + ); + } + + #[test] + fn describe_does_not_reproduce_a_password_argument() { + let described = describe_forge_admin(&[ + "user", + "change-password", + "--username", + "iris", + "--password", + "correct-horse-battery-staple", + ]); + assert_eq!(described, "forgejo admin user change-password"); + assert!(!described.contains("correct-horse-battery-staple")); + } + + #[test] + fn describe_survives_a_leading_flag_and_an_empty_vector() { + assert_eq!(describe_forge_admin(&["--help"]), "forgejo admin"); + assert_eq!(describe_forge_admin(&[]), "forgejo admin"); + } +}