1use crate::pages::summary::{PrePublishSummary, ScanReportSummary};
16use std::collections::HashSet;
17
18pub const MIN_STRONG_PASSWORD_BITS: f64 = 60.0;
20
21const SECRET_ACK_PHRASE: &str = "I understand the risks";
22const SECRET_ACK_PHRASE_NORMALIZED: &str = "i understand the risks";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum ConfirmationStep {
27 SecretScanAcknowledgment,
29 ContentReview,
31 PublicPublishingWarning,
33 PasswordStrengthWarning,
35 RecoveryKeyBackup,
37 FinalConfirmation,
39}
40
41impl ConfirmationStep {
42 pub fn label(self) -> &'static str {
44 match self {
45 ConfirmationStep::SecretScanAcknowledgment => "Secret Scan Acknowledgment",
46 ConfirmationStep::ContentReview => "Content Review",
47 ConfirmationStep::PublicPublishingWarning => "Public Publishing Warning",
48 ConfirmationStep::PasswordStrengthWarning => "Password Strength Warning",
49 ConfirmationStep::RecoveryKeyBackup => "Recovery Key Backup",
50 ConfirmationStep::FinalConfirmation => "Final Confirmation",
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum StepValidation {
58 Passed,
60 Failed(String),
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum ConfirmationResult {
67 Continue,
69 StepCompleted,
71 Confirmed,
73 Aborted,
75 Skip,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum PasswordStrengthAction {
82 SetStronger,
84 ProceedAnyway,
86 Abort,
88}
89
90#[derive(Debug, Clone)]
92pub struct ConfirmationConfig {
93 pub has_secrets: bool,
95 pub has_critical_secrets: bool,
97 pub secret_count: usize,
99 pub target_domain: Option<String>,
101 pub is_remote_publish: bool,
103 pub password_entropy_bits: f64,
105 pub has_recovery_key: bool,
107 pub recovery_key_phrase: Option<String>,
109 pub summary: PrePublishSummary,
111}
112
113impl Default for ConfirmationConfig {
114 fn default() -> Self {
115 Self {
116 has_secrets: false,
117 has_critical_secrets: false,
118 secret_count: 0,
119 target_domain: None,
120 is_remote_publish: false,
121 password_entropy_bits: 0.0,
122 has_recovery_key: false,
123 recovery_key_phrase: None,
124 summary: PrePublishSummary {
125 total_conversations: 0,
126 total_messages: 0,
127 total_characters: 0,
128 estimated_size_bytes: 0,
129 earliest_timestamp: None,
130 latest_timestamp: None,
131 date_histogram: Vec::new(),
132 workspaces: Vec::new(),
133 agents: Vec::new(),
134 secret_scan: ScanReportSummary::default(),
135 encryption_config: None,
136 key_slots: Vec::new(),
137 generated_at: chrono::Utc::now(),
138 },
139 }
140 }
141}
142
143pub struct ConfirmationFlow {
145 current_step: ConfirmationStep,
147 completed_steps: HashSet<ConfirmationStep>,
149 config: ConfirmationConfig,
151 final_enter_count: u8,
153 password_action: Option<PasswordStrengthAction>,
155}
156
157impl ConfirmationFlow {
158 pub fn new(config: ConfirmationConfig) -> Self {
160 let first_step = Self::determine_first_step(&config);
161 Self {
162 current_step: first_step,
163 completed_steps: HashSet::new(),
164 config,
165 final_enter_count: 0,
166 password_action: None,
167 }
168 }
169
170 pub fn current_step(&self) -> ConfirmationStep {
172 self.current_step
173 }
174
175 pub fn config(&self) -> &ConfirmationConfig {
177 &self.config
178 }
179
180 pub fn password_action(&self) -> Option<PasswordStrengthAction> {
182 self.password_action
183 }
184
185 fn determine_first_step(config: &ConfirmationConfig) -> ConfirmationStep {
187 if config.has_secrets {
188 ConfirmationStep::SecretScanAcknowledgment
189 } else {
190 ConfirmationStep::ContentReview
191 }
192 }
193
194 pub fn should_skip_current(&self) -> bool {
196 match self.current_step {
197 ConfirmationStep::SecretScanAcknowledgment => !self.config.has_secrets,
198 ConfirmationStep::PublicPublishingWarning => !self.config.is_remote_publish,
199 ConfirmationStep::PasswordStrengthWarning => {
200 self.config.password_entropy_bits >= MIN_STRONG_PASSWORD_BITS
201 }
202 ConfirmationStep::RecoveryKeyBackup => !self.config.has_recovery_key,
203 _ => false,
204 }
205 }
206
207 pub fn validate_secret_ack(&self, input: &str) -> StepValidation {
209 let normalized = input.trim().to_lowercase();
210 if normalized == SECRET_ACK_PHRASE_NORMALIZED {
211 StepValidation::Passed
212 } else {
213 StepValidation::Failed(format!("Please type exactly: \"{SECRET_ACK_PHRASE}\""))
214 }
215 }
216
217 pub fn validate_content_review(&self, input: &str) -> StepValidation {
219 let normalized = input.trim().to_lowercase();
220 if normalized == "y" || normalized == "yes" {
221 StepValidation::Passed
222 } else if normalized == "r" {
223 StepValidation::Failed("Return to summary".to_string())
224 } else {
225 StepValidation::Failed("Press Y to confirm or R to return to summary".to_string())
226 }
227 }
228
229 pub fn validate_public_warning(&self, input: &str) -> StepValidation {
231 let Some(domain) = &self.config.target_domain else {
232 return StepValidation::Passed;
233 };
234
235 let expected = format!("publish to {}", domain);
236 let normalized = input.trim().to_lowercase();
237
238 if normalized == expected.to_lowercase() {
239 StepValidation::Passed
240 } else {
241 StepValidation::Failed(format!("Please type exactly: \"publish to {}\"", domain))
242 }
243 }
244
245 pub fn parse_password_action(&self, input: &str) -> Option<PasswordStrengthAction> {
247 match input.trim().to_lowercase().as_str() {
248 "s" => Some(PasswordStrengthAction::SetStronger),
249 "p" => Some(PasswordStrengthAction::ProceedAnyway),
250 "a" => Some(PasswordStrengthAction::Abort),
251 _ => None,
252 }
253 }
254
255 pub fn validate_recovery_key(&self, input: &str) -> StepValidation {
257 let Some(phrase) = &self.config.recovery_key_phrase else {
258 return StepValidation::Passed;
259 };
260
261 let last_word = phrase
263 .split('-')
264 .next_back()
265 .or_else(|| phrase.split_whitespace().next_back())
266 .unwrap_or("");
267
268 let normalized = input.trim().to_lowercase();
269 if normalized == last_word.to_lowercase() {
270 StepValidation::Passed
271 } else {
272 StepValidation::Failed(
273 "Incorrect. Please type the LAST word of the recovery key.".to_string(),
274 )
275 }
276 }
277
278 pub fn process_final_enter(&mut self) -> bool {
281 self.final_enter_count += 1;
282 self.final_enter_count >= 2
283 }
284
285 pub fn reset_final_enter(&mut self) {
287 self.final_enter_count = 0;
288 }
289
290 pub fn final_enter_count(&self) -> u8 {
292 self.final_enter_count
293 }
294
295 pub fn complete_current_step(&mut self) {
297 self.completed_steps.insert(self.current_step);
298 self.advance_to_next_step();
299 }
300
301 fn advance_to_next_step(&mut self) {
303 let next = match self.current_step {
304 ConfirmationStep::SecretScanAcknowledgment => ConfirmationStep::ContentReview,
305 ConfirmationStep::ContentReview => {
306 if self.config.is_remote_publish {
307 ConfirmationStep::PublicPublishingWarning
308 } else if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
309 ConfirmationStep::PasswordStrengthWarning
310 } else if self.config.has_recovery_key {
311 ConfirmationStep::RecoveryKeyBackup
312 } else {
313 ConfirmationStep::FinalConfirmation
314 }
315 }
316 ConfirmationStep::PublicPublishingWarning => {
317 if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
318 ConfirmationStep::PasswordStrengthWarning
319 } else if self.config.has_recovery_key {
320 ConfirmationStep::RecoveryKeyBackup
321 } else {
322 ConfirmationStep::FinalConfirmation
323 }
324 }
325 ConfirmationStep::PasswordStrengthWarning => {
326 if self.config.has_recovery_key {
327 ConfirmationStep::RecoveryKeyBackup
328 } else {
329 ConfirmationStep::FinalConfirmation
330 }
331 }
332 ConfirmationStep::RecoveryKeyBackup => ConfirmationStep::FinalConfirmation,
333 ConfirmationStep::FinalConfirmation => ConfirmationStep::FinalConfirmation,
334 };
335
336 self.current_step = next;
337
338 if self.should_skip_current() && self.current_step != ConfirmationStep::FinalConfirmation {
340 self.advance_to_next_step();
341 }
342 }
343
344 pub fn is_complete(&self) -> bool {
346 self.completed_steps
347 .contains(&ConfirmationStep::FinalConfirmation)
348 }
349
350 pub fn set_password_action(&mut self, action: PasswordStrengthAction) {
352 self.password_action = Some(action);
353 }
354
355 pub fn completed_steps_summary(&self) -> Vec<(ConfirmationStep, &'static str)> {
357 let mut steps = Vec::new();
358
359 if self.config.has_secrets
360 && self
361 .completed_steps
362 .contains(&ConfirmationStep::SecretScanAcknowledgment)
363 {
364 steps.push((
365 ConfirmationStep::SecretScanAcknowledgment,
366 "Secrets acknowledged",
367 ));
368 }
369
370 if self
371 .completed_steps
372 .contains(&ConfirmationStep::ContentReview)
373 {
374 steps.push((ConfirmationStep::ContentReview, "Content reviewed"));
375 }
376
377 if self.config.is_remote_publish
378 && self
379 .completed_steps
380 .contains(&ConfirmationStep::PublicPublishingWarning)
381 {
382 steps.push((
383 ConfirmationStep::PublicPublishingWarning,
384 "Public URL confirmed",
385 ));
386 }
387
388 if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS
389 && self
390 .completed_steps
391 .contains(&ConfirmationStep::PasswordStrengthWarning)
392 {
393 let label = match self.password_action {
394 Some(PasswordStrengthAction::ProceedAnyway) => "Password warning acknowledged",
395 _ => "Password strength confirmed",
396 };
397 steps.push((ConfirmationStep::PasswordStrengthWarning, label));
398 }
399
400 if self.config.has_recovery_key
401 && self
402 .completed_steps
403 .contains(&ConfirmationStep::RecoveryKeyBackup)
404 {
405 steps.push((ConfirmationStep::RecoveryKeyBackup, "Recovery key saved"));
406 }
407
408 steps
409 }
410}
411
412pub fn estimate_password_entropy(password: &str) -> f64 {
422 if password.is_empty() {
423 return 0.0;
424 }
425
426 let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
427 let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
428 let has_digit = password.chars().any(|c| c.is_ascii_digit());
429 let has_special = password.chars().any(|c| !c.is_alphanumeric());
430
431 let mut pool_size = 0u32;
432 if has_lower {
433 pool_size += 26;
434 }
435 if has_upper {
436 pool_size += 26;
437 }
438 if has_digit {
439 pool_size += 10;
440 }
441 if has_special {
442 pool_size += 32;
443 }
444
445 if pool_size == 0 {
446 pool_size = 26; }
448
449 let bits_per_char = (pool_size as f64).log2();
450 let length = password.chars().count() as f64;
451
452 bits_per_char * length
453}
454
455pub fn password_strength_label(entropy_bits: f64) -> &'static str {
457 if entropy_bits >= 80.0 {
458 "Very Strong"
459 } else if entropy_bits >= 60.0 {
460 "Strong"
461 } else if entropy_bits >= 40.0 {
462 "Fair"
463 } else if entropy_bits >= 20.0 {
464 "Weak"
465 } else {
466 "Very Weak"
467 }
468}
469
470pub fn count_required_steps(config: &ConfirmationConfig) -> usize {
472 let mut count = 2; if config.has_secrets {
475 count += 1;
476 }
477 if config.is_remote_publish {
478 count += 1;
479 }
480 if config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
481 count += 1;
482 }
483 if config.has_recovery_key {
484 count += 1;
485 }
486
487 count
488}
489
490pub const UNENCRYPTED_ACK_PHRASE: &str = "I UNDERSTAND AND ACCEPT THE RISKS";
492
493pub const EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED: i32 = 3;
495
496const UNENCRYPTED_BLOCKED_ERROR_KIND: &str = "unencrypted_blocked";
497const UNENCRYPTED_BLOCKED_MESSAGE: &str = "Unencrypted exports are not allowed in robot mode";
498const UNENCRYPTED_BLOCKED_SUGGESTION: &str =
499 "Use --i-understand-unencrypted-risks flag if you really need this";
500
501#[derive(Debug, Clone, PartialEq, Eq)]
503pub enum UnencryptedConfirmResult {
504 Confirmed,
506 Cancelled,
508 RobotModeBlocked,
510}
511
512pub fn validate_unencrypted_ack(input: &str) -> StepValidation {
516 let normalized = input.trim().to_uppercase();
517 if normalized == UNENCRYPTED_ACK_PHRASE {
518 StepValidation::Passed
519 } else {
520 StepValidation::Failed(format!(
521 "Please type exactly: \"{}\"",
522 UNENCRYPTED_ACK_PHRASE
523 ))
524 }
525}
526
527pub fn check_robot_mode_unencrypted(
532 is_robot_mode: bool,
533 has_override_flag: bool,
534) -> UnencryptedConfirmResult {
535 if is_robot_mode && !has_override_flag {
536 UnencryptedConfirmResult::RobotModeBlocked
537 } else {
538 UnencryptedConfirmResult::Confirmed
539 }
540}
541
542pub fn robot_mode_blocked_error() -> serde_json::Value {
544 serde_json::json!({
545 "error": UNENCRYPTED_BLOCKED_ERROR_KIND,
546 "message": UNENCRYPTED_BLOCKED_MESSAGE,
547 "suggestion": UNENCRYPTED_BLOCKED_SUGGESTION,
548 "exit_code": EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED
549 })
550}
551
552pub fn unencrypted_warning_lines() -> Vec<&'static str> {
554 vec![
555 "You are about to export WITHOUT ENCRYPTION.",
556 "",
557 "This means:",
558 " • All conversation content will be publicly readable",
559 " • Anyone with the URL can view your data",
560 " • Search engines may index your content",
561 " • There is NO way to restrict access later",
562 "",
563 "This is IRREVERSIBLE once deployed.",
564 ]
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 fn make_basic_config() -> ConfirmationConfig {
572 ConfirmationConfig {
573 has_secrets: false,
574 has_critical_secrets: false,
575 secret_count: 0,
576 target_domain: None,
577 is_remote_publish: false,
578 password_entropy_bits: 80.0,
579 has_recovery_key: false,
580 recovery_key_phrase: None,
581 ..Default::default()
582 }
583 }
584
585 fn basic_flow_with(configure: impl FnOnce(&mut ConfirmationConfig)) -> ConfirmationFlow {
586 let mut config = make_basic_config();
587 configure(&mut config);
588 ConfirmationFlow::new(config)
589 }
590
591 #[test]
592 fn test_basic_flow_no_secrets() {
593 let config = make_basic_config();
594 let flow = ConfirmationFlow::new(config);
595
596 assert_eq!(flow.current_step(), ConfirmationStep::ContentReview);
598 }
599
600 #[test]
601 fn test_flow_with_secrets() {
602 let mut config = make_basic_config();
603 config.has_secrets = true;
604
605 let flow = ConfirmationFlow::new(config);
606
607 assert_eq!(
609 flow.current_step(),
610 ConfirmationStep::SecretScanAcknowledgment
611 );
612 }
613
614 #[test]
615 fn test_secret_ack_validation() {
616 let flow = basic_flow_with(|config| {
617 config.has_secrets = true;
618 });
619
620 assert_eq!(
622 flow.validate_secret_ack("i understand"),
623 StepValidation::Failed("Please type exactly: \"I understand the risks\"".to_string())
624 );
625
626 assert_eq!(
628 flow.validate_secret_ack("I UNDERSTAND THE RISKS"),
629 StepValidation::Passed
630 );
631 assert_eq!(
632 flow.validate_secret_ack("i understand the risks"),
633 StepValidation::Passed
634 );
635 }
636
637 #[test]
638 fn test_public_warning_validation() {
639 let flow = basic_flow_with(|config| {
640 config.is_remote_publish = true;
641 config.target_domain = Some("user.github.io".to_string());
642 });
643
644 assert!(matches!(
646 flow.validate_public_warning("publish"),
647 StepValidation::Failed(_)
648 ));
649
650 assert_eq!(
652 flow.validate_public_warning("publish to user.github.io"),
653 StepValidation::Passed
654 );
655 }
656
657 #[test]
658 fn test_recovery_key_validation() {
659 let flow = basic_flow_with(|config| {
660 config.has_recovery_key = true;
661 config.recovery_key_phrase = Some("forge-table-river-cloud-dance".to_string());
662 });
663
664 assert!(matches!(
666 flow.validate_recovery_key("river"),
667 StepValidation::Failed(_)
668 ));
669
670 assert_eq!(flow.validate_recovery_key("dance"), StepValidation::Passed);
672 }
673
674 #[test]
675 fn test_final_confirmation_double_enter() {
676 let config = make_basic_config();
677 let mut flow = ConfirmationFlow::new(config);
678
679 assert!(!flow.process_final_enter());
681 assert_eq!(flow.final_enter_count(), 1);
682
683 assert!(flow.process_final_enter());
685 assert_eq!(flow.final_enter_count(), 2);
686 }
687
688 #[test]
689 fn test_step_advancement() {
690 let mut config = make_basic_config();
691 config.has_secrets = true;
692 config.is_remote_publish = true;
693 config.target_domain = Some("test.github.io".to_string());
694 config.has_recovery_key = true;
695 config.recovery_key_phrase = Some("word1-word2-word3".to_string());
696
697 let mut flow = ConfirmationFlow::new(config);
698
699 assert_eq!(
701 flow.current_step(),
702 ConfirmationStep::SecretScanAcknowledgment
703 );
704
705 flow.complete_current_step();
706 assert_eq!(flow.current_step(), ConfirmationStep::ContentReview);
707
708 flow.complete_current_step();
709 assert_eq!(
710 flow.current_step(),
711 ConfirmationStep::PublicPublishingWarning
712 );
713
714 flow.complete_current_step();
715 assert_eq!(flow.current_step(), ConfirmationStep::RecoveryKeyBackup);
717
718 flow.complete_current_step();
719 assert_eq!(flow.current_step(), ConfirmationStep::FinalConfirmation);
720 }
721
722 #[test]
723 fn test_password_entropy_estimation() {
724 assert_eq!(estimate_password_entropy(""), 0.0);
726
727 let entropy = estimate_password_entropy("password");
729 assert!(entropy > 30.0 && entropy < 40.0); let entropy = estimate_password_entropy("P@ssw0rd!");
733 assert!(entropy > 50.0); }
735
736 #[test]
737 fn test_password_strength_label() {
738 for (entropy_bits, expected_label) in [
739 (10.0, "Very Weak"),
740 (30.0, "Weak"),
741 (50.0, "Fair"),
742 (70.0, "Strong"),
743 (90.0, "Very Strong"),
744 ] {
745 assert_eq!(
746 password_strength_label(entropy_bits),
747 expected_label,
748 "entropy_bits={entropy_bits}"
749 );
750 }
751 }
752
753 #[test]
754 fn test_count_required_steps() {
755 let config = make_basic_config();
756 assert_eq!(count_required_steps(&config), 2); let mut config = make_basic_config();
759 config.has_secrets = true;
760 config.is_remote_publish = true;
761 config.password_entropy_bits = 30.0;
762 config.has_recovery_key = true;
763 assert_eq!(count_required_steps(&config), 6); }
765
766 #[test]
767 fn test_content_review_validation() {
768 let config = make_basic_config();
769 let flow = ConfirmationFlow::new(config);
770
771 for input in ["y", "Y", "yes"] {
772 assert_eq!(
773 flow.validate_content_review(input),
774 StepValidation::Passed,
775 "input={input}"
776 );
777 }
778 assert!(matches!(
779 flow.validate_content_review("n"),
780 StepValidation::Failed(_)
781 ));
782 }
783
784 #[test]
785 fn test_password_action_parsing() {
786 let config = make_basic_config();
787 let flow = ConfirmationFlow::new(config);
788
789 for (input, expected) in [
790 ("s", Some(PasswordStrengthAction::SetStronger)),
791 ("P", Some(PasswordStrengthAction::ProceedAnyway)),
792 ("a", Some(PasswordStrengthAction::Abort)),
793 ] {
794 assert_eq!(flow.parse_password_action(input), expected, "input={input}");
795 }
796 assert_eq!(flow.parse_password_action("x"), None);
797 }
798
799 #[test]
800 fn test_completed_steps_summary() {
801 let mut config = make_basic_config();
802 config.has_secrets = true;
803 config.is_remote_publish = true;
804 config.target_domain = Some("test.github.io".to_string());
805
806 let mut flow = ConfirmationFlow::new(config);
807
808 flow.complete_current_step();
810
811 flow.complete_current_step();
813
814 let summary = flow.completed_steps_summary();
815 assert_eq!(summary.len(), 2);
816 assert_eq!(summary[0].1, "Secrets acknowledged");
817 assert_eq!(summary[1].1, "Content reviewed");
818 }
819
820 #[test]
821 fn test_unencrypted_ack_validation() {
822 assert_eq!(
824 validate_unencrypted_ack("I UNDERSTAND AND ACCEPT THE RISKS"),
825 StepValidation::Passed
826 );
827
828 assert_eq!(
830 validate_unencrypted_ack("i understand and accept the risks"),
831 StepValidation::Passed
832 );
833
834 assert_eq!(
836 validate_unencrypted_ack(" I UNDERSTAND AND ACCEPT THE RISKS "),
837 StepValidation::Passed
838 );
839
840 assert!(matches!(
842 validate_unencrypted_ack("I understand"),
843 StepValidation::Failed(_)
844 ));
845 assert!(matches!(
846 validate_unencrypted_ack("yes"),
847 StepValidation::Failed(_)
848 ));
849 assert!(matches!(
850 validate_unencrypted_ack("I ACCEPT THE RISKS"),
851 StepValidation::Failed(_)
852 ));
853 }
854
855 #[test]
856 fn test_robot_mode_unencrypted_check() {
857 assert_eq!(
859 check_robot_mode_unencrypted(false, false),
860 UnencryptedConfirmResult::Confirmed
861 );
862
863 assert_eq!(
865 check_robot_mode_unencrypted(true, true),
866 UnencryptedConfirmResult::Confirmed
867 );
868
869 assert_eq!(
871 check_robot_mode_unencrypted(true, false),
872 UnencryptedConfirmResult::RobotModeBlocked
873 );
874 }
875
876 #[test]
877 fn test_robot_mode_blocked_error() {
878 let error = robot_mode_blocked_error();
879 assert_eq!(
880 error,
881 serde_json::json!({
882 "error": "unencrypted_blocked",
883 "message": "Unencrypted exports are not allowed in robot mode",
884 "suggestion": "Use --i-understand-unencrypted-risks flag if you really need this",
885 "exit_code": EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED
886 })
887 );
888 }
889
890 #[test]
891 fn test_unencrypted_warning_lines() {
892 let lines = unencrypted_warning_lines();
893 assert!(!lines.is_empty());
894 assert!(lines[0].contains("WITHOUT ENCRYPTION"));
895 }
896}