use std::io::{IsTerminal, Write};
use chrono::{DateTime, Utc};
pub const REASON_MIN_LEN: usize = 8;
#[derive(Debug, Clone)]
pub struct OperationContext {
pub operation: &'static str,
pub target_email: String,
pub target_user_id: i64,
pub target_role: String,
pub reason: String,
pub os_actor: String,
pub when: DateTime<Utc>,
}
pub fn validate_reason(raw: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!(
"--reason must not be empty (and must be at least {REASON_MIN_LEN} characters)"
));
}
if trimmed.chars().count() < REASON_MIN_LEN {
return Err(format!(
"--reason is too short: {} characters, minimum {REASON_MIN_LEN}",
trimmed.chars().count()
));
}
Ok(trimmed.to_string())
}
pub fn os_actor() -> String {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string());
let host = hostname_via_command().unwrap_or_else(|| "unknown".to_string());
format!("{user}@{host}")
}
fn hostname_via_command() -> Option<String> {
let output = std::process::Command::new("hostname").output().ok()?;
let s = String::from_utf8(output.stdout).ok()?;
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn now() -> DateTime<Utc> {
Utc::now()
}
pub fn format_banner(ctx: &OperationContext, with_ansi: bool) -> String {
let (red_on, red_off) = if with_ansi {
("\x1b[31m", "\x1b[0m")
} else {
("", "")
};
let target_line = format!(
"{} (user_id={}, role={})",
ctx.target_email, ctx.target_user_id, ctx.target_role
);
let when_iso = ctx.when.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut out = String::new();
out.push_str(&format!(
"{red_on}┌──────────────────────────────────────────────────────────────┐{red_off}\n"
));
out.push_str(&format!(
"{red_on}│ ⚠ EMERGENCY OPERATION -- RUSTIO ADMIN CLI │{red_off}\n"
));
out.push_str(&format!(
"{red_on}├──────────────────────────────────────────────────────────────┤{red_off}\n"
));
out.push_str(&body_row("Operation:", ctx.operation));
out.push_str(&body_row("Target:", &target_line));
out.push_str(&body_row("Reason:", &ctx.reason));
out.push_str(&body_row("Operator:", &ctx.os_actor));
out.push_str(&body_row("Time:", &when_iso));
out.push_str(&format!(
"{red_on}├──────────────────────────────────────────────────────────────┤{red_off}\n"
));
out.push_str(&body_row("", "This action is audited and irreversible."));
out.push_str(&format!(
"{red_on}└──────────────────────────────────────────────────────────────┘{red_off}\n"
));
out
}
pub fn print_banner(ctx: &OperationContext) {
let with_ansi = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
print!("{}", format_banner(ctx, with_ansi));
let _ = std::io::stdout().flush();
}
fn body_row(label: &str, value: &str) -> String {
let inside = if label.is_empty() {
format!(" {value}")
} else {
format!(" {label:<11}{value}")
};
let pad_target = 60_usize;
let inside_width = inside.chars().count();
if inside_width <= pad_target {
let pad = " ".repeat(pad_target - inside_width);
format!("│{inside}{pad}│\n")
} else {
format!("│{inside}│\n")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmOutcome {
Confirmed,
Aborted,
NeedsTtyOrYesFlag,
}
pub fn require_confirm(yes: bool) -> ConfirmOutcome {
if yes {
return ConfirmOutcome::Confirmed;
}
let is_tty = std::io::stdin().is_terminal();
if !is_tty {
return ConfirmOutcome::NeedsTtyOrYesFlag;
}
print!("Type 'yes' to confirm, anything else to abort: ");
let _ = std::io::stdout().flush();
let mut buf = String::new();
if std::io::stdin().read_line(&mut buf).is_err() {
return ConfirmOutcome::Aborted;
}
classify_response(&buf)
}
pub fn classify_response(line: &str) -> ConfirmOutcome {
if line.trim() == "yes" {
ConfirmOutcome::Confirmed
} else {
ConfirmOutcome::Aborted
}
}
pub fn redact_reason_in_argv(args: &[String]) -> String {
let mut out: Vec<String> = Vec::with_capacity(args.len());
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--reason" && i + 1 < args.len() {
out.push("--reason".to_string());
out.push("<redacted>".to_string());
i += 2;
} else if a.starts_with("--reason=") {
out.push("--reason=<redacted>".to_string());
i += 1;
} else {
out.push(a.clone());
i += 1;
}
}
out.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixed_ctx() -> OperationContext {
OperationContext {
operation: "reset-password",
target_email: "alice@example.com".to_string(),
target_user_id: 42,
target_role: "administrator".to_string(),
reason: "Lost MFA device + locked out, no other admins".to_string(),
os_actor: "mansour@studio.local".to_string(),
when: Utc.with_ymd_and_hms(2026, 5, 11, 16, 42, 0).unwrap(),
}
}
#[test]
fn reason_rejects_empty() {
let e = validate_reason("").unwrap_err();
assert!(e.contains("must not be empty"), "got {e}");
}
#[test]
fn reason_rejects_all_whitespace() {
let e = validate_reason(" \t\n").unwrap_err();
assert!(e.contains("must not be empty"), "got {e}");
}
#[test]
fn reason_rejects_too_short() {
let e = validate_reason("hello").unwrap_err();
assert!(e.contains("too short"), "got {e}");
assert!(e.contains("5 characters"), "got {e}");
}
#[test]
fn reason_rejects_seven_chars() {
let e = validate_reason("seven_x").unwrap_err();
assert!(e.contains("too short"), "got {e}");
}
#[test]
fn reason_accepts_eight_chars_exactly() {
let r = validate_reason("eightlen").unwrap();
assert_eq!(r, "eightlen");
}
#[test]
fn reason_trims_surrounding_whitespace() {
let r = validate_reason(" hello world ").unwrap();
assert_eq!(r, "hello world");
}
#[test]
fn banner_plain_renders_locked_layout() {
let ctx = fixed_ctx();
let out = format_banner(&ctx, false);
assert!(out.contains("EMERGENCY OPERATION -- RUSTIO ADMIN CLI"));
assert!(out.contains("Operation:"));
assert!(out.contains("Target:"));
assert!(out.contains("Reason:"));
assert!(out.contains("Operator:"));
assert!(out.contains("Time:"));
assert!(out.contains("reset-password"));
assert!(out.contains("alice@example.com"));
assert!(out.contains("user_id=42"));
assert!(out.contains("role=administrator"));
assert!(out.contains("Lost MFA device + locked out, no other admins"));
assert!(out.contains("mansour@studio.local"));
assert!(out.contains("2026-05-11T16:42:00Z"));
assert!(out.contains("This action is audited and irreversible."));
assert!(
!out.contains("\x1b["),
"plain banner must not contain ANSI escapes"
);
}
#[test]
fn banner_ansi_wraps_only_box_chars() {
let ctx = fixed_ctx();
let out = format_banner(&ctx, true);
assert!(out.contains("\x1b[31m"), "missing red ANSI prefix");
assert!(out.contains("\x1b[0m"), "missing ANSI reset");
let stripped = strip_ansi(&out);
let plain = format_banner(&ctx, false);
assert_eq!(stripped, plain);
}
#[test]
fn banner_long_reason_does_not_panic_or_truncate() {
let mut ctx = fixed_ctx();
ctx.reason = "x".repeat(200);
let out = format_banner(&ctx, false);
assert!(
out.contains(&"x".repeat(200)),
"reason was truncated; banner must echo verbatim"
);
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
while let Some(&nc) = chars.peek() {
chars.next();
if nc.is_ascii_alphabetic() {
break;
}
}
continue;
}
out.push(c);
}
out
}
#[test]
fn confirm_accepts_yes_exactly() {
assert_eq!(classify_response("yes"), ConfirmOutcome::Confirmed);
assert_eq!(classify_response("yes\n"), ConfirmOutcome::Confirmed);
assert_eq!(classify_response(" yes \n"), ConfirmOutcome::Confirmed);
}
#[test]
fn confirm_rejects_other_responses() {
for s in ["y", "Y", "YES", "yeah", "no", "n", "", "\n", "yes please"] {
assert_eq!(
classify_response(s),
ConfirmOutcome::Aborted,
"{s:?} should not confirm"
);
}
}
#[test]
fn yes_flag_returns_confirmed_without_reading_stdin() {
assert_eq!(require_confirm(true), ConfirmOutcome::Confirmed);
}
#[test]
fn redact_space_form() {
let argv: Vec<String> = [
"rustio",
"user",
"reset-password",
"--email",
"alice@example.com",
"--reason",
"lost MFA device, no other admins",
"--yes",
]
.iter()
.map(|s| s.to_string())
.collect();
let out = redact_reason_in_argv(&argv);
assert!(out.contains("--reason <redacted>"));
assert!(!out.contains("lost MFA"));
assert!(out.contains("--email alice@example.com"));
assert!(out.contains("--yes"));
}
#[test]
fn redact_equals_form() {
let argv: Vec<String> = [
"rustio",
"user",
"reset-password",
"--reason=lost MFA device",
"--email=alice@example.com",
]
.iter()
.map(|s| s.to_string())
.collect();
let out = redact_reason_in_argv(&argv);
assert!(out.contains("--reason=<redacted>"));
assert!(!out.contains("lost MFA"));
assert!(out.contains("--email=alice@example.com"));
}
#[test]
fn redact_no_reason_passes_through() {
let argv: Vec<String> = ["rustio", "user", "list"]
.iter()
.map(|s| s.to_string())
.collect();
let out = redact_reason_in_argv(&argv);
assert_eq!(out, "rustio user list");
}
#[test]
fn redact_lone_reason_flag_at_end_passes_through() {
let argv: Vec<String> = ["rustio", "--reason"]
.iter()
.map(|s| s.to_string())
.collect();
let out = redact_reason_in_argv(&argv);
assert_eq!(out, "rustio --reason");
}
}