use crate::pages::summary::{PrePublishSummary, ScanReportSummary};
use std::collections::HashSet;
pub const MIN_STRONG_PASSWORD_BITS: f64 = 60.0;
const SECRET_ACK_PHRASE: &str = "I understand the risks";
const SECRET_ACK_PHRASE_NORMALIZED: &str = "i understand the risks";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConfirmationStep {
SecretScanAcknowledgment,
ContentReview,
PublicPublishingWarning,
PasswordStrengthWarning,
RecoveryKeyBackup,
FinalConfirmation,
}
impl ConfirmationStep {
pub fn label(self) -> &'static str {
match self {
ConfirmationStep::SecretScanAcknowledgment => "Secret Scan Acknowledgment",
ConfirmationStep::ContentReview => "Content Review",
ConfirmationStep::PublicPublishingWarning => "Public Publishing Warning",
ConfirmationStep::PasswordStrengthWarning => "Password Strength Warning",
ConfirmationStep::RecoveryKeyBackup => "Recovery Key Backup",
ConfirmationStep::FinalConfirmation => "Final Confirmation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepValidation {
Passed,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmationResult {
Continue,
StepCompleted,
Confirmed,
Aborted,
Skip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordStrengthAction {
SetStronger,
ProceedAnyway,
Abort,
}
#[derive(Debug, Clone)]
pub struct ConfirmationConfig {
pub has_secrets: bool,
pub has_critical_secrets: bool,
pub secret_count: usize,
pub target_domain: Option<String>,
pub is_remote_publish: bool,
pub password_entropy_bits: f64,
pub has_recovery_key: bool,
pub recovery_key_phrase: Option<String>,
pub summary: PrePublishSummary,
}
impl Default for ConfirmationConfig {
fn default() -> Self {
Self {
has_secrets: false,
has_critical_secrets: false,
secret_count: 0,
target_domain: None,
is_remote_publish: false,
password_entropy_bits: 0.0,
has_recovery_key: false,
recovery_key_phrase: None,
summary: PrePublishSummary {
total_conversations: 0,
total_messages: 0,
total_characters: 0,
estimated_size_bytes: 0,
earliest_timestamp: None,
latest_timestamp: None,
date_histogram: Vec::new(),
workspaces: Vec::new(),
agents: Vec::new(),
secret_scan: ScanReportSummary::default(),
encryption_config: None,
key_slots: Vec::new(),
generated_at: chrono::Utc::now(),
},
}
}
}
pub struct ConfirmationFlow {
current_step: ConfirmationStep,
completed_steps: HashSet<ConfirmationStep>,
config: ConfirmationConfig,
final_enter_count: u8,
password_action: Option<PasswordStrengthAction>,
}
impl ConfirmationFlow {
pub fn new(config: ConfirmationConfig) -> Self {
let first_step = Self::determine_first_step(&config);
Self {
current_step: first_step,
completed_steps: HashSet::new(),
config,
final_enter_count: 0,
password_action: None,
}
}
pub fn current_step(&self) -> ConfirmationStep {
self.current_step
}
pub fn config(&self) -> &ConfirmationConfig {
&self.config
}
pub fn password_action(&self) -> Option<PasswordStrengthAction> {
self.password_action
}
fn determine_first_step(config: &ConfirmationConfig) -> ConfirmationStep {
if config.has_secrets {
ConfirmationStep::SecretScanAcknowledgment
} else {
ConfirmationStep::ContentReview
}
}
pub fn should_skip_current(&self) -> bool {
match self.current_step {
ConfirmationStep::SecretScanAcknowledgment => !self.config.has_secrets,
ConfirmationStep::PublicPublishingWarning => !self.config.is_remote_publish,
ConfirmationStep::PasswordStrengthWarning => {
self.config.password_entropy_bits >= MIN_STRONG_PASSWORD_BITS
}
ConfirmationStep::RecoveryKeyBackup => !self.config.has_recovery_key,
_ => false,
}
}
pub fn validate_secret_ack(&self, input: &str) -> StepValidation {
let normalized = input.trim().to_lowercase();
if normalized == SECRET_ACK_PHRASE_NORMALIZED {
StepValidation::Passed
} else {
StepValidation::Failed(format!("Please type exactly: \"{SECRET_ACK_PHRASE}\""))
}
}
pub fn validate_content_review(&self, input: &str) -> StepValidation {
let normalized = input.trim().to_lowercase();
if normalized == "y" || normalized == "yes" {
StepValidation::Passed
} else if normalized == "r" {
StepValidation::Failed("Return to summary".to_string())
} else {
StepValidation::Failed("Press Y to confirm or R to return to summary".to_string())
}
}
pub fn validate_public_warning(&self, input: &str) -> StepValidation {
let Some(domain) = &self.config.target_domain else {
return StepValidation::Passed;
};
let expected = format!("publish to {}", domain);
let normalized = input.trim().to_lowercase();
if normalized == expected.to_lowercase() {
StepValidation::Passed
} else {
StepValidation::Failed(format!("Please type exactly: \"publish to {}\"", domain))
}
}
pub fn parse_password_action(&self, input: &str) -> Option<PasswordStrengthAction> {
match input.trim().to_lowercase().as_str() {
"s" => Some(PasswordStrengthAction::SetStronger),
"p" => Some(PasswordStrengthAction::ProceedAnyway),
"a" => Some(PasswordStrengthAction::Abort),
_ => None,
}
}
pub fn validate_recovery_key(&self, input: &str) -> StepValidation {
let Some(phrase) = &self.config.recovery_key_phrase else {
return StepValidation::Passed;
};
let last_word = phrase
.split('-')
.next_back()
.or_else(|| phrase.split_whitespace().next_back())
.unwrap_or("");
let normalized = input.trim().to_lowercase();
if normalized == last_word.to_lowercase() {
StepValidation::Passed
} else {
StepValidation::Failed(
"Incorrect. Please type the LAST word of the recovery key.".to_string(),
)
}
}
pub fn process_final_enter(&mut self) -> bool {
self.final_enter_count += 1;
self.final_enter_count >= 2
}
pub fn reset_final_enter(&mut self) {
self.final_enter_count = 0;
}
pub fn final_enter_count(&self) -> u8 {
self.final_enter_count
}
pub fn complete_current_step(&mut self) {
self.completed_steps.insert(self.current_step);
self.advance_to_next_step();
}
fn advance_to_next_step(&mut self) {
let next = match self.current_step {
ConfirmationStep::SecretScanAcknowledgment => ConfirmationStep::ContentReview,
ConfirmationStep::ContentReview => {
if self.config.is_remote_publish {
ConfirmationStep::PublicPublishingWarning
} else if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
ConfirmationStep::PasswordStrengthWarning
} else if self.config.has_recovery_key {
ConfirmationStep::RecoveryKeyBackup
} else {
ConfirmationStep::FinalConfirmation
}
}
ConfirmationStep::PublicPublishingWarning => {
if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
ConfirmationStep::PasswordStrengthWarning
} else if self.config.has_recovery_key {
ConfirmationStep::RecoveryKeyBackup
} else {
ConfirmationStep::FinalConfirmation
}
}
ConfirmationStep::PasswordStrengthWarning => {
if self.config.has_recovery_key {
ConfirmationStep::RecoveryKeyBackup
} else {
ConfirmationStep::FinalConfirmation
}
}
ConfirmationStep::RecoveryKeyBackup => ConfirmationStep::FinalConfirmation,
ConfirmationStep::FinalConfirmation => ConfirmationStep::FinalConfirmation,
};
self.current_step = next;
if self.should_skip_current() && self.current_step != ConfirmationStep::FinalConfirmation {
self.advance_to_next_step();
}
}
pub fn is_complete(&self) -> bool {
self.completed_steps
.contains(&ConfirmationStep::FinalConfirmation)
}
pub fn set_password_action(&mut self, action: PasswordStrengthAction) {
self.password_action = Some(action);
}
pub fn completed_steps_summary(&self) -> Vec<(ConfirmationStep, &'static str)> {
let mut steps = Vec::new();
if self.config.has_secrets
&& self
.completed_steps
.contains(&ConfirmationStep::SecretScanAcknowledgment)
{
steps.push((
ConfirmationStep::SecretScanAcknowledgment,
"Secrets acknowledged",
));
}
if self
.completed_steps
.contains(&ConfirmationStep::ContentReview)
{
steps.push((ConfirmationStep::ContentReview, "Content reviewed"));
}
if self.config.is_remote_publish
&& self
.completed_steps
.contains(&ConfirmationStep::PublicPublishingWarning)
{
steps.push((
ConfirmationStep::PublicPublishingWarning,
"Public URL confirmed",
));
}
if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS
&& self
.completed_steps
.contains(&ConfirmationStep::PasswordStrengthWarning)
{
let label = match self.password_action {
Some(PasswordStrengthAction::ProceedAnyway) => "Password warning acknowledged",
_ => "Password strength confirmed",
};
steps.push((ConfirmationStep::PasswordStrengthWarning, label));
}
if self.config.has_recovery_key
&& self
.completed_steps
.contains(&ConfirmationStep::RecoveryKeyBackup)
{
steps.push((ConfirmationStep::RecoveryKeyBackup, "Recovery key saved"));
}
steps
}
}
pub fn estimate_password_entropy(password: &str) -> f64 {
if password.is_empty() {
return 0.0;
}
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_special = password.chars().any(|c| !c.is_alphanumeric());
let mut pool_size = 0u32;
if has_lower {
pool_size += 26;
}
if has_upper {
pool_size += 26;
}
if has_digit {
pool_size += 10;
}
if has_special {
pool_size += 32;
}
if pool_size == 0 {
pool_size = 26; }
let bits_per_char = (pool_size as f64).log2();
let length = password.chars().count() as f64;
bits_per_char * length
}
pub fn password_strength_label(entropy_bits: f64) -> &'static str {
if entropy_bits >= 80.0 {
"Very Strong"
} else if entropy_bits >= 60.0 {
"Strong"
} else if entropy_bits >= 40.0 {
"Fair"
} else if entropy_bits >= 20.0 {
"Weak"
} else {
"Very Weak"
}
}
pub fn count_required_steps(config: &ConfirmationConfig) -> usize {
let mut count = 2;
if config.has_secrets {
count += 1;
}
if config.is_remote_publish {
count += 1;
}
if config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
count += 1;
}
if config.has_recovery_key {
count += 1;
}
count
}
pub const UNENCRYPTED_ACK_PHRASE: &str = "I UNDERSTAND AND ACCEPT THE RISKS";
pub const EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED: i32 = 3;
const UNENCRYPTED_BLOCKED_ERROR_KIND: &str = "unencrypted_blocked";
const UNENCRYPTED_BLOCKED_MESSAGE: &str = "Unencrypted exports are not allowed in robot mode";
const UNENCRYPTED_BLOCKED_SUGGESTION: &str =
"Use --i-understand-unencrypted-risks flag if you really need this";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnencryptedConfirmResult {
Confirmed,
Cancelled,
RobotModeBlocked,
}
pub fn validate_unencrypted_ack(input: &str) -> StepValidation {
let normalized = input.trim().to_uppercase();
if normalized == UNENCRYPTED_ACK_PHRASE {
StepValidation::Passed
} else {
StepValidation::Failed(format!(
"Please type exactly: \"{}\"",
UNENCRYPTED_ACK_PHRASE
))
}
}
pub fn check_robot_mode_unencrypted(
is_robot_mode: bool,
has_override_flag: bool,
) -> UnencryptedConfirmResult {
if is_robot_mode && !has_override_flag {
UnencryptedConfirmResult::RobotModeBlocked
} else {
UnencryptedConfirmResult::Confirmed
}
}
pub fn robot_mode_blocked_error() -> serde_json::Value {
serde_json::json!({
"error": UNENCRYPTED_BLOCKED_ERROR_KIND,
"message": UNENCRYPTED_BLOCKED_MESSAGE,
"suggestion": UNENCRYPTED_BLOCKED_SUGGESTION,
"exit_code": EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED
})
}
pub fn unencrypted_warning_lines() -> Vec<&'static str> {
vec![
"You are about to export WITHOUT ENCRYPTION.",
"",
"This means:",
" • All conversation content will be publicly readable",
" • Anyone with the URL can view your data",
" • Search engines may index your content",
" • There is NO way to restrict access later",
"",
"This is IRREVERSIBLE once deployed.",
]
}
#[cfg(test)]
mod tests {
use super::*;
fn make_basic_config() -> ConfirmationConfig {
ConfirmationConfig {
has_secrets: false,
has_critical_secrets: false,
secret_count: 0,
target_domain: None,
is_remote_publish: false,
password_entropy_bits: 80.0,
has_recovery_key: false,
recovery_key_phrase: None,
..Default::default()
}
}
fn basic_flow_with(configure: impl FnOnce(&mut ConfirmationConfig)) -> ConfirmationFlow {
let mut config = make_basic_config();
configure(&mut config);
ConfirmationFlow::new(config)
}
#[test]
fn test_basic_flow_no_secrets() {
let config = make_basic_config();
let flow = ConfirmationFlow::new(config);
assert_eq!(flow.current_step(), ConfirmationStep::ContentReview);
}
#[test]
fn test_flow_with_secrets() {
let mut config = make_basic_config();
config.has_secrets = true;
let flow = ConfirmationFlow::new(config);
assert_eq!(
flow.current_step(),
ConfirmationStep::SecretScanAcknowledgment
);
}
#[test]
fn test_secret_ack_validation() {
let flow = basic_flow_with(|config| {
config.has_secrets = true;
});
assert_eq!(
flow.validate_secret_ack("i understand"),
StepValidation::Failed("Please type exactly: \"I understand the risks\"".to_string())
);
assert_eq!(
flow.validate_secret_ack("I UNDERSTAND THE RISKS"),
StepValidation::Passed
);
assert_eq!(
flow.validate_secret_ack("i understand the risks"),
StepValidation::Passed
);
}
#[test]
fn test_public_warning_validation() {
let flow = basic_flow_with(|config| {
config.is_remote_publish = true;
config.target_domain = Some("user.github.io".to_string());
});
assert!(matches!(
flow.validate_public_warning("publish"),
StepValidation::Failed(_)
));
assert_eq!(
flow.validate_public_warning("publish to user.github.io"),
StepValidation::Passed
);
}
#[test]
fn test_recovery_key_validation() {
let flow = basic_flow_with(|config| {
config.has_recovery_key = true;
config.recovery_key_phrase = Some("forge-table-river-cloud-dance".to_string());
});
assert!(matches!(
flow.validate_recovery_key("river"),
StepValidation::Failed(_)
));
assert_eq!(flow.validate_recovery_key("dance"), StepValidation::Passed);
}
#[test]
fn test_final_confirmation_double_enter() {
let config = make_basic_config();
let mut flow = ConfirmationFlow::new(config);
assert!(!flow.process_final_enter());
assert_eq!(flow.final_enter_count(), 1);
assert!(flow.process_final_enter());
assert_eq!(flow.final_enter_count(), 2);
}
#[test]
fn test_step_advancement() {
let mut config = make_basic_config();
config.has_secrets = true;
config.is_remote_publish = true;
config.target_domain = Some("test.github.io".to_string());
config.has_recovery_key = true;
config.recovery_key_phrase = Some("word1-word2-word3".to_string());
let mut flow = ConfirmationFlow::new(config);
assert_eq!(
flow.current_step(),
ConfirmationStep::SecretScanAcknowledgment
);
flow.complete_current_step();
assert_eq!(flow.current_step(), ConfirmationStep::ContentReview);
flow.complete_current_step();
assert_eq!(
flow.current_step(),
ConfirmationStep::PublicPublishingWarning
);
flow.complete_current_step();
assert_eq!(flow.current_step(), ConfirmationStep::RecoveryKeyBackup);
flow.complete_current_step();
assert_eq!(flow.current_step(), ConfirmationStep::FinalConfirmation);
}
#[test]
fn test_password_entropy_estimation() {
assert_eq!(estimate_password_entropy(""), 0.0);
let entropy = estimate_password_entropy("password");
assert!(entropy > 30.0 && entropy < 40.0);
let entropy = estimate_password_entropy("P@ssw0rd!");
assert!(entropy > 50.0); }
#[test]
fn test_password_strength_label() {
for (entropy_bits, expected_label) in [
(10.0, "Very Weak"),
(30.0, "Weak"),
(50.0, "Fair"),
(70.0, "Strong"),
(90.0, "Very Strong"),
] {
assert_eq!(
password_strength_label(entropy_bits),
expected_label,
"entropy_bits={entropy_bits}"
);
}
}
#[test]
fn test_count_required_steps() {
let config = make_basic_config();
assert_eq!(count_required_steps(&config), 2);
let mut config = make_basic_config();
config.has_secrets = true;
config.is_remote_publish = true;
config.password_entropy_bits = 30.0;
config.has_recovery_key = true;
assert_eq!(count_required_steps(&config), 6); }
#[test]
fn test_content_review_validation() {
let config = make_basic_config();
let flow = ConfirmationFlow::new(config);
for input in ["y", "Y", "yes"] {
assert_eq!(
flow.validate_content_review(input),
StepValidation::Passed,
"input={input}"
);
}
assert!(matches!(
flow.validate_content_review("n"),
StepValidation::Failed(_)
));
}
#[test]
fn test_password_action_parsing() {
let config = make_basic_config();
let flow = ConfirmationFlow::new(config);
for (input, expected) in [
("s", Some(PasswordStrengthAction::SetStronger)),
("P", Some(PasswordStrengthAction::ProceedAnyway)),
("a", Some(PasswordStrengthAction::Abort)),
] {
assert_eq!(flow.parse_password_action(input), expected, "input={input}");
}
assert_eq!(flow.parse_password_action("x"), None);
}
#[test]
fn test_completed_steps_summary() {
let mut config = make_basic_config();
config.has_secrets = true;
config.is_remote_publish = true;
config.target_domain = Some("test.github.io".to_string());
let mut flow = ConfirmationFlow::new(config);
flow.complete_current_step();
flow.complete_current_step();
let summary = flow.completed_steps_summary();
assert_eq!(summary.len(), 2);
assert_eq!(summary[0].1, "Secrets acknowledged");
assert_eq!(summary[1].1, "Content reviewed");
}
#[test]
fn test_unencrypted_ack_validation() {
assert_eq!(
validate_unencrypted_ack("I UNDERSTAND AND ACCEPT THE RISKS"),
StepValidation::Passed
);
assert_eq!(
validate_unencrypted_ack("i understand and accept the risks"),
StepValidation::Passed
);
assert_eq!(
validate_unencrypted_ack(" I UNDERSTAND AND ACCEPT THE RISKS "),
StepValidation::Passed
);
assert!(matches!(
validate_unencrypted_ack("I understand"),
StepValidation::Failed(_)
));
assert!(matches!(
validate_unencrypted_ack("yes"),
StepValidation::Failed(_)
));
assert!(matches!(
validate_unencrypted_ack("I ACCEPT THE RISKS"),
StepValidation::Failed(_)
));
}
#[test]
fn test_robot_mode_unencrypted_check() {
assert_eq!(
check_robot_mode_unencrypted(false, false),
UnencryptedConfirmResult::Confirmed
);
assert_eq!(
check_robot_mode_unencrypted(true, true),
UnencryptedConfirmResult::Confirmed
);
assert_eq!(
check_robot_mode_unencrypted(true, false),
UnencryptedConfirmResult::RobotModeBlocked
);
}
#[test]
fn test_robot_mode_blocked_error() {
let error = robot_mode_blocked_error();
assert_eq!(
error,
serde_json::json!({
"error": "unencrypted_blocked",
"message": "Unencrypted exports are not allowed in robot mode",
"suggestion": "Use --i-understand-unencrypted-risks flag if you really need this",
"exit_code": EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED
})
);
}
#[test]
fn test_unencrypted_warning_lines() {
let lines = unencrypted_warning_lines();
assert!(!lines.is_empty());
assert!(lines[0].contains("WITHOUT ENCRYPTION"));
}
}