1use colored::Colorize;
21use rand::RngExt;
22use serde::{Deserialize, Serialize};
23use std::collections::HashSet;
24use std::fmt::Write as FmtWrite;
25use std::io::{self, BufRead, IsTerminal, Write};
26use std::sync::mpsc;
27use std::thread;
28use std::time::Duration;
29
30pub const DEFAULT_TIMEOUT_SECONDS: u64 = 5;
32
33pub const DEFAULT_CODE_LENGTH: usize = 4;
35
36pub const MAX_CODE_LENGTH: usize = 8;
38
39pub const MIN_CODE_LENGTH: usize = 4;
41
42pub const MAX_TIMEOUT_SECONDS: u64 = 30;
44
45pub const MIN_TIMEOUT_SECONDS: u64 = 1;
47
48const CODE_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
51
52static SESSION_CODES: std::sync::OnceLock<std::sync::Mutex<VerificationCodeGenerator>> =
53 std::sync::OnceLock::new();
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum VerificationMethod {
59 Code,
61 Command,
63 None,
65}
66
67impl Default for VerificationMethod {
68 fn default() -> Self {
69 Self::Code
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum InteractiveResult {
76 AllowlistRequested(AllowlistScope),
78
79 InvalidCode,
81
82 Timeout,
84
85 Cancelled,
87
88 NotAvailable(NotAvailableReason),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum NotAvailableReason {
95 NotTty,
97
98 CiEnvironment,
100
101 Disabled,
103
104 MissingEnv(String),
106
107 UnsuitableTerminal,
109}
110
111impl std::fmt::Display for NotAvailableReason {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::NotTty => write!(f, "stdin is not a terminal (TTY)"),
115 Self::CiEnvironment => write!(f, "running in CI environment"),
116 Self::Disabled => write!(f, "interactive mode is disabled in configuration"),
117 Self::MissingEnv(var) => write!(f, "required environment variable '{var}' is not set"),
118 Self::UnsuitableTerminal => write!(f, "terminal environment is not suitable"),
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum AllowlistScope {
126 Once,
128
129 Session,
131
132 Temporary(Duration),
134
135 Permanent,
137}
138
139impl std::fmt::Display for AllowlistScope {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Self::Once => write!(f, "once (this execution only)"),
143 Self::Session => write!(f, "session (until terminal closes)"),
144 Self::Temporary(d) => write!(f, "temporary ({} hours)", d.as_secs() / 3600),
145 Self::Permanent => write!(f, "permanent (added to allowlist)"),
146 }
147 }
148}
149
150#[derive(Debug, Default)]
156pub struct VerificationCodeGenerator {
157 used_codes: HashSet<String>,
158}
159
160impl VerificationCodeGenerator {
161 #[must_use]
163 pub fn new() -> Self {
164 Self::default()
165 }
166
167 #[must_use]
172 pub fn generate(&mut self, length: usize) -> String {
173 let length = length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH);
174 self.clear_length_if_exhausted(length);
175
176 loop {
177 let code = generate_verification_code(length);
178 if self.used_codes.insert(code.clone()) {
179 return code;
180 }
181 }
182 }
183
184 fn clear_length_if_exhausted(&mut self, length: usize) {
185 let space_size = CODE_CHARSET
186 .len()
187 .saturating_pow(u32::try_from(length).unwrap_or(u32::MAX));
188 let used_at_length = self
189 .used_codes
190 .iter()
191 .filter(|code| code.len() == length)
192 .count();
193
194 if used_at_length >= space_size {
195 self.used_codes.retain(|code| code.len() != length);
196 }
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct InteractiveConfig {
203 pub enabled: bool,
205
206 pub verification: VerificationMethod,
208
209 pub timeout_seconds: u64,
211
212 pub code_length: usize,
214
215 pub max_attempts: u32,
217
218 pub allow_non_tty_fallback: bool,
220
221 pub disable_in_ci: bool,
223
224 pub require_env: Option<String>,
226}
227
228impl Default for InteractiveConfig {
229 fn default() -> Self {
230 Self {
231 enabled: false, verification: VerificationMethod::Code,
233 timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
234 code_length: DEFAULT_CODE_LENGTH,
235 max_attempts: 3,
236 allow_non_tty_fallback: true,
237 disable_in_ci: true,
238 require_env: None,
239 }
240 }
241}
242
243impl InteractiveConfig {
244 #[must_use]
246 pub fn new() -> Self {
247 Self::default()
248 }
249
250 #[must_use]
252 pub fn timeout(&self) -> Duration {
253 Duration::from_secs(
254 self.timeout_seconds
255 .clamp(MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS),
256 )
257 }
258
259 #[must_use]
261 pub fn effective_code_length(&self) -> usize {
262 self.code_length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH)
263 }
264}
265
266#[must_use]
276pub fn generate_verification_code(length: usize) -> String {
277 let length = length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH);
278 let mut rng = rand::rng();
279
280 (0..length)
281 .map(|_| {
282 let idx = rng.random_range(0..CODE_CHARSET.len());
283 CODE_CHARSET[idx] as char
284 })
285 .collect()
286}
287
288fn generate_session_verification_code(length: usize) -> String {
289 let generator =
290 SESSION_CODES.get_or_init(|| std::sync::Mutex::new(VerificationCodeGenerator::new()));
291 let mut guard = generator
292 .lock()
293 .unwrap_or_else(std::sync::PoisonError::into_inner);
294 guard.generate(length)
295}
296
297#[must_use]
301pub fn validate_code(input: &str, expected: &str) -> bool {
302 input.trim().eq_ignore_ascii_case(expected)
303}
304
305pub fn check_interactive_available(config: &InteractiveConfig) -> Result<(), NotAvailableReason> {
314 let stdin_is_tty = io::stdin().is_terminal();
315 let ci_environment = is_ci_environment();
316 let term_is_dumb = matches!(std::env::var("TERM").as_deref(), Ok("dumb"));
317
318 check_interactive_available_with_context(config, stdin_is_tty, ci_environment, term_is_dumb)
319}
320
321fn check_interactive_available_with_context(
322 config: &InteractiveConfig,
323 stdin_is_tty: bool,
324 ci_environment: bool,
325 term_is_dumb: bool,
326) -> Result<(), NotAvailableReason> {
327 if !config.enabled {
329 return Err(NotAvailableReason::Disabled);
330 }
331
332 if let Some(var) = config.require_env.as_ref() {
333 if std::env::var(var).is_err() {
334 return Err(NotAvailableReason::MissingEnv(var.clone()));
335 }
336 }
337
338 if !stdin_is_tty {
341 return Err(NotAvailableReason::NotTty);
342 }
343
344 if config.disable_in_ci && ci_environment {
346 return Err(NotAvailableReason::CiEnvironment);
347 }
348
349 if term_is_dumb {
351 return Err(NotAvailableReason::UnsuitableTerminal);
352 }
353
354 Ok(())
355}
356
357fn is_ci_environment() -> bool {
358 ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS"]
359 .iter()
360 .any(|var| std::env::var(var).is_ok())
361}
362
363#[allow(clippy::too_many_lines)]
384pub fn run_interactive_prompt(
385 command: &str,
386 reason: &str,
387 rule_id: Option<&str>,
388 config: &InteractiveConfig,
389) -> InteractiveResult {
390 if let Err(reason) = check_interactive_available(config) {
392 return InteractiveResult::NotAvailable(reason);
393 }
394
395 let timeout = config.timeout();
396
397 match config.verification {
398 VerificationMethod::Code => {
399 let code = generate_session_verification_code(config.effective_code_length());
400 display_prompt(
401 command,
402 reason,
403 rule_id,
404 VerificationMethod::Code,
405 Some(&code),
406 timeout,
407 );
408
409 match read_input_with_timeout(timeout) {
411 Ok(input) => {
412 let input = input.trim();
413
414 if input.is_empty() {
416 return InteractiveResult::Cancelled;
417 }
418
419 if validate_code(input, &code) {
421 match select_allowlist_scope(timeout) {
423 Ok(scope) => InteractiveResult::AllowlistRequested(scope),
424 Err(_) => InteractiveResult::Cancelled,
425 }
426 } else {
427 InteractiveResult::InvalidCode
428 }
429 }
430 Err(InputError::Timeout) => InteractiveResult::Timeout,
431 Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
432 }
433 }
434 VerificationMethod::Command => {
435 display_prompt(
436 command,
437 reason,
438 rule_id,
439 VerificationMethod::Command,
440 None,
441 timeout,
442 );
443
444 match read_input_with_timeout(timeout) {
445 Ok(input) => {
446 let input = input.trim();
447
448 if input.is_empty() {
449 return InteractiveResult::Cancelled;
450 }
451
452 if input == command {
453 match select_allowlist_scope(timeout) {
454 Ok(scope) => InteractiveResult::AllowlistRequested(scope),
455 Err(_) => InteractiveResult::Cancelled,
456 }
457 } else {
458 InteractiveResult::InvalidCode
459 }
460 }
461 Err(InputError::Timeout) => InteractiveResult::Timeout,
462 Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
463 }
464 }
465 VerificationMethod::None => {
466 display_prompt(
467 command,
468 reason,
469 rule_id,
470 VerificationMethod::None,
471 None,
472 timeout,
473 );
474
475 match select_allowlist_scope(timeout) {
476 Ok(scope) => InteractiveResult::AllowlistRequested(scope),
477 Err(_) => InteractiveResult::Cancelled,
478 }
479 }
480 }
481}
482
483fn sanitize_for_display(input: &str) -> String {
503 #[derive(Copy, Clone)]
504 enum State {
505 Normal,
506 EscOpen,
507 Csi,
508 Osc,
509 OscWantSt,
510 }
511
512 let mut out = String::with_capacity(input.len());
513 let mut state = State::Normal;
514
515 let push_visible_control = |out: &mut String, c: char| match c {
516 '\n' => out.push_str("\\n"),
517 '\r' => out.push_str("\\r"),
518 '\t' => out.push_str("\\t"),
519 _ => {
520 let cp = c as u32;
521 if cp <= 0xFF {
522 let _ = write!(out, "\\x{cp:02X}");
523 } else {
524 let _ = write!(out, "\\u{{{cp:04X}}}");
525 }
526 }
527 };
528
529 for c in input.chars() {
530 match state {
531 State::Normal => {
532 if c == '\x1b' {
533 state = State::EscOpen;
534 } else if (c as u32) <= 0x1F || c == '\x7f' || (0x80..=0x9F).contains(&(c as u32)) {
535 push_visible_control(&mut out, c);
536 } else {
537 out.push(c);
538 }
539 }
540 State::EscOpen => {
541 state = match c {
542 '[' => State::Csi,
543 ']' => State::Osc,
544 _ => State::Normal,
545 };
546 }
547 State::Csi => {
548 let cp = c as u32;
549 if (0x40..=0x7E).contains(&cp) {
550 state = State::Normal;
551 }
552 }
553 State::Osc => {
554 if c == '\x07' {
555 state = State::Normal;
556 } else if c == '\x1b' {
557 state = State::OscWantSt;
558 }
559 }
560 State::OscWantSt => {
561 state = if c == '\\' {
562 State::Normal
563 } else {
564 State::EscOpen
565 };
566 }
567 }
568 }
569
570 out
571}
572
573fn display_prompt(
579 command: &str,
580 reason: &str,
581 rule_id: Option<&str>,
582 verification: VerificationMethod,
583 code: Option<&str>,
584 timeout: Duration,
585) {
586 let command = sanitize_for_display(command);
587 let reason = sanitize_for_display(reason);
588 let stderr = io::stderr();
589 let mut handle = stderr.lock();
590
591 const WIDTH: usize = 66;
592
593 let write_line = |handle: &mut std::io::StderrLock<'_>, content: &str, style: &str| {
595 let visible_len = content.chars().count();
596 let padding = WIDTH.saturating_sub(visible_len);
597 match style {
598 "red" => {
599 let _ = writeln!(
600 handle,
601 "{}{}{}{}",
602 "\u{2502}".red(),
603 content,
604 " ".repeat(padding),
605 "\u{2502}".red()
606 );
607 }
608 _ => {
609 let _ = writeln!(
610 handle,
611 "{}{}{}{}",
612 "\u{2502}".red(),
613 content,
614 " ".repeat(padding),
615 "\u{2502}".red()
616 );
617 }
618 }
619 };
620
621 let _ = writeln!(
623 handle,
624 "{}{}{}",
625 "\u{256d}".red(),
626 "\u{2500}".repeat(WIDTH).red(),
627 "\u{256e}".red()
628 );
629
630 let cmd_prefix = " \u{1f6d1} BLOCKED: ";
632 let max_cmd_len = WIDTH - cmd_prefix.chars().count() - 1;
633 let display_cmd = if command.chars().count() > max_cmd_len {
634 format!(
635 "{}...",
636 command.chars().take(max_cmd_len - 3).collect::<String>()
637 )
638 } else {
639 command.clone()
640 };
641 let header = format!("{cmd_prefix}{display_cmd}");
642 let header_padding = WIDTH.saturating_sub(header.chars().count());
643 let _ = writeln!(
644 handle,
645 "{} {} {}{}{}",
646 "\u{2502}".red(),
647 "\u{1f6d1}",
648 format!("BLOCKED: {display_cmd}").white().bold(),
649 " ".repeat(header_padding.saturating_sub(2)),
650 "\u{2502}".red()
651 );
652
653 let _ = writeln!(
655 handle,
656 "{}{}{}",
657 "\u{251c}".red(),
658 "\u{2500}".repeat(WIDTH).red().dimmed(),
659 "\u{2524}".red()
660 );
661
662 if let Some(rule) = rule_id {
664 let rule_line = format!(" Rule: {rule}");
665 let _ = writeln!(
666 handle,
667 "{}{}{}{}",
668 "\u{2502}".red(),
669 rule_line.yellow(),
670 " ".repeat(WIDTH.saturating_sub(rule_line.chars().count())),
671 "\u{2502}".red()
672 );
673 }
674
675 let reason_line = format!(" Reason: {reason}");
677 let truncated_reason = if reason_line.chars().count() > WIDTH - 2 {
678 format!(
679 "{}...",
680 reason_line.chars().take(WIDTH - 5).collect::<String>()
681 )
682 } else {
683 reason_line
684 };
685 let _ = writeln!(
686 handle,
687 "{}{}{}{}",
688 "\u{2502}".red(),
689 truncated_reason.bright_black(),
690 " ".repeat(WIDTH.saturating_sub(truncated_reason.chars().count())),
691 "\u{2502}".red()
692 );
693
694 let _ = writeln!(
696 handle,
697 "{}{}{}",
698 "\u{251c}".red(),
699 "\u{2500}".repeat(WIDTH).red().dimmed(),
700 "\u{2524}".red()
701 );
702
703 let options = [
705 ("o", "Allowlist once (this execution only)"),
706 ("t", "Allowlist temporarily (24 hours)"),
707 ("p", "Allowlist permanently (add to project)"),
708 ("Enter", "Keep blocked"),
709 ];
710
711 for (key, desc) in &options {
712 let option_line = if *key == "Enter" {
713 format!(" [{}] {}", key.bright_black(), desc.bright_black())
714 } else {
715 format!(" [{}] {}", key.cyan(), desc.white())
716 };
717 let visible_len = 2 + 1 + key.len() + 1 + 1 + desc.len(); let padding = WIDTH.saturating_sub(visible_len);
720 let _ = writeln!(
721 handle,
722 "{}{}{}{}",
723 "\u{2502}".red(),
724 option_line,
725 " ".repeat(padding),
726 "\u{2502}".red()
727 );
728 }
729
730 write_line(&mut handle, "", "red");
732
733 let _ = writeln!(
735 handle,
736 "{}{}{}",
737 "\u{251c}".red(),
738 "\u{2500}".repeat(WIDTH).red().dimmed(),
739 "\u{2524}".red()
740 );
741
742 let mut show_input_prompt = true;
743
744 match verification {
745 VerificationMethod::Code => {
746 let code = code.unwrap_or_default();
747 let verify_prefix = " To proceed, type: ";
748 let verify_visible_len = verify_prefix.len() + code.len();
749 let verify_padding = WIDTH.saturating_sub(verify_visible_len);
750 let _ = writeln!(
751 handle,
752 "{}{}{}{}{}",
753 "\u{2502}".red(),
754 verify_prefix.white(),
755 code.bright_yellow().bold(),
756 " ".repeat(verify_padding),
757 "\u{2502}".red()
758 );
759
760 let timeout_secs = timeout.as_secs();
762 let timeout_line = format!(" ({timeout_secs} seconds remaining)");
763 let _ = writeln!(
764 handle,
765 "{}{}{}{}",
766 "\u{2502}".red(),
767 timeout_line.bright_black(),
768 " ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
769 "\u{2502}".red()
770 );
771 }
772 VerificationMethod::Command => {
773 let verify_line = " To proceed, retype the full command:";
774 let _ = writeln!(
775 handle,
776 "{}{}{}{}",
777 "\u{2502}".red(),
778 verify_line.white(),
779 " ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
780 "\u{2502}".red()
781 );
782
783 let timeout_secs = timeout.as_secs();
784 let timeout_line = format!(" ({timeout_secs} seconds remaining)");
785 let _ = writeln!(
786 handle,
787 "{}{}{}{}",
788 "\u{2502}".red(),
789 timeout_line.bright_black(),
790 " ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
791 "\u{2502}".red()
792 );
793 }
794 VerificationMethod::None => {
795 let verify_line = " Verification disabled (least secure).";
796 let _ = writeln!(
797 handle,
798 "{}{}{}{}",
799 "\u{2502}".red(),
800 verify_line.bright_black(),
801 " ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
802 "\u{2502}".red()
803 );
804 show_input_prompt = false;
805 }
806 }
807
808 let _ = writeln!(
810 handle,
811 "{}{}{}",
812 "\u{2570}".red(),
813 "\u{2500}".repeat(WIDTH).red(),
814 "\u{256f}".red()
815 );
816
817 if show_input_prompt {
818 let _ = write!(handle, "{} ", ">".green().bold());
820 let _ = handle.flush();
821 }
822}
823
824#[derive(Debug)]
826enum InputError {
827 Timeout,
828 #[allow(dead_code)] Io(io::Error),
830 Interrupted,
831}
832
833fn read_input_with_timeout(timeout: Duration) -> Result<String, InputError> {
834 read_line_with_timeout(
835 || {
836 let stdin = io::stdin();
837 let handle = stdin.lock();
838 let mut reader = io::BufReader::new(handle);
839 read_line_from_reader(&mut reader)
840 },
841 timeout,
842 )
843}
844
845fn read_line_with_timeout<F>(read_line: F, timeout: Duration) -> Result<String, InputError>
846where
847 F: FnOnce() -> Result<String, InputError> + Send + 'static,
848{
849 let (tx, rx) = mpsc::sync_channel(1);
850 let _input_thread = thread::Builder::new()
851 .name("dcg-interactive-input".to_string())
852 .spawn(move || {
853 let _ = tx.send(read_line());
854 })
855 .map_err(InputError::Io)?;
856
857 match rx.recv_timeout(timeout) {
858 Ok(result) => result,
859 Err(mpsc::RecvTimeoutError::Timeout) => Err(InputError::Timeout),
860 Err(mpsc::RecvTimeoutError::Disconnected) => Err(InputError::Interrupted),
861 }
862}
863
864fn read_line_from_reader<R>(reader: &mut R) -> Result<String, InputError>
865where
866 R: BufRead,
867{
868 let mut input = String::new();
869 match reader.read_line(&mut input) {
870 Ok(0) => Err(InputError::Interrupted), Ok(_) => Ok(input),
872 Err(e) if e.kind() == io::ErrorKind::Interrupted => Err(InputError::Interrupted),
873 Err(e) => Err(InputError::Io(e)),
874 }
875}
876
877fn select_allowlist_scope(timeout: Duration) -> Result<AllowlistScope, InputError> {
879 let stderr = io::stderr();
880 let mut handle = stderr.lock();
881
882 let _ = writeln!(handle);
883 let _ = writeln!(handle, "{}", "Verification successful!".green().bold());
884 let _ = writeln!(handle);
885 let _ = writeln!(handle, "Select allowlist scope:");
886 let _ = writeln!(handle, " {} Once (this execution only)", "[o]".cyan());
887 let _ = writeln!(handle, " {} Session (until terminal closes)", "[s]".cyan());
888 let _ = writeln!(handle, " {} Temporary (24 hours)", "[t]".cyan());
889 let _ = writeln!(
890 handle,
891 " {} Permanent (add to project allowlist)",
892 "[p]".cyan()
893 );
894 let _ = writeln!(handle);
895 let _ = write!(handle, "{} ", "Choice [o/s/t/p]:".white());
896 let _ = handle.flush();
897
898 match read_input_with_timeout(timeout) {
899 Ok(input) => {
900 let choice = input.trim().to_lowercase();
901 match choice.as_str() {
902 "o" | "once" | "1" => Ok(AllowlistScope::Once),
903 "s" | "session" | "2" => Ok(AllowlistScope::Session),
904 "t" | "temporary" | "temp" | "3" => {
905 Ok(AllowlistScope::Temporary(Duration::from_secs(24 * 3600)))
906 }
907 "p" | "permanent" | "perm" | "4" => Ok(AllowlistScope::Permanent),
908 "" => Err(InputError::Interrupted),
917 _ => Err(InputError::Interrupted),
918 }
919 }
920 Err(err) => Err(err),
921 }
922}
923
924pub fn print_not_available_message(reason: &NotAvailableReason) {
926 let stderr = io::stderr();
927 let mut handle = stderr.lock();
928
929 let _ = writeln!(
930 handle,
931 "{} Interactive mode not available: {}",
932 "[dcg]".bright_black(),
933 reason
934 );
935
936 if matches!(reason, NotAvailableReason::NotTty) {
937 let _ = writeln!(
938 handle,
939 "{} This is a security feature to prevent automated bypass.",
940 " ".repeat(5)
941 );
942 let _ = writeln!(
943 handle,
944 "{} Run dcg in an interactive terminal to use this feature.",
945 " ".repeat(5)
946 );
947 } else if let NotAvailableReason::MissingEnv(var) = reason {
948 let _ = writeln!(
949 handle,
950 "{} Set {} to enable interactive prompts.",
951 " ".repeat(5),
952 var
953 );
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use std::ffi::OsString;
961 use std::sync::{Mutex, OnceLock};
962 use std::time::Instant;
963
964 const CI_ENV_VARS: [&str; 5] = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS"];
965
966 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
967 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
968 ENV_LOCK
969 .get_or_init(|| Mutex::new(()))
970 .lock()
971 .expect("env lock poisoned")
972 }
973
974 fn with_clean_ci_env<F>(f: F)
975 where
976 F: FnOnce(),
977 {
978 let _guard = env_lock();
979 let saved: Vec<(&str, Option<OsString>)> = CI_ENV_VARS
980 .iter()
981 .map(|key| (*key, std::env::var_os(key)))
982 .collect();
983
984 for key in CI_ENV_VARS {
985 unsafe {
986 std::env::remove_var(key);
987 }
988 }
989
990 f();
991
992 for (key, value) in saved {
993 match value {
994 Some(existing) => unsafe {
995 std::env::set_var(key, existing);
996 },
997 None => unsafe {
998 std::env::remove_var(key);
999 },
1000 }
1001 }
1002 }
1003
1004 #[test]
1005 fn test_generate_verification_code_length() {
1006 let code = generate_verification_code(4);
1007 assert_eq!(code.len(), 4);
1008
1009 let code = generate_verification_code(6);
1010 assert_eq!(code.len(), 6);
1011
1012 let code = generate_verification_code(8);
1013 assert_eq!(code.len(), 8);
1014 }
1015
1016 #[test]
1017 fn test_generate_verification_code_clamps_length() {
1018 let code = generate_verification_code(2);
1020 assert_eq!(code.len(), MIN_CODE_LENGTH);
1021
1022 let code = generate_verification_code(20);
1024 assert_eq!(code.len(), MAX_CODE_LENGTH);
1025 }
1026
1027 #[test]
1028 fn test_generate_verification_code_valid_characters() {
1029 let code = generate_verification_code(100); for c in code.chars() {
1031 assert!(
1032 c.is_ascii_lowercase() || c.is_ascii_digit(),
1033 "Invalid character in code: {c}"
1034 );
1035 }
1036 }
1037
1038 #[test]
1039 fn test_generate_verification_code_randomness() {
1040 let codes: Vec<String> = (0..10).map(|_| generate_verification_code(4)).collect();
1042 let unique_count = codes.iter().collect::<std::collections::HashSet<_>>().len();
1043
1044 assert!(
1046 unique_count > 5,
1047 "Generated codes should be mostly unique, got {unique_count} unique out of 10"
1048 );
1049 }
1050
1051 #[test]
1052 fn test_verification_code_generator_avoids_reuse() {
1053 let mut generator = VerificationCodeGenerator::new();
1054 let mut seen = HashSet::new();
1055
1056 for _ in 0..256 {
1057 let code = generator.generate(DEFAULT_CODE_LENGTH);
1058 assert_eq!(code.len(), DEFAULT_CODE_LENGTH);
1059 assert!(seen.insert(code.clone()), "code reused: {code}");
1060 }
1061
1062 assert_eq!(generator.used_codes.len(), seen.len());
1063 }
1064
1065 #[test]
1066 fn test_verification_code_generator_tracks_lengths_independently() {
1067 let mut generator = VerificationCodeGenerator::new();
1068 let short_code = generator.generate(MIN_CODE_LENGTH);
1069 let long_code = generator.generate(MAX_CODE_LENGTH);
1070
1071 assert_eq!(short_code.len(), MIN_CODE_LENGTH);
1072 assert_eq!(long_code.len(), MAX_CODE_LENGTH);
1073 assert!(generator.used_codes.contains(&short_code));
1074 assert!(generator.used_codes.contains(&long_code));
1075 }
1076
1077 #[test]
1078 fn test_code_charset_excludes_ambiguous_chars() {
1079 let charset = std::str::from_utf8(CODE_CHARSET).unwrap();
1080 for ch in ['i', 'l', 'o', '0', '1'] {
1081 assert!(!charset.contains(ch), "charset should not contain '{ch}'");
1082 }
1083 }
1084
1085 #[test]
1086 fn test_validate_code_case_insensitive() {
1087 assert!(validate_code("AbC", "abc"));
1088 assert!(validate_code(" abc ", "aBc"));
1089 assert!(!validate_code("abcd", "abc"));
1090 }
1091
1092 #[test]
1093 fn test_interactive_config_defaults() {
1094 let config = InteractiveConfig::default();
1095 assert!(!config.enabled);
1096 assert_eq!(config.verification, VerificationMethod::Code);
1097 assert_eq!(config.timeout_seconds, DEFAULT_TIMEOUT_SECONDS);
1098 assert_eq!(config.code_length, DEFAULT_CODE_LENGTH);
1099 assert_eq!(config.max_attempts, 3);
1100 assert!(config.allow_non_tty_fallback);
1101 assert!(config.disable_in_ci);
1102 assert!(config.require_env.is_none());
1103 }
1104
1105 #[test]
1106 fn test_interactive_config_timeout() {
1107 let mut config = InteractiveConfig::default();
1108
1109 config.timeout_seconds = 10;
1110 assert_eq!(config.timeout(), Duration::from_secs(10));
1111
1112 config.timeout_seconds = 0;
1114 assert_eq!(config.timeout(), Duration::from_secs(MIN_TIMEOUT_SECONDS));
1115
1116 config.timeout_seconds = 100;
1118 assert_eq!(config.timeout(), Duration::from_secs(MAX_TIMEOUT_SECONDS));
1119 }
1120
1121 #[test]
1122 fn test_interactive_config_effective_code_length() {
1123 let mut config = InteractiveConfig::default();
1124
1125 config.code_length = 6;
1126 assert_eq!(config.effective_code_length(), 6);
1127
1128 config.code_length = 1;
1130 assert_eq!(config.effective_code_length(), MIN_CODE_LENGTH);
1131
1132 config.code_length = 100;
1134 assert_eq!(config.effective_code_length(), MAX_CODE_LENGTH);
1135 }
1136
1137 #[test]
1138 fn test_not_available_reason_display() {
1139 assert_eq!(
1140 NotAvailableReason::NotTty.to_string(),
1141 "stdin is not a terminal (TTY)"
1142 );
1143 assert_eq!(
1144 NotAvailableReason::CiEnvironment.to_string(),
1145 "running in CI environment"
1146 );
1147 assert_eq!(
1148 NotAvailableReason::Disabled.to_string(),
1149 "interactive mode is disabled in configuration"
1150 );
1151 assert_eq!(
1152 NotAvailableReason::MissingEnv("DCG_INTERACTIVE".to_string()).to_string(),
1153 "required environment variable 'DCG_INTERACTIVE' is not set"
1154 );
1155 assert_eq!(
1156 NotAvailableReason::UnsuitableTerminal.to_string(),
1157 "terminal environment is not suitable"
1158 );
1159 }
1160
1161 #[test]
1162 fn test_allowlist_scope_display() {
1163 assert_eq!(
1164 AllowlistScope::Once.to_string(),
1165 "once (this execution only)"
1166 );
1167 assert_eq!(
1168 AllowlistScope::Session.to_string(),
1169 "session (until terminal closes)"
1170 );
1171 assert_eq!(
1172 AllowlistScope::Temporary(Duration::from_secs(24 * 3600)).to_string(),
1173 "temporary (24 hours)"
1174 );
1175 assert_eq!(
1176 AllowlistScope::Permanent.to_string(),
1177 "permanent (added to allowlist)"
1178 );
1179 }
1180
1181 #[test]
1182 fn test_check_interactive_disabled() {
1183 let config = InteractiveConfig {
1184 enabled: false,
1185 ..Default::default()
1186 };
1187 assert_eq!(
1188 check_interactive_available(&config),
1189 Err(NotAvailableReason::Disabled)
1190 );
1191 }
1192
1193 #[test]
1194 fn test_check_interactive_not_tty() {
1195 let config = InteractiveConfig {
1196 enabled: true,
1197 ..Default::default()
1198 };
1199 assert_eq!(
1200 check_interactive_available_with_context(&config, false, false, false),
1201 Err(NotAvailableReason::NotTty)
1202 );
1203 }
1204
1205 #[test]
1206 fn test_check_interactive_ci_environment_blocked() {
1207 let config = InteractiveConfig {
1208 enabled: true,
1209 disable_in_ci: true,
1210 ..Default::default()
1211 };
1212 assert_eq!(
1213 check_interactive_available_with_context(&config, true, true, false),
1214 Err(NotAvailableReason::CiEnvironment)
1215 );
1216 }
1217
1218 #[test]
1219 fn test_check_interactive_ci_environment_allowed_when_disabled() {
1220 let config = InteractiveConfig {
1221 enabled: true,
1222 disable_in_ci: false,
1223 ..Default::default()
1224 };
1225 assert_eq!(
1226 check_interactive_available_with_context(&config, true, true, false),
1227 Ok(())
1228 );
1229 }
1230
1231 #[test]
1232 fn test_is_ci_environment_false_when_no_known_vars_are_set() {
1233 with_clean_ci_env(|| {
1234 assert!(
1235 !is_ci_environment(),
1236 "Expected CI detection to be false with no CI env vars set"
1237 );
1238 });
1239 }
1240
1241 #[test]
1242 fn test_is_ci_environment_detects_each_supported_variable() {
1243 for key in CI_ENV_VARS {
1244 with_clean_ci_env(|| {
1245 unsafe {
1246 std::env::set_var(key, "1");
1247 }
1248 assert!(
1249 is_ci_environment(),
1250 "Expected CI detection to be true when {key} is set"
1251 );
1252 });
1253 }
1254 }
1255
1256 #[test]
1257 fn test_check_interactive_unsuitable_terminal() {
1258 let config = InteractiveConfig {
1259 enabled: true,
1260 ..Default::default()
1261 };
1262 assert_eq!(
1263 check_interactive_available_with_context(&config, true, false, true),
1264 Err(NotAvailableReason::UnsuitableTerminal)
1265 );
1266 }
1267
1268 #[test]
1269 fn test_check_interactive_missing_required_env() {
1270 let config = InteractiveConfig {
1271 enabled: true,
1272 require_env: Some("DCG_INTERACTIVE_TEST_SENTINEL_UNSET".to_string()),
1273 ..Default::default()
1274 };
1275 assert_eq!(
1276 check_interactive_available_with_context(&config, true, false, false),
1277 Err(NotAvailableReason::MissingEnv(
1278 "DCG_INTERACTIVE_TEST_SENTINEL_UNSET".to_string()
1279 ))
1280 );
1281 }
1282
1283 #[test]
1284 fn test_check_interactive_available_when_requirements_met() {
1285 let config = InteractiveConfig {
1286 enabled: true,
1287 disable_in_ci: true,
1288 require_env: None,
1289 ..Default::default()
1290 };
1291 assert_eq!(
1292 check_interactive_available_with_context(&config, true, false, false),
1293 Ok(())
1294 );
1295 }
1296
1297 #[test]
1298 fn test_read_line_with_timeout_returns_input_before_deadline() {
1299 let input = read_line_with_timeout(
1300 || Ok("verification-code\n".to_string()),
1301 Duration::from_millis(100),
1302 )
1303 .expect("input should arrive before timeout");
1304
1305 assert_eq!(input, "verification-code\n");
1306 }
1307
1308 #[test]
1309 fn test_read_line_with_timeout_enforces_deadline() {
1310 let start = Instant::now();
1311 let result = read_line_with_timeout(
1312 || {
1313 std::thread::sleep(Duration::from_millis(250));
1314 Ok("late input\n".to_string())
1315 },
1316 Duration::from_millis(20),
1317 );
1318
1319 assert!(matches!(result, Err(InputError::Timeout)));
1320 assert!(
1321 start.elapsed() < Duration::from_millis(150),
1322 "timeout should return before the blocking reader finishes"
1323 );
1324 }
1325
1326 #[test]
1327 fn sanitize_strips_csi_sequences() {
1328 assert_eq!(sanitize_for_display("rm \x1b[31m-rf /\x1b[0m"), "rm -rf /");
1331 assert_eq!(sanitize_for_display("\x1b[Kafter-erase"), "after-erase");
1332 assert_eq!(sanitize_for_display("\x1b[2J\x1b[Hclear"), "clear");
1333 }
1334
1335 #[test]
1336 fn sanitize_strips_osc_sequences() {
1337 assert_eq!(
1340 sanitize_for_display("\x1b]0;Pwned by attacker\x07rm /etc"),
1341 "rm /etc"
1342 );
1343 assert_eq!(
1345 sanitize_for_display("\x1b]8;;https://evil\x1b\\click\x1b]8;;\x1b\\"),
1346 "click"
1347 );
1348 }
1349
1350 #[test]
1351 fn sanitize_visualizes_remaining_control_chars() {
1352 assert_eq!(
1355 sanitize_for_display("line1\nline2\r> fake-prompt"),
1356 "line1\\nline2\\r> fake-prompt"
1357 );
1358 assert_eq!(sanitize_for_display("ding\x07"), "ding\\x07");
1360 assert_eq!(sanitize_for_display("a\x7fb"), "a\\x7Fb");
1362 assert_eq!(sanitize_for_display("\x00null"), "\\x00null");
1363 }
1364
1365 #[test]
1366 fn sanitize_passes_through_normal_text_and_unicode() {
1367 assert_eq!(sanitize_for_display("rm -rf /tmp/foo"), "rm -rf /tmp/foo");
1368 assert_eq!(
1369 sanitize_for_display("git commit -m \"naïve façade — done\""),
1370 "git commit -m \"naïve façade — done\""
1371 );
1372 }
1373
1374 #[test]
1375 fn sanitize_drops_two_byte_esc_sequences() {
1376 assert_eq!(sanitize_for_display("foo\x1b=bar"), "foobar");
1378 assert_eq!(sanitize_for_display("foo\x1b"), "foo");
1380 }
1381
1382 #[test]
1383 fn test_read_line_from_reader_maps_eof_to_interrupted() {
1384 let mut reader = io::Cursor::new(Vec::<u8>::new());
1385
1386 assert!(matches!(
1387 read_line_from_reader(&mut reader),
1388 Err(InputError::Interrupted)
1389 ));
1390 }
1391}