Skip to main content

destructive_command_guard/
interactive.rs

1//! Interactive mode for dcg - allows users to bypass blocks via terminal interaction.
2//!
3//! This module implements the security-critical interactive prompt that allows human users
4//! (but not AI agents) to bypass blocked commands. See `docs/security-model.md` for the
5//! full threat model and design rationale.
6//!
7//! # Security Model
8//!
9//! Interactive mode defaults to a random verification code + timeout, and can
10//! be configured to use other verification methods when explicitly enabled.
11//!
12//! This combination prevents automated bypass by AI agents while remaining usable for humans.
13//!
14//! # Critical Security Checks
15//!
16//! - **TTY detection**: If stdin is not a TTY, interactive mode is disabled (piped input = agent)
17//! - **CI detection**: Interactive mode is disabled in CI environments
18//! - **Code freshness**: Each prompt generates a new code; codes are single-use
19
20use colored::Colorize;
21use rand::RngExt;
22use serde::{Deserialize, Serialize};
23use std::io::{self, BufRead, IsTerminal, Write};
24use std::time::{Duration, Instant};
25
26/// Default timeout for interactive prompts (5 seconds).
27pub const DEFAULT_TIMEOUT_SECONDS: u64 = 5;
28
29/// Default verification code length (4 characters).
30pub const DEFAULT_CODE_LENGTH: usize = 4;
31
32/// Maximum verification code length.
33pub const MAX_CODE_LENGTH: usize = 8;
34
35/// Minimum verification code length.
36pub const MIN_CODE_LENGTH: usize = 4;
37
38/// Maximum timeout in seconds.
39pub const MAX_TIMEOUT_SECONDS: u64 = 30;
40
41/// Minimum timeout in seconds.
42pub const MIN_TIMEOUT_SECONDS: u64 = 1;
43
44/// Character set for verification codes (lowercase, unambiguous).
45/// Excludes visually confusing characters: i, l, o, 0, 1.
46const CODE_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
47
48/// Verification method for interactive prompts.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum VerificationMethod {
52    /// Random verification code (default).
53    Code,
54    /// Retype the full command.
55    Command,
56    /// No verification (least secure).
57    None,
58}
59
60impl Default for VerificationMethod {
61    fn default() -> Self {
62        Self::Code
63    }
64}
65
66/// Result of an interactive prompt session.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum InteractiveResult {
69    /// User entered correct code and selected an allowlist option.
70    AllowlistRequested(AllowlistScope),
71
72    /// User entered incorrect code.
73    InvalidCode,
74
75    /// Timeout expired before user responded.
76    Timeout,
77
78    /// User cancelled (pressed Enter without input or Ctrl+C).
79    Cancelled,
80
81    /// Interactive mode not available (not a TTY, CI environment, etc.).
82    NotAvailable(NotAvailableReason),
83}
84
85/// Reason why interactive mode is not available.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum NotAvailableReason {
88    /// stdin is not a TTY (piped input, likely from an AI agent).
89    NotTty,
90
91    /// Running in a CI environment.
92    CiEnvironment,
93
94    /// Interactive mode is disabled in configuration.
95    Disabled,
96
97    /// Required environment variable is not set.
98    MissingEnv(String),
99
100    /// Terminal environment is not suitable (TERM=dumb, etc.).
101    UnsuitableTerminal,
102}
103
104impl std::fmt::Display for NotAvailableReason {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Self::NotTty => write!(f, "stdin is not a terminal (TTY)"),
108            Self::CiEnvironment => write!(f, "running in CI environment"),
109            Self::Disabled => write!(f, "interactive mode is disabled in configuration"),
110            Self::MissingEnv(var) => write!(f, "required environment variable '{var}' is not set"),
111            Self::UnsuitableTerminal => write!(f, "terminal environment is not suitable"),
112        }
113    }
114}
115
116/// Scope for allowlisting a command.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum AllowlistScope {
119    /// Allow this single execution only.
120    Once,
121
122    /// Allow for the current session (until terminal closes).
123    Session,
124
125    /// Allow temporarily (24 hours by default).
126    Temporary(Duration),
127
128    /// Add to permanent allowlist.
129    Permanent,
130}
131
132impl std::fmt::Display for AllowlistScope {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::Once => write!(f, "once (this execution only)"),
136            Self::Session => write!(f, "session (until terminal closes)"),
137            Self::Temporary(d) => write!(f, "temporary ({} hours)", d.as_secs() / 3600),
138            Self::Permanent => write!(f, "permanent (added to allowlist)"),
139        }
140    }
141}
142
143/// Configuration for interactive mode.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct InteractiveConfig {
146    /// Whether interactive mode is enabled.
147    pub enabled: bool,
148
149    /// Verification method ("code", "command", "none").
150    pub verification: VerificationMethod,
151
152    /// Timeout in seconds for user response.
153    pub timeout_seconds: u64,
154
155    /// Length of verification code.
156    pub code_length: usize,
157
158    /// Maximum attempts before lockout.
159    pub max_attempts: u32,
160
161    /// Whether to allow fallback when not a TTY (always block in that case).
162    pub allow_non_tty_fallback: bool,
163
164    /// Disable interactive mode in CI environments.
165    pub disable_in_ci: bool,
166
167    /// Require this env var to be set to enable interactive mode.
168    pub require_env: Option<String>,
169}
170
171impl Default for InteractiveConfig {
172    fn default() -> Self {
173        Self {
174            enabled: false, // Disabled by default for safety
175            verification: VerificationMethod::Code,
176            timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
177            code_length: DEFAULT_CODE_LENGTH,
178            max_attempts: 3,
179            allow_non_tty_fallback: true,
180            disable_in_ci: true,
181            require_env: None,
182        }
183    }
184}
185
186impl InteractiveConfig {
187    /// Create a new interactive config with default values.
188    #[must_use]
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    /// Get the timeout as a `Duration`.
194    #[must_use]
195    pub fn timeout(&self) -> Duration {
196        Duration::from_secs(
197            self.timeout_seconds
198                .clamp(MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS),
199        )
200    }
201
202    /// Get the code length, clamped to valid range.
203    #[must_use]
204    pub fn effective_code_length(&self) -> usize {
205        self.code_length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH)
206    }
207}
208
209/// Generate a cryptographically secure verification code.
210///
211/// # Arguments
212///
213/// * `length` - The length of the code to generate (will be clamped to valid range)
214///
215/// # Returns
216///
217/// A lowercase alphanumeric string of the specified length.
218#[must_use]
219pub fn generate_verification_code(length: usize) -> String {
220    let length = length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH);
221    let mut rng = rand::rng();
222
223    (0..length)
224        .map(|_| {
225            let idx = rng.random_range(0..CODE_CHARSET.len());
226            CODE_CHARSET[idx] as char
227        })
228        .collect()
229}
230
231/// Validate a user-provided verification code.
232///
233/// Comparison is case-insensitive and ignores leading/trailing whitespace.
234#[must_use]
235pub fn validate_code(input: &str, expected: &str) -> bool {
236    input.trim().eq_ignore_ascii_case(expected)
237}
238
239/// Check if interactive mode is available in the current environment.
240///
241/// Returns `Ok(())` if interactive mode can be used, or `Err(reason)` if not.
242///
243/// # Security
244///
245/// This is a critical security check. If stdin is not a TTY, we're likely
246/// receiving piped input from an AI agent, and interactive mode MUST be disabled.
247pub fn check_interactive_available(config: &InteractiveConfig) -> Result<(), NotAvailableReason> {
248    // Check if interactive mode is enabled
249    if !config.enabled {
250        return Err(NotAvailableReason::Disabled);
251    }
252
253    if let Some(var) = config.require_env.as_ref() {
254        if std::env::var(var).is_err() {
255            return Err(NotAvailableReason::MissingEnv(var.clone()));
256        }
257    }
258
259    // Critical: Check if stdin is a TTY
260    // If not, we're likely receiving piped input from an AI agent
261    if !io::stdin().is_terminal() {
262        return Err(NotAvailableReason::NotTty);
263    }
264
265    // Check for CI environment
266    if config.disable_in_ci && is_ci_environment() {
267        return Err(NotAvailableReason::CiEnvironment);
268    }
269
270    // Check for dumb terminal
271    if matches!(std::env::var("TERM").as_deref(), Ok("dumb")) {
272        return Err(NotAvailableReason::UnsuitableTerminal);
273    }
274
275    Ok(())
276}
277
278fn is_ci_environment() -> bool {
279    ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS"]
280        .iter()
281        .any(|var| std::env::var(var).is_ok())
282}
283
284/// Display the interactive prompt and wait for user input.
285///
286/// # Arguments
287///
288/// * `command` - The blocked command
289/// * `reason` - Why the command was blocked
290/// * `rule_id` - Optional rule ID that triggered the block
291/// * `config` - Interactive mode configuration
292///
293/// # Returns
294///
295/// The result of the interactive session.
296///
297/// # Security
298///
299/// This function includes multiple security checks:
300/// - TTY detection before prompting
301/// - Random verification code generation
302/// - Timeout enforcement
303/// - Single-use codes (new code on each call)
304#[allow(clippy::too_many_lines)]
305pub fn run_interactive_prompt(
306    command: &str,
307    reason: &str,
308    rule_id: Option<&str>,
309    config: &InteractiveConfig,
310) -> InteractiveResult {
311    // Security check: verify interactive mode is available
312    if let Err(reason) = check_interactive_available(config) {
313        return InteractiveResult::NotAvailable(reason);
314    }
315
316    let timeout = config.timeout();
317
318    match config.verification {
319        VerificationMethod::Code => {
320            let code = generate_verification_code(config.effective_code_length());
321            display_prompt(
322                command,
323                reason,
324                rule_id,
325                VerificationMethod::Code,
326                Some(&code),
327                timeout,
328            );
329
330            // Read input with timeout
331            match read_input_with_timeout(timeout) {
332                Ok(input) => {
333                    let input = input.trim();
334
335                    // Empty input = cancelled
336                    if input.is_empty() {
337                        return InteractiveResult::Cancelled;
338                    }
339
340                    // Check verification code (case-insensitive)
341                    if validate_code(input, &code) {
342                        // Code correct - show scope selection
343                        match select_allowlist_scope() {
344                            Ok(scope) => InteractiveResult::AllowlistRequested(scope),
345                            Err(_) => InteractiveResult::Cancelled,
346                        }
347                    } else {
348                        InteractiveResult::InvalidCode
349                    }
350                }
351                Err(InputError::Timeout) => InteractiveResult::Timeout,
352                Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
353            }
354        }
355        VerificationMethod::Command => {
356            display_prompt(
357                command,
358                reason,
359                rule_id,
360                VerificationMethod::Command,
361                None,
362                timeout,
363            );
364
365            match read_input_with_timeout(timeout) {
366                Ok(input) => {
367                    let input = input.trim();
368
369                    if input.is_empty() {
370                        return InteractiveResult::Cancelled;
371                    }
372
373                    if input == command {
374                        match select_allowlist_scope() {
375                            Ok(scope) => InteractiveResult::AllowlistRequested(scope),
376                            Err(_) => InteractiveResult::Cancelled,
377                        }
378                    } else {
379                        InteractiveResult::InvalidCode
380                    }
381                }
382                Err(InputError::Timeout) => InteractiveResult::Timeout,
383                Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
384            }
385        }
386        VerificationMethod::None => {
387            display_prompt(
388                command,
389                reason,
390                rule_id,
391                VerificationMethod::None,
392                None,
393                timeout,
394            );
395
396            match select_allowlist_scope() {
397                Ok(scope) => InteractiveResult::AllowlistRequested(scope),
398                Err(_) => InteractiveResult::Cancelled,
399            }
400        }
401    }
402}
403
404/// Display the interactive prompt to stderr.
405///
406/// Shows a formatted box with the blocked command, available options, and
407/// verification code. The user must type the verification code to proceed
408/// with any allowlist action.
409fn display_prompt(
410    command: &str,
411    reason: &str,
412    rule_id: Option<&str>,
413    verification: VerificationMethod,
414    code: Option<&str>,
415    timeout: Duration,
416) {
417    let stderr = io::stderr();
418    let mut handle = stderr.lock();
419
420    const WIDTH: usize = 66;
421
422    // Helper to write a padded line
423    let write_line = |handle: &mut std::io::StderrLock<'_>, content: &str, style: &str| {
424        let visible_len = content.chars().count();
425        let padding = WIDTH.saturating_sub(visible_len);
426        match style {
427            "red" => {
428                let _ = writeln!(
429                    handle,
430                    "{}{}{}{}",
431                    "\u{2502}".red(),
432                    content,
433                    " ".repeat(padding),
434                    "\u{2502}".red()
435                );
436            }
437            _ => {
438                let _ = writeln!(
439                    handle,
440                    "{}{}{}{}",
441                    "\u{2502}".red(),
442                    content,
443                    " ".repeat(padding),
444                    "\u{2502}".red()
445                );
446            }
447        }
448    };
449
450    // Top border
451    let _ = writeln!(
452        handle,
453        "{}{}{}",
454        "\u{256d}".red(),
455        "\u{2500}".repeat(WIDTH).red(),
456        "\u{256e}".red()
457    );
458
459    // Header with blocked command (truncated if needed)
460    let cmd_prefix = "  \u{1f6d1} BLOCKED: ";
461    let max_cmd_len = WIDTH - cmd_prefix.chars().count() - 1;
462    let display_cmd = if command.chars().count() > max_cmd_len {
463        format!(
464            "{}...",
465            command.chars().take(max_cmd_len - 3).collect::<String>()
466        )
467    } else {
468        command.to_string()
469    };
470    let header = format!("{cmd_prefix}{display_cmd}");
471    let header_padding = WIDTH.saturating_sub(header.chars().count());
472    let _ = writeln!(
473        handle,
474        "{}  {} {}{}{}",
475        "\u{2502}".red(),
476        "\u{1f6d1}",
477        format!("BLOCKED: {display_cmd}").white().bold(),
478        " ".repeat(header_padding.saturating_sub(2)),
479        "\u{2502}".red()
480    );
481
482    // Separator
483    let _ = writeln!(
484        handle,
485        "{}{}{}",
486        "\u{251c}".red(),
487        "\u{2500}".repeat(WIDTH).red().dimmed(),
488        "\u{2524}".red()
489    );
490
491    // Rule ID if available
492    if let Some(rule) = rule_id {
493        let rule_line = format!("  Rule: {rule}");
494        let _ = writeln!(
495            handle,
496            "{}{}{}{}",
497            "\u{2502}".red(),
498            rule_line.yellow(),
499            " ".repeat(WIDTH.saturating_sub(rule_line.chars().count())),
500            "\u{2502}".red()
501        );
502    }
503
504    // Reason (truncated if too long)
505    let reason_line = format!("  Reason: {reason}");
506    let truncated_reason = if reason_line.chars().count() > WIDTH - 2 {
507        format!(
508            "{}...",
509            reason_line.chars().take(WIDTH - 5).collect::<String>()
510        )
511    } else {
512        reason_line
513    };
514    let _ = writeln!(
515        handle,
516        "{}{}{}{}",
517        "\u{2502}".red(),
518        truncated_reason.bright_black(),
519        " ".repeat(WIDTH.saturating_sub(truncated_reason.chars().count())),
520        "\u{2502}".red()
521    );
522
523    // Separator
524    let _ = writeln!(
525        handle,
526        "{}{}{}",
527        "\u{251c}".red(),
528        "\u{2500}".repeat(WIDTH).red().dimmed(),
529        "\u{2524}".red()
530    );
531
532    // Options preview (shown for user awareness)
533    let options = [
534        ("o", "Allowlist once (this execution only)"),
535        ("t", "Allowlist temporarily (24 hours)"),
536        ("p", "Allowlist permanently (add to project)"),
537        ("Enter", "Keep blocked"),
538    ];
539
540    for (key, desc) in &options {
541        let option_line = if *key == "Enter" {
542            format!("  [{}] {}", key.bright_black(), desc.bright_black())
543        } else {
544            format!("  [{}] {}", key.cyan(), desc.white())
545        };
546        // Calculate visible length (without ANSI codes)
547        let visible_len = 2 + 1 + key.len() + 1 + 1 + desc.len(); // "  [" + key + "] " + desc
548        let padding = WIDTH.saturating_sub(visible_len);
549        let _ = writeln!(
550            handle,
551            "{}{}{}{}",
552            "\u{2502}".red(),
553            option_line,
554            " ".repeat(padding),
555            "\u{2502}".red()
556        );
557    }
558
559    // Empty line
560    write_line(&mut handle, "", "red");
561
562    // Separator
563    let _ = writeln!(
564        handle,
565        "{}{}{}",
566        "\u{251c}".red(),
567        "\u{2500}".repeat(WIDTH).red().dimmed(),
568        "\u{2524}".red()
569    );
570
571    let mut show_input_prompt = true;
572
573    match verification {
574        VerificationMethod::Code => {
575            let code = code.unwrap_or_default();
576            let verify_prefix = "  To proceed, type: ";
577            let verify_visible_len = verify_prefix.len() + code.len();
578            let verify_padding = WIDTH.saturating_sub(verify_visible_len);
579            let _ = writeln!(
580                handle,
581                "{}{}{}{}{}",
582                "\u{2502}".red(),
583                verify_prefix.white(),
584                code.bright_yellow().bold(),
585                " ".repeat(verify_padding),
586                "\u{2502}".red()
587            );
588
589            // Timeout indicator
590            let timeout_secs = timeout.as_secs();
591            let timeout_line = format!("  ({timeout_secs} seconds remaining)");
592            let _ = writeln!(
593                handle,
594                "{}{}{}{}",
595                "\u{2502}".red(),
596                timeout_line.bright_black(),
597                " ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
598                "\u{2502}".red()
599            );
600        }
601        VerificationMethod::Command => {
602            let verify_line = "  To proceed, retype the full command:";
603            let _ = writeln!(
604                handle,
605                "{}{}{}{}",
606                "\u{2502}".red(),
607                verify_line.white(),
608                " ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
609                "\u{2502}".red()
610            );
611
612            let timeout_secs = timeout.as_secs();
613            let timeout_line = format!("  ({timeout_secs} seconds remaining)");
614            let _ = writeln!(
615                handle,
616                "{}{}{}{}",
617                "\u{2502}".red(),
618                timeout_line.bright_black(),
619                " ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
620                "\u{2502}".red()
621            );
622        }
623        VerificationMethod::None => {
624            let verify_line = "  Verification disabled (least secure).";
625            let _ = writeln!(
626                handle,
627                "{}{}{}{}",
628                "\u{2502}".red(),
629                verify_line.bright_black(),
630                " ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
631                "\u{2502}".red()
632            );
633            show_input_prompt = false;
634        }
635    }
636
637    // Bottom border
638    let _ = writeln!(
639        handle,
640        "{}{}{}",
641        "\u{2570}".red(),
642        "\u{2500}".repeat(WIDTH).red(),
643        "\u{256f}".red()
644    );
645
646    if show_input_prompt {
647        // Input prompt
648        let _ = write!(handle, "{} ", ">".green().bold());
649        let _ = handle.flush();
650    }
651}
652
653/// Error type for input reading.
654#[derive(Debug)]
655enum InputError {
656    Timeout,
657    #[allow(dead_code)] // Error value preserved for future logging/debugging
658    Io(io::Error),
659    Interrupted,
660}
661
662/// Read a line of input with a timeout.
663///
664/// # Platform Notes
665///
666/// On Unix, this uses a simple polling approach with non-blocking stdin.
667/// On Windows, this falls back to blocking read (timeout not enforced).
668fn read_input_with_timeout(timeout: Duration) -> Result<String, InputError> {
669    let start = Instant::now();
670    let stdin = io::stdin();
671
672    // Simple polling approach - check if data is available
673    // This works on most Unix systems with TTY
674    let mut input = String::new();
675
676    // For simplicity and cross-platform compatibility, we use a blocking read
677    // with a check after. A more sophisticated implementation would use
678    // platform-specific async I/O or select/poll.
679    //
680    // TODO: Implement proper timeout with platform-specific code:
681    // - Unix: use libc::select or mio
682    // - Windows: use WaitForSingleObject
683    //
684    // For now, we rely on the user to respond or press Enter to cancel.
685    // The timeout is checked after the read completes.
686
687    let handle = stdin.lock();
688    let mut reader = io::BufReader::new(handle);
689
690    // Read a line (blocking)
691    match reader.read_line(&mut input) {
692        Ok(0) => Err(InputError::Interrupted), // EOF
693        Ok(_) => {
694            // Check if we exceeded timeout
695            if start.elapsed() > timeout {
696                return Err(InputError::Timeout);
697            }
698            Ok(input)
699        }
700        Err(e) if e.kind() == io::ErrorKind::Interrupted => Err(InputError::Interrupted),
701        Err(e) => Err(InputError::Io(e)),
702    }
703}
704
705/// Display the scope selection menu and get user choice.
706fn select_allowlist_scope() -> Result<AllowlistScope, InputError> {
707    let stderr = io::stderr();
708    let mut handle = stderr.lock();
709
710    let _ = writeln!(handle);
711    let _ = writeln!(handle, "{}", "Verification successful!".green().bold());
712    let _ = writeln!(handle);
713    let _ = writeln!(handle, "Select allowlist scope:");
714    let _ = writeln!(handle, "  {} Once (this execution only)", "[o]".cyan());
715    let _ = writeln!(handle, "  {} Session (until terminal closes)", "[s]".cyan());
716    let _ = writeln!(handle, "  {} Temporary (24 hours)", "[t]".cyan());
717    let _ = writeln!(
718        handle,
719        "  {} Permanent (add to project allowlist)",
720        "[p]".cyan()
721    );
722    let _ = writeln!(handle);
723    let _ = write!(handle, "{} ", "Choice [o/s/t/p]:".white());
724    let _ = handle.flush();
725
726    let stdin = io::stdin();
727    let mut input = String::new();
728    let handle = stdin.lock();
729    let mut reader = io::BufReader::new(handle);
730
731    match reader.read_line(&mut input) {
732        Ok(0) => Err(InputError::Interrupted),
733        Ok(_) => {
734            let choice = input.trim().to_lowercase();
735            match choice.as_str() {
736                "o" | "once" | "1" => Ok(AllowlistScope::Once),
737                "s" | "session" | "2" => Ok(AllowlistScope::Session),
738                "t" | "temporary" | "temp" | "3" => {
739                    Ok(AllowlistScope::Temporary(Duration::from_secs(24 * 3600)))
740                }
741                "p" | "permanent" | "perm" | "4" => Ok(AllowlistScope::Permanent),
742                "" => Ok(AllowlistScope::Once), // Default to once
743                _ => Ok(AllowlistScope::Once),  // Invalid input defaults to once (safest)
744            }
745        }
746        Err(e) if e.kind() == io::ErrorKind::Interrupted => Err(InputError::Interrupted),
747        Err(e) => Err(InputError::Io(e)),
748    }
749}
750
751/// Print a message indicating interactive mode is not available.
752pub fn print_not_available_message(reason: &NotAvailableReason) {
753    let stderr = io::stderr();
754    let mut handle = stderr.lock();
755
756    let _ = writeln!(
757        handle,
758        "{} Interactive mode not available: {}",
759        "[dcg]".bright_black(),
760        reason
761    );
762
763    if matches!(reason, NotAvailableReason::NotTty) {
764        let _ = writeln!(
765            handle,
766            "{}   This is a security feature to prevent automated bypass.",
767            " ".repeat(5)
768        );
769        let _ = writeln!(
770            handle,
771            "{}   Run dcg in an interactive terminal to use this feature.",
772            " ".repeat(5)
773        );
774    } else if let NotAvailableReason::MissingEnv(var) = reason {
775        let _ = writeln!(
776            handle,
777            "{}   Set {} to enable interactive prompts.",
778            " ".repeat(5),
779            var
780        );
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    #[test]
789    fn test_generate_verification_code_length() {
790        let code = generate_verification_code(4);
791        assert_eq!(code.len(), 4);
792
793        let code = generate_verification_code(6);
794        assert_eq!(code.len(), 6);
795
796        let code = generate_verification_code(8);
797        assert_eq!(code.len(), 8);
798    }
799
800    #[test]
801    fn test_generate_verification_code_clamps_length() {
802        // Below minimum
803        let code = generate_verification_code(2);
804        assert_eq!(code.len(), MIN_CODE_LENGTH);
805
806        // Above maximum
807        let code = generate_verification_code(20);
808        assert_eq!(code.len(), MAX_CODE_LENGTH);
809    }
810
811    #[test]
812    fn test_generate_verification_code_valid_characters() {
813        let code = generate_verification_code(100); // Generate long code for coverage
814        for c in code.chars() {
815            assert!(
816                c.is_ascii_lowercase() || c.is_ascii_digit(),
817                "Invalid character in code: {c}"
818            );
819        }
820    }
821
822    #[test]
823    fn test_generate_verification_code_randomness() {
824        // Generate multiple codes and verify they're not all the same
825        let codes: Vec<String> = (0..10).map(|_| generate_verification_code(4)).collect();
826        let unique_count = codes.iter().collect::<std::collections::HashSet<_>>().len();
827
828        // With 31^4 = 923,521 possible codes, getting duplicates in 10 tries is unlikely
829        assert!(
830            unique_count > 5,
831            "Generated codes should be mostly unique, got {unique_count} unique out of 10"
832        );
833    }
834
835    #[test]
836    fn test_code_charset_excludes_ambiguous_chars() {
837        let charset = std::str::from_utf8(CODE_CHARSET).unwrap();
838        for ch in ['i', 'l', 'o', '0', '1'] {
839            assert!(!charset.contains(ch), "charset should not contain '{ch}'");
840        }
841    }
842
843    #[test]
844    fn test_validate_code_case_insensitive() {
845        assert!(validate_code("AbC", "abc"));
846        assert!(validate_code(" abc ", "aBc"));
847        assert!(!validate_code("abcd", "abc"));
848    }
849
850    #[test]
851    fn test_interactive_config_defaults() {
852        let config = InteractiveConfig::default();
853        assert!(!config.enabled);
854        assert_eq!(config.verification, VerificationMethod::Code);
855        assert_eq!(config.timeout_seconds, DEFAULT_TIMEOUT_SECONDS);
856        assert_eq!(config.code_length, DEFAULT_CODE_LENGTH);
857        assert_eq!(config.max_attempts, 3);
858        assert!(config.allow_non_tty_fallback);
859        assert!(config.disable_in_ci);
860        assert!(config.require_env.is_none());
861    }
862
863    #[test]
864    fn test_interactive_config_timeout() {
865        let mut config = InteractiveConfig::default();
866
867        config.timeout_seconds = 10;
868        assert_eq!(config.timeout(), Duration::from_secs(10));
869
870        // Test clamping to minimum
871        config.timeout_seconds = 0;
872        assert_eq!(config.timeout(), Duration::from_secs(MIN_TIMEOUT_SECONDS));
873
874        // Test clamping to maximum
875        config.timeout_seconds = 100;
876        assert_eq!(config.timeout(), Duration::from_secs(MAX_TIMEOUT_SECONDS));
877    }
878
879    #[test]
880    fn test_interactive_config_effective_code_length() {
881        let mut config = InteractiveConfig::default();
882
883        config.code_length = 6;
884        assert_eq!(config.effective_code_length(), 6);
885
886        // Test clamping to minimum
887        config.code_length = 1;
888        assert_eq!(config.effective_code_length(), MIN_CODE_LENGTH);
889
890        // Test clamping to maximum
891        config.code_length = 100;
892        assert_eq!(config.effective_code_length(), MAX_CODE_LENGTH);
893    }
894
895    #[test]
896    fn test_not_available_reason_display() {
897        assert_eq!(
898            NotAvailableReason::NotTty.to_string(),
899            "stdin is not a terminal (TTY)"
900        );
901        assert_eq!(
902            NotAvailableReason::CiEnvironment.to_string(),
903            "running in CI environment"
904        );
905        assert_eq!(
906            NotAvailableReason::Disabled.to_string(),
907            "interactive mode is disabled in configuration"
908        );
909        assert_eq!(
910            NotAvailableReason::MissingEnv("DCG_INTERACTIVE".to_string()).to_string(),
911            "required environment variable 'DCG_INTERACTIVE' is not set"
912        );
913        assert_eq!(
914            NotAvailableReason::UnsuitableTerminal.to_string(),
915            "terminal environment is not suitable"
916        );
917    }
918
919    #[test]
920    fn test_allowlist_scope_display() {
921        assert_eq!(
922            AllowlistScope::Once.to_string(),
923            "once (this execution only)"
924        );
925        assert_eq!(
926            AllowlistScope::Session.to_string(),
927            "session (until terminal closes)"
928        );
929        assert_eq!(
930            AllowlistScope::Temporary(Duration::from_secs(24 * 3600)).to_string(),
931            "temporary (24 hours)"
932        );
933        assert_eq!(
934            AllowlistScope::Permanent.to_string(),
935            "permanent (added to allowlist)"
936        );
937    }
938
939    #[test]
940    fn test_check_interactive_disabled() {
941        let config = InteractiveConfig {
942            enabled: false,
943            ..Default::default()
944        };
945        assert_eq!(
946            check_interactive_available(&config),
947            Err(NotAvailableReason::Disabled)
948        );
949    }
950
951    // Note: TTY and CI environment tests are difficult to unit test because they
952    // depend on the actual runtime environment. These are better tested via
953    // integration tests or manual testing.
954}