1use colored::Colorize;
21use rand::RngExt;
22use serde::{Deserialize, Serialize};
23use std::io::{self, BufRead, IsTerminal, Write};
24use std::time::{Duration, Instant};
25
26pub const DEFAULT_TIMEOUT_SECONDS: u64 = 5;
28
29pub const DEFAULT_CODE_LENGTH: usize = 4;
31
32pub const MAX_CODE_LENGTH: usize = 8;
34
35pub const MIN_CODE_LENGTH: usize = 4;
37
38pub const MAX_TIMEOUT_SECONDS: u64 = 30;
40
41pub const MIN_TIMEOUT_SECONDS: u64 = 1;
43
44const CODE_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum VerificationMethod {
52 Code,
54 Command,
56 None,
58}
59
60impl Default for VerificationMethod {
61 fn default() -> Self {
62 Self::Code
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum InteractiveResult {
69 AllowlistRequested(AllowlistScope),
71
72 InvalidCode,
74
75 Timeout,
77
78 Cancelled,
80
81 NotAvailable(NotAvailableReason),
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum NotAvailableReason {
88 NotTty,
90
91 CiEnvironment,
93
94 Disabled,
96
97 MissingEnv(String),
99
100 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#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum AllowlistScope {
119 Once,
121
122 Session,
124
125 Temporary(Duration),
127
128 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#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct InteractiveConfig {
146 pub enabled: bool,
148
149 pub verification: VerificationMethod,
151
152 pub timeout_seconds: u64,
154
155 pub code_length: usize,
157
158 pub max_attempts: u32,
160
161 pub allow_non_tty_fallback: bool,
163
164 pub disable_in_ci: bool,
166
167 pub require_env: Option<String>,
169}
170
171impl Default for InteractiveConfig {
172 fn default() -> Self {
173 Self {
174 enabled: false, 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 #[must_use]
189 pub fn new() -> Self {
190 Self::default()
191 }
192
193 #[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 #[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#[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#[must_use]
235pub fn validate_code(input: &str, expected: &str) -> bool {
236 input.trim().eq_ignore_ascii_case(expected)
237}
238
239pub fn check_interactive_available(config: &InteractiveConfig) -> Result<(), NotAvailableReason> {
248 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 if !io::stdin().is_terminal() {
262 return Err(NotAvailableReason::NotTty);
263 }
264
265 if config.disable_in_ci && is_ci_environment() {
267 return Err(NotAvailableReason::CiEnvironment);
268 }
269
270 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#[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 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 match read_input_with_timeout(timeout) {
332 Ok(input) => {
333 let input = input.trim();
334
335 if input.is_empty() {
337 return InteractiveResult::Cancelled;
338 }
339
340 if validate_code(input, &code) {
342 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
404fn 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 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 let _ = writeln!(
452 handle,
453 "{}{}{}",
454 "\u{256d}".red(),
455 "\u{2500}".repeat(WIDTH).red(),
456 "\u{256e}".red()
457 );
458
459 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 let _ = writeln!(
484 handle,
485 "{}{}{}",
486 "\u{251c}".red(),
487 "\u{2500}".repeat(WIDTH).red().dimmed(),
488 "\u{2524}".red()
489 );
490
491 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 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 let _ = writeln!(
525 handle,
526 "{}{}{}",
527 "\u{251c}".red(),
528 "\u{2500}".repeat(WIDTH).red().dimmed(),
529 "\u{2524}".red()
530 );
531
532 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 let visible_len = 2 + 1 + key.len() + 1 + 1 + desc.len(); 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 write_line(&mut handle, "", "red");
561
562 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 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 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 let _ = write!(handle, "{} ", ">".green().bold());
649 let _ = handle.flush();
650 }
651}
652
653#[derive(Debug)]
655enum InputError {
656 Timeout,
657 #[allow(dead_code)] Io(io::Error),
659 Interrupted,
660}
661
662fn read_input_with_timeout(timeout: Duration) -> Result<String, InputError> {
669 let start = Instant::now();
670 let stdin = io::stdin();
671
672 let mut input = String::new();
675
676 let handle = stdin.lock();
688 let mut reader = io::BufReader::new(handle);
689
690 match reader.read_line(&mut input) {
692 Ok(0) => Err(InputError::Interrupted), Ok(_) => {
694 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
705fn 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), _ => Ok(AllowlistScope::Once), }
745 }
746 Err(e) if e.kind() == io::ErrorKind::Interrupted => Err(InputError::Interrupted),
747 Err(e) => Err(InputError::Io(e)),
748 }
749}
750
751pub 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 let code = generate_verification_code(2);
804 assert_eq!(code.len(), MIN_CODE_LENGTH);
805
806 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); 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 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 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 config.timeout_seconds = 0;
872 assert_eq!(config.timeout(), Duration::from_secs(MIN_TIMEOUT_SECONDS));
873
874 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 config.code_length = 1;
888 assert_eq!(config.effective_code_length(), MIN_CODE_LENGTH);
889
890 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 }