use colored::Colorize;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use std::io::{self, BufRead, IsTerminal, Write};
use std::time::{Duration, Instant};
pub const DEFAULT_TIMEOUT_SECONDS: u64 = 5;
pub const DEFAULT_CODE_LENGTH: usize = 4;
pub const MAX_CODE_LENGTH: usize = 8;
pub const MIN_CODE_LENGTH: usize = 4;
pub const MAX_TIMEOUT_SECONDS: u64 = 30;
pub const MIN_TIMEOUT_SECONDS: u64 = 1;
const CODE_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VerificationMethod {
Code,
Command,
None,
}
impl Default for VerificationMethod {
fn default() -> Self {
Self::Code
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InteractiveResult {
AllowlistRequested(AllowlistScope),
InvalidCode,
Timeout,
Cancelled,
NotAvailable(NotAvailableReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotAvailableReason {
NotTty,
CiEnvironment,
Disabled,
MissingEnv(String),
UnsuitableTerminal,
}
impl std::fmt::Display for NotAvailableReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotTty => write!(f, "stdin is not a terminal (TTY)"),
Self::CiEnvironment => write!(f, "running in CI environment"),
Self::Disabled => write!(f, "interactive mode is disabled in configuration"),
Self::MissingEnv(var) => write!(f, "required environment variable '{var}' is not set"),
Self::UnsuitableTerminal => write!(f, "terminal environment is not suitable"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllowlistScope {
Once,
Session,
Temporary(Duration),
Permanent,
}
impl std::fmt::Display for AllowlistScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Once => write!(f, "once (this execution only)"),
Self::Session => write!(f, "session (until terminal closes)"),
Self::Temporary(d) => write!(f, "temporary ({} hours)", d.as_secs() / 3600),
Self::Permanent => write!(f, "permanent (added to allowlist)"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractiveConfig {
pub enabled: bool,
pub verification: VerificationMethod,
pub timeout_seconds: u64,
pub code_length: usize,
pub max_attempts: u32,
pub allow_non_tty_fallback: bool,
pub disable_in_ci: bool,
pub require_env: Option<String>,
}
impl Default for InteractiveConfig {
fn default() -> Self {
Self {
enabled: false, verification: VerificationMethod::Code,
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
code_length: DEFAULT_CODE_LENGTH,
max_attempts: 3,
allow_non_tty_fallback: true,
disable_in_ci: true,
require_env: None,
}
}
}
impl InteractiveConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn timeout(&self) -> Duration {
Duration::from_secs(
self.timeout_seconds
.clamp(MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS),
)
}
#[must_use]
pub fn effective_code_length(&self) -> usize {
self.code_length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH)
}
}
#[must_use]
pub fn generate_verification_code(length: usize) -> String {
let length = length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH);
let mut rng = rand::rng();
(0..length)
.map(|_| {
let idx = rng.random_range(0..CODE_CHARSET.len());
CODE_CHARSET[idx] as char
})
.collect()
}
#[must_use]
pub fn validate_code(input: &str, expected: &str) -> bool {
input.trim().eq_ignore_ascii_case(expected)
}
pub fn check_interactive_available(config: &InteractiveConfig) -> Result<(), NotAvailableReason> {
let stdin_is_tty = io::stdin().is_terminal();
let ci_environment = is_ci_environment();
let term_is_dumb = matches!(std::env::var("TERM").as_deref(), Ok("dumb"));
check_interactive_available_with_context(config, stdin_is_tty, ci_environment, term_is_dumb)
}
fn check_interactive_available_with_context(
config: &InteractiveConfig,
stdin_is_tty: bool,
ci_environment: bool,
term_is_dumb: bool,
) -> Result<(), NotAvailableReason> {
if !config.enabled {
return Err(NotAvailableReason::Disabled);
}
if let Some(var) = config.require_env.as_ref() {
if std::env::var(var).is_err() {
return Err(NotAvailableReason::MissingEnv(var.clone()));
}
}
if !stdin_is_tty {
return Err(NotAvailableReason::NotTty);
}
if config.disable_in_ci && ci_environment {
return Err(NotAvailableReason::CiEnvironment);
}
if term_is_dumb {
return Err(NotAvailableReason::UnsuitableTerminal);
}
Ok(())
}
fn is_ci_environment() -> bool {
["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS"]
.iter()
.any(|var| std::env::var(var).is_ok())
}
#[allow(clippy::too_many_lines)]
pub fn run_interactive_prompt(
command: &str,
reason: &str,
rule_id: Option<&str>,
config: &InteractiveConfig,
) -> InteractiveResult {
if let Err(reason) = check_interactive_available(config) {
return InteractiveResult::NotAvailable(reason);
}
let timeout = config.timeout();
match config.verification {
VerificationMethod::Code => {
let code = generate_verification_code(config.effective_code_length());
display_prompt(
command,
reason,
rule_id,
VerificationMethod::Code,
Some(&code),
timeout,
);
match read_input_with_timeout(timeout) {
Ok(input) => {
let input = input.trim();
if input.is_empty() {
return InteractiveResult::Cancelled;
}
if validate_code(input, &code) {
match select_allowlist_scope() {
Ok(scope) => InteractiveResult::AllowlistRequested(scope),
Err(_) => InteractiveResult::Cancelled,
}
} else {
InteractiveResult::InvalidCode
}
}
Err(InputError::Timeout) => InteractiveResult::Timeout,
Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
}
}
VerificationMethod::Command => {
display_prompt(
command,
reason,
rule_id,
VerificationMethod::Command,
None,
timeout,
);
match read_input_with_timeout(timeout) {
Ok(input) => {
let input = input.trim();
if input.is_empty() {
return InteractiveResult::Cancelled;
}
if input == command {
match select_allowlist_scope() {
Ok(scope) => InteractiveResult::AllowlistRequested(scope),
Err(_) => InteractiveResult::Cancelled,
}
} else {
InteractiveResult::InvalidCode
}
}
Err(InputError::Timeout) => InteractiveResult::Timeout,
Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
}
}
VerificationMethod::None => {
display_prompt(
command,
reason,
rule_id,
VerificationMethod::None,
None,
timeout,
);
match select_allowlist_scope() {
Ok(scope) => InteractiveResult::AllowlistRequested(scope),
Err(_) => InteractiveResult::Cancelled,
}
}
}
}
fn display_prompt(
command: &str,
reason: &str,
rule_id: Option<&str>,
verification: VerificationMethod,
code: Option<&str>,
timeout: Duration,
) {
let stderr = io::stderr();
let mut handle = stderr.lock();
const WIDTH: usize = 66;
let write_line = |handle: &mut std::io::StderrLock<'_>, content: &str, style: &str| {
let visible_len = content.chars().count();
let padding = WIDTH.saturating_sub(visible_len);
match style {
"red" => {
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
content,
" ".repeat(padding),
"\u{2502}".red()
);
}
_ => {
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
content,
" ".repeat(padding),
"\u{2502}".red()
);
}
}
};
let _ = writeln!(
handle,
"{}{}{}",
"\u{256d}".red(),
"\u{2500}".repeat(WIDTH).red(),
"\u{256e}".red()
);
let cmd_prefix = " \u{1f6d1} BLOCKED: ";
let max_cmd_len = WIDTH - cmd_prefix.chars().count() - 1;
let display_cmd = if command.chars().count() > max_cmd_len {
format!(
"{}...",
command.chars().take(max_cmd_len - 3).collect::<String>()
)
} else {
command.to_string()
};
let header = format!("{cmd_prefix}{display_cmd}");
let header_padding = WIDTH.saturating_sub(header.chars().count());
let _ = writeln!(
handle,
"{} {} {}{}{}",
"\u{2502}".red(),
"\u{1f6d1}",
format!("BLOCKED: {display_cmd}").white().bold(),
" ".repeat(header_padding.saturating_sub(2)),
"\u{2502}".red()
);
let _ = writeln!(
handle,
"{}{}{}",
"\u{251c}".red(),
"\u{2500}".repeat(WIDTH).red().dimmed(),
"\u{2524}".red()
);
if let Some(rule) = rule_id {
let rule_line = format!(" Rule: {rule}");
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
rule_line.yellow(),
" ".repeat(WIDTH.saturating_sub(rule_line.chars().count())),
"\u{2502}".red()
);
}
let reason_line = format!(" Reason: {reason}");
let truncated_reason = if reason_line.chars().count() > WIDTH - 2 {
format!(
"{}...",
reason_line.chars().take(WIDTH - 5).collect::<String>()
)
} else {
reason_line
};
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
truncated_reason.bright_black(),
" ".repeat(WIDTH.saturating_sub(truncated_reason.chars().count())),
"\u{2502}".red()
);
let _ = writeln!(
handle,
"{}{}{}",
"\u{251c}".red(),
"\u{2500}".repeat(WIDTH).red().dimmed(),
"\u{2524}".red()
);
let options = [
("o", "Allowlist once (this execution only)"),
("t", "Allowlist temporarily (24 hours)"),
("p", "Allowlist permanently (add to project)"),
("Enter", "Keep blocked"),
];
for (key, desc) in &options {
let option_line = if *key == "Enter" {
format!(" [{}] {}", key.bright_black(), desc.bright_black())
} else {
format!(" [{}] {}", key.cyan(), desc.white())
};
let visible_len = 2 + 1 + key.len() + 1 + 1 + desc.len(); let padding = WIDTH.saturating_sub(visible_len);
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
option_line,
" ".repeat(padding),
"\u{2502}".red()
);
}
write_line(&mut handle, "", "red");
let _ = writeln!(
handle,
"{}{}{}",
"\u{251c}".red(),
"\u{2500}".repeat(WIDTH).red().dimmed(),
"\u{2524}".red()
);
let mut show_input_prompt = true;
match verification {
VerificationMethod::Code => {
let code = code.unwrap_or_default();
let verify_prefix = " To proceed, type: ";
let verify_visible_len = verify_prefix.len() + code.len();
let verify_padding = WIDTH.saturating_sub(verify_visible_len);
let _ = writeln!(
handle,
"{}{}{}{}{}",
"\u{2502}".red(),
verify_prefix.white(),
code.bright_yellow().bold(),
" ".repeat(verify_padding),
"\u{2502}".red()
);
let timeout_secs = timeout.as_secs();
let timeout_line = format!(" ({timeout_secs} seconds remaining)");
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
timeout_line.bright_black(),
" ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
"\u{2502}".red()
);
}
VerificationMethod::Command => {
let verify_line = " To proceed, retype the full command:";
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
verify_line.white(),
" ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
"\u{2502}".red()
);
let timeout_secs = timeout.as_secs();
let timeout_line = format!(" ({timeout_secs} seconds remaining)");
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
timeout_line.bright_black(),
" ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
"\u{2502}".red()
);
}
VerificationMethod::None => {
let verify_line = " Verification disabled (least secure).";
let _ = writeln!(
handle,
"{}{}{}{}",
"\u{2502}".red(),
verify_line.bright_black(),
" ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
"\u{2502}".red()
);
show_input_prompt = false;
}
}
let _ = writeln!(
handle,
"{}{}{}",
"\u{2570}".red(),
"\u{2500}".repeat(WIDTH).red(),
"\u{256f}".red()
);
if show_input_prompt {
let _ = write!(handle, "{} ", ">".green().bold());
let _ = handle.flush();
}
}
#[derive(Debug)]
enum InputError {
Timeout,
#[allow(dead_code)] Io(io::Error),
Interrupted,
}
fn read_input_with_timeout(timeout: Duration) -> Result<String, InputError> {
let start = Instant::now();
let stdin = io::stdin();
let mut input = String::new();
let handle = stdin.lock();
let mut reader = io::BufReader::new(handle);
match reader.read_line(&mut input) {
Ok(0) => Err(InputError::Interrupted), Ok(_) => {
if start.elapsed() > timeout {
return Err(InputError::Timeout);
}
Ok(input)
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => Err(InputError::Interrupted),
Err(e) => Err(InputError::Io(e)),
}
}
fn select_allowlist_scope() -> Result<AllowlistScope, InputError> {
let stderr = io::stderr();
let mut handle = stderr.lock();
let _ = writeln!(handle);
let _ = writeln!(handle, "{}", "Verification successful!".green().bold());
let _ = writeln!(handle);
let _ = writeln!(handle, "Select allowlist scope:");
let _ = writeln!(handle, " {} Once (this execution only)", "[o]".cyan());
let _ = writeln!(handle, " {} Session (until terminal closes)", "[s]".cyan());
let _ = writeln!(handle, " {} Temporary (24 hours)", "[t]".cyan());
let _ = writeln!(
handle,
" {} Permanent (add to project allowlist)",
"[p]".cyan()
);
let _ = writeln!(handle);
let _ = write!(handle, "{} ", "Choice [o/s/t/p]:".white());
let _ = handle.flush();
let stdin = io::stdin();
let mut input = String::new();
let handle = stdin.lock();
let mut reader = io::BufReader::new(handle);
match reader.read_line(&mut input) {
Ok(0) => Err(InputError::Interrupted),
Ok(_) => {
let choice = input.trim().to_lowercase();
match choice.as_str() {
"o" | "once" | "1" => Ok(AllowlistScope::Once),
"s" | "session" | "2" => Ok(AllowlistScope::Session),
"t" | "temporary" | "temp" | "3" => {
Ok(AllowlistScope::Temporary(Duration::from_secs(24 * 3600)))
}
"p" | "permanent" | "perm" | "4" => Ok(AllowlistScope::Permanent),
"" => Ok(AllowlistScope::Once), _ => Ok(AllowlistScope::Once), }
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => Err(InputError::Interrupted),
Err(e) => Err(InputError::Io(e)),
}
}
pub fn print_not_available_message(reason: &NotAvailableReason) {
let stderr = io::stderr();
let mut handle = stderr.lock();
let _ = writeln!(
handle,
"{} Interactive mode not available: {}",
"[dcg]".bright_black(),
reason
);
if matches!(reason, NotAvailableReason::NotTty) {
let _ = writeln!(
handle,
"{} This is a security feature to prevent automated bypass.",
" ".repeat(5)
);
let _ = writeln!(
handle,
"{} Run dcg in an interactive terminal to use this feature.",
" ".repeat(5)
);
} else if let NotAvailableReason::MissingEnv(var) = reason {
let _ = writeln!(
handle,
"{} Set {} to enable interactive prompts.",
" ".repeat(5),
var
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
const CI_ENV_VARS: [&str; 5] = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS"];
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock poisoned")
}
fn with_clean_ci_env<F>(f: F)
where
F: FnOnce(),
{
let _guard = env_lock();
let saved: Vec<(&str, Option<OsString>)> = CI_ENV_VARS
.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect();
for key in CI_ENV_VARS {
unsafe {
std::env::remove_var(key);
}
}
f();
for (key, value) in saved {
match value {
Some(existing) => unsafe {
std::env::set_var(key, existing);
},
None => unsafe {
std::env::remove_var(key);
},
}
}
}
#[test]
fn test_generate_verification_code_length() {
let code = generate_verification_code(4);
assert_eq!(code.len(), 4);
let code = generate_verification_code(6);
assert_eq!(code.len(), 6);
let code = generate_verification_code(8);
assert_eq!(code.len(), 8);
}
#[test]
fn test_generate_verification_code_clamps_length() {
let code = generate_verification_code(2);
assert_eq!(code.len(), MIN_CODE_LENGTH);
let code = generate_verification_code(20);
assert_eq!(code.len(), MAX_CODE_LENGTH);
}
#[test]
fn test_generate_verification_code_valid_characters() {
let code = generate_verification_code(100); for c in code.chars() {
assert!(
c.is_ascii_lowercase() || c.is_ascii_digit(),
"Invalid character in code: {c}"
);
}
}
#[test]
fn test_generate_verification_code_randomness() {
let codes: Vec<String> = (0..10).map(|_| generate_verification_code(4)).collect();
let unique_count = codes.iter().collect::<std::collections::HashSet<_>>().len();
assert!(
unique_count > 5,
"Generated codes should be mostly unique, got {unique_count} unique out of 10"
);
}
#[test]
fn test_code_charset_excludes_ambiguous_chars() {
let charset = std::str::from_utf8(CODE_CHARSET).unwrap();
for ch in ['i', 'l', 'o', '0', '1'] {
assert!(!charset.contains(ch), "charset should not contain '{ch}'");
}
}
#[test]
fn test_validate_code_case_insensitive() {
assert!(validate_code("AbC", "abc"));
assert!(validate_code(" abc ", "aBc"));
assert!(!validate_code("abcd", "abc"));
}
#[test]
fn test_interactive_config_defaults() {
let config = InteractiveConfig::default();
assert!(!config.enabled);
assert_eq!(config.verification, VerificationMethod::Code);
assert_eq!(config.timeout_seconds, DEFAULT_TIMEOUT_SECONDS);
assert_eq!(config.code_length, DEFAULT_CODE_LENGTH);
assert_eq!(config.max_attempts, 3);
assert!(config.allow_non_tty_fallback);
assert!(config.disable_in_ci);
assert!(config.require_env.is_none());
}
#[test]
fn test_interactive_config_timeout() {
let mut config = InteractiveConfig::default();
config.timeout_seconds = 10;
assert_eq!(config.timeout(), Duration::from_secs(10));
config.timeout_seconds = 0;
assert_eq!(config.timeout(), Duration::from_secs(MIN_TIMEOUT_SECONDS));
config.timeout_seconds = 100;
assert_eq!(config.timeout(), Duration::from_secs(MAX_TIMEOUT_SECONDS));
}
#[test]
fn test_interactive_config_effective_code_length() {
let mut config = InteractiveConfig::default();
config.code_length = 6;
assert_eq!(config.effective_code_length(), 6);
config.code_length = 1;
assert_eq!(config.effective_code_length(), MIN_CODE_LENGTH);
config.code_length = 100;
assert_eq!(config.effective_code_length(), MAX_CODE_LENGTH);
}
#[test]
fn test_not_available_reason_display() {
assert_eq!(
NotAvailableReason::NotTty.to_string(),
"stdin is not a terminal (TTY)"
);
assert_eq!(
NotAvailableReason::CiEnvironment.to_string(),
"running in CI environment"
);
assert_eq!(
NotAvailableReason::Disabled.to_string(),
"interactive mode is disabled in configuration"
);
assert_eq!(
NotAvailableReason::MissingEnv("DCG_INTERACTIVE".to_string()).to_string(),
"required environment variable 'DCG_INTERACTIVE' is not set"
);
assert_eq!(
NotAvailableReason::UnsuitableTerminal.to_string(),
"terminal environment is not suitable"
);
}
#[test]
fn test_allowlist_scope_display() {
assert_eq!(
AllowlistScope::Once.to_string(),
"once (this execution only)"
);
assert_eq!(
AllowlistScope::Session.to_string(),
"session (until terminal closes)"
);
assert_eq!(
AllowlistScope::Temporary(Duration::from_secs(24 * 3600)).to_string(),
"temporary (24 hours)"
);
assert_eq!(
AllowlistScope::Permanent.to_string(),
"permanent (added to allowlist)"
);
}
#[test]
fn test_check_interactive_disabled() {
let config = InteractiveConfig {
enabled: false,
..Default::default()
};
assert_eq!(
check_interactive_available(&config),
Err(NotAvailableReason::Disabled)
);
}
#[test]
fn test_check_interactive_not_tty() {
let config = InteractiveConfig {
enabled: true,
..Default::default()
};
assert_eq!(
check_interactive_available_with_context(&config, false, false, false),
Err(NotAvailableReason::NotTty)
);
}
#[test]
fn test_check_interactive_ci_environment_blocked() {
let config = InteractiveConfig {
enabled: true,
disable_in_ci: true,
..Default::default()
};
assert_eq!(
check_interactive_available_with_context(&config, true, true, false),
Err(NotAvailableReason::CiEnvironment)
);
}
#[test]
fn test_check_interactive_ci_environment_allowed_when_disabled() {
let config = InteractiveConfig {
enabled: true,
disable_in_ci: false,
..Default::default()
};
assert_eq!(
check_interactive_available_with_context(&config, true, true, false),
Ok(())
);
}
#[test]
fn test_is_ci_environment_false_when_no_known_vars_are_set() {
with_clean_ci_env(|| {
assert!(
!is_ci_environment(),
"Expected CI detection to be false with no CI env vars set"
);
});
}
#[test]
fn test_is_ci_environment_detects_each_supported_variable() {
for key in CI_ENV_VARS {
with_clean_ci_env(|| {
unsafe {
std::env::set_var(key, "1");
}
assert!(
is_ci_environment(),
"Expected CI detection to be true when {key} is set"
);
});
}
}
#[test]
fn test_check_interactive_unsuitable_terminal() {
let config = InteractiveConfig {
enabled: true,
..Default::default()
};
assert_eq!(
check_interactive_available_with_context(&config, true, false, true),
Err(NotAvailableReason::UnsuitableTerminal)
);
}
#[test]
fn test_check_interactive_missing_required_env() {
let config = InteractiveConfig {
enabled: true,
require_env: Some("DCG_INTERACTIVE_TEST_SENTINEL_UNSET".to_string()),
..Default::default()
};
assert_eq!(
check_interactive_available_with_context(&config, true, false, false),
Err(NotAvailableReason::MissingEnv(
"DCG_INTERACTIVE_TEST_SENTINEL_UNSET".to_string()
))
);
}
#[test]
fn test_check_interactive_available_when_requirements_met() {
let config = InteractiveConfig {
enabled: true,
disable_in_ci: true,
require_env: None,
..Default::default()
};
assert_eq!(
check_interactive_available_with_context(&config, true, false, false),
Ok(())
);
}
}