//! Short-lived keyboard forms. Password fields live only in zeroizing owners //! here and are never copied into `UiState`, screen snapshots, or Debug output. use std::ffi::OsString; use std::net::{Ipv4Addr, Ipv6Addr}; use crossterm::event::KeyCode; use crate::app::RestartChoice; use crate::app::{FirstIssueAttempt, FirstIssueRetry}; use crate::backup::ArchiveSummary; use crate::delivery::QrCodeOutput; use crate::domain::{ClientAddresses, PublicKey}; use crate::tui::events::{ DeliveryDestinationSummary, RestartPreviewSummary, RestorePreviewSummary, SecretText, SensitiveCommand, UiCommand, }; const MAX_INPUT_CHARS: usize = 4096; pub enum FormAction { None, Cancel, Submit(UiCommand), SubmitSensitive(SensitiveCommand), PreviewRestart, ConfirmRename { identity: PublicKey, alias: String, }, CancelRestorePreview, CancelPreparedDelivery, StartReissue { identity: PublicKey, overwrite_confirmed: bool, prepared_generation: u64, }, Exit, Feedback(String), } /// Public, secret-free summary of keyboard capabilities for the active form. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct FormKeyContext { pub enter_action: Option<&'static str>, pub supports_tab: bool, pub supports_selection: bool, } enum Form { Setup { password: SecretInput, confirmation: SecretInput, confirmation_focused: bool, }, Create { alias: String, ipv4: String, ipv6: String, field: usize, }, Rename { identity: PublicKey, alias: String, }, Import { file_name: String, }, Confirm(Confirmation), DraftReview, DeliveryPath { identity: PublicKey, reissue: bool, attempt: FirstIssueAttempt, path: String, include_qr: bool, }, ReissuePassword { identity: PublicKey, password: SecretInput, overwrite_confirmed: bool, prepared_generation: u64, }, Backups { items: Vec, selected: usize, }, Restore(Confirmation), Disclosure { qr: QrCodeOutput, notice: String, }, } struct Confirmation { title: String, description: String, affirmative: String, action: ConfirmAction, } enum ConfirmAction { Rename { identity: PublicKey, alias: String }, Delete(PublicKey), Commit, Discard, Exit, Restart(RestartChoice), Delivery { summary: DeliveryDestinationSummary }, ConfirmRestore { preview_generation: u64 }, } struct SecretInput(zeroize::Zeroizing); impl SecretInput { fn new() -> Self { Self(zeroize::Zeroizing::new(String::new())) } fn push(&mut self, character: char) { if !character.is_control() && self.0.chars().count() < MAX_INPUT_CHARS { self.0.push(character); } } fn pop(&mut self) { self.0.pop(); } fn is_empty(&self) -> bool { self.0.is_empty() } fn matches(&self, other: &Self) -> bool { self.0.as_str() == other.0.as_str() } fn masked(&self) -> String { "•".repeat(self.0.chars().count().min(64)) } fn into_secret(self) -> SecretText { SecretText::new(self.0.to_string()) } } /// Per-run form owner. It intentionally has no Debug/Clone implementation. #[derive(Default)] pub struct FormController { active: Option
, feedback: Option, first_retry: Option<(PublicKey, FirstIssueRetry)>, } impl FormController { pub fn is_active(&self) -> bool { self.active.is_some() } pub fn is_draft_review(&self) -> bool { matches!(self.active, Some(Form::DraftReview)) } /// Describes only keys handled by the active form; never exposes form data. pub fn key_context(&self) -> Option { self.active.as_ref().map(|form| match form { Form::Setup { .. } => FormKeyContext { enter_action: Some("next / submit"), supports_tab: true, supports_selection: false, }, Form::Create { .. } => FormKeyContext { enter_action: Some("next / submit"), supports_tab: true, supports_selection: false, }, Form::Rename { .. } => FormKeyContext { enter_action: Some("submit"), supports_tab: false, supports_selection: false, }, Form::Import { .. } => FormKeyContext { enter_action: Some("submit"), supports_tab: false, supports_selection: false, }, Form::Confirm(_) => FormKeyContext { enter_action: Some("confirm"), supports_tab: false, supports_selection: false, }, Form::DraftReview => FormKeyContext { enter_action: None, supports_tab: false, supports_selection: false, }, Form::DeliveryPath { .. } => FormKeyContext { enter_action: Some("prepare file"), supports_tab: true, supports_selection: false, }, Form::ReissuePassword { .. } => FormKeyContext { enter_action: Some("submit password"), supports_tab: false, supports_selection: false, }, Form::Backups { items, .. } => FormKeyContext { enter_action: (!items.is_empty()).then_some("preview restore"), supports_tab: false, supports_selection: !items.is_empty(), }, Form::Restore(_) => FormKeyContext { enter_action: Some("confirm restore"), supports_tab: false, supports_selection: false, }, Form::Disclosure { .. } => FormKeyContext { enter_action: None, supports_tab: false, supports_selection: false, }, }) } pub fn qr_output(&self) -> Option<&QrCodeOutput> { match self.active.as_ref() { Some(Form::Disclosure { qr, .. }) => Some(qr), _ => None, } } pub fn open_delivery(&mut self, identity: PublicKey, reissue: bool) { let attempt = if reissue { FirstIssueAttempt::Initial } else { match self.first_retry { Some((key, FirstIssueRetry::Available)) if key == identity => { FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: false, } } Some((key, FirstIssueRetry::OverwriteConfirmationRequired)) if key == identity => { FirstIssueAttempt::Retry { confirm_possible_overwrite: true, confirm_manual_cleanup: false, } } Some((key, FirstIssueRetry::ManualCleanupRequired)) if key == identity => { FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: true, } } _ => FirstIssueAttempt::Initial, } }; self.active = Some(Form::DeliveryPath { identity, reissue, attempt, path: String::new(), include_qr: false, }); self.feedback = None; } pub fn note_first_issue(&mut self, identity: PublicKey, retry: Option) { self.first_retry = retry.map(|value| (identity, value)); } pub fn clear_delivery_state(&mut self) { self.first_retry = None; self.active = None; } pub fn open_delivery_confirmation(&mut self, summary: DeliveryDestinationSummary) { let mut description = format!( "Always publish the required client file at {}.", safe_form_text(&summary.path.display().to_string()) ); if summary.overwrite_required { description .push_str(" This exact existing file must be explicitly approved for replacement."); } if matches!( summary.attempt, FirstIssueAttempt::Retry { confirm_possible_overwrite: true, .. } ) { description.push_str( " The prior publication was uncertain; inspect the selected path before retrying.", ); } if matches!( summary.attempt, FirstIssueAttempt::Retry { confirm_manual_cleanup: true, .. } ) { description.push_str(" Inspect and manually remove any retained private temporary file before acknowledging cleanup."); } self.active = Some(Form::Confirm(Confirmation { title: "Confirm file delivery".to_owned(), description, affirmative: if summary.overwrite_required { "Replace this exact destination and deliver".to_owned() } else { "Deliver client file".to_owned() }, action: ConfirmAction::Delivery { summary }, })); self.feedback = None; } pub fn open_reissue_password( &mut self, identity: PublicKey, overwrite_confirmed: bool, prepared_generation: u64, ) { self.active = Some(Form::ReissuePassword { identity, password: SecretInput::new(), overwrite_confirmed, prepared_generation, }); self.feedback = None; } pub fn open_backups(&mut self, items: Vec) { self.active = Some(Form::Backups { items, selected: 0 }); self.feedback = None; } pub fn open_restore_confirmation(&mut self, summary: RestorePreviewSummary, has_draft: bool) { let changes = &summary.changes; let mut description = format!( "Archive {} created {} ({:?}, historical apply: {:?}). Full replacement; running VPN will not restart. Settings changed: {}; server configuration changed: {}; vault changed: {}; added peers: {}; removed peers: {}.{}", safe_form_text(&summary.file_name), safe_form_text(&summary.created_at_utc), summary.reason, summary.historical_apply, changes.settings_changed, changes.server_config_changed, changes.vault_changed, changes.added_clients.len(), changes.removed_clients.len(), if has_draft { " The current draft will be discarded." } else { "" } ); if !changes.added_clients.is_empty() { description.push_str(&format!( " Added full keys: {}.", changes .added_clients .iter() .map(ToString::to_string) .collect::>() .join(", ") )); } if !changes.removed_clients.is_empty() { description.push_str(&format!( " Removed full keys: {}.", changes .removed_clients .iter() .map(ToString::to_string) .collect::>() .join(", ") )); } if !changes.changed_clients.is_empty() { description.push_str(&format!( " Changed peer routes: {}.", changes .changed_clients .iter() .map(ToString::to_string) .collect::>() .join(", ") )); } if !changes.settings_fields_changed.is_empty() { description.push_str(&format!( " Changed settings: {}.", changes.settings_fields_changed.join(", ") )); } self.active = Some(Form::Restore(Confirmation { title: "Confirm full restore".to_owned(), description, affirmative: format!( "Restore exact archive {}", safe_form_text(&summary.file_name) ), action: ConfirmAction::ConfirmRestore { preview_generation: summary.preview_generation, }, })); self.feedback = None; } pub fn open_disclosure(&mut self, qr: QrCodeOutput, notice: String) { self.active = Some(Form::Disclosure { qr, notice: safe_form_text(¬ice), }); self.feedback = None; } pub fn open_setup(&mut self) { self.active = Some(Form::Setup { password: SecretInput::new(), confirmation: SecretInput::new(), confirmation_focused: false, }); self.feedback = None; } pub fn open_create(&mut self) { self.active = Some(Form::Create { alias: String::new(), ipv4: String::new(), ipv6: String::new(), field: 0, }); self.feedback = None; } pub fn open_rename(&mut self, identity: PublicKey, current_alias: &str) { self.active = Some(Form::Rename { identity, alias: current_alias.to_owned(), }); self.feedback = None; } pub fn open_rename_confirmation(&mut self, identity: PublicKey, alias: String) { self.open_confirmation(Confirmation { title: "Confirm alias change".to_owned(), description: format!("Change alias for peer {identity}?"), affirmative: format!("Rename peer {identity}"), action: ConfirmAction::Rename { identity, alias }, }); } pub fn open_import(&mut self) { self.active = Some(Form::Import { file_name: String::new(), }); self.feedback = None; } pub fn open_delete(&mut self, identity: PublicKey, label: &str) { self.open_confirmation(Confirmation { title: "Confirm deletion".to_owned(), description: format!( "Delete {} ({identity}) from the draft? Saved removal can leave runtime access active until restart, and the address remains reserved.", safe_form_text(label) ), affirmative: format!("Delete peer {identity}"), action: ConfirmAction::Delete(identity), }); } pub fn open_commit(&mut self) { self.open_confirmation(Confirmation { title: "Confirm Commit".to_owned(), description: "Commit writes a complete backup, then saves the draft files and vault. It does not change the running VPN.".to_owned(), affirmative: "Commit saved files only".to_owned(), action: ConfirmAction::Commit, }); } pub fn open_discard(&mut self) { self.open_confirmation(Confirmation { title: "Discard draft".to_owned(), description: "Discard all uncommitted client, metadata, and vault changes?".to_owned(), affirmative: "Discard draft".to_owned(), action: ConfirmAction::Discard, }); } pub fn open_exit_with_draft(&mut self) { self.open_confirmation(Confirmation { title: "Exit with draft changes".to_owned(), description: "The draft will be discarded when this session closes. Nothing will be committed." .to_owned(), affirmative: "Discard draft and exit".to_owned(), action: ConfirmAction::Exit, }); } pub fn open_restart(&mut self, preview: RestartPreviewSummary) { let (description, affirmative, choice) = if preview.has_draft { ( format!( "Restart {} using the saved configuration? The current draft will be discarded after the mandatory backup completes.", safe_form_text(&preview.interface) ), "Discard draft and restart".to_owned(), RestartChoice::DiscardDraftAndRestart, ) } else { ( format!( "Restart {} using the saved configuration? A complete backup will be created first.", safe_form_text(&preview.interface) ), "Restart saved state".to_owned(), RestartChoice::RestartSavedState, ) }; self.open_confirmation(Confirmation { title: "Explicit restart".to_owned(), description: format!( "{description}\nService: {}", safe_form_text(&preview.systemd_unit) ), affirmative, action: ConfirmAction::Restart(choice), }); } pub fn open_draft_review(&mut self) { self.active = Some(Form::DraftReview); self.feedback = None; } pub fn cancel(&mut self) { self.active = None; self.feedback = None; } pub fn feedback(&mut self, message: impl Into) { self.feedback = Some(message.into()); } pub fn display_lines(&self) -> Vec { let mut lines = match self.active.as_ref() { Some(Form::Setup { password, confirmation, confirmation_focused }) => vec![ "Create vault and scan configured client files".to_owned(), format!("Vault password: {}{}", if *confirmation_focused { " " } else { "> " }, password.masked()), format!("Confirm password: {}{}", if *confirmation_focused { "> " } else { " " }, confirmation.masked()), "Tab switches fields; Enter confirms a field; typing is masked.".to_owned(), ], Some(Form::Create { alias, ipv4, ipv6, field }) => vec![ "New dual-stack client".to_owned(), format!("{}Alias: {alias}", if *field == 0 { "> " } else { " " }), format!("{}Manual IPv4 (blank = automatic): {ipv4}", if *field == 1 { "> " } else { " " }), format!("{}Manual IPv6 (blank = automatic): {ipv6}", if *field == 2 { "> " } else { " " }), "Enter advances/submits; Tab changes field. Both manual addresses are required together.".to_owned(), ], Some(Form::Rename { identity, alias }) => vec![ format!("Rename peer {identity}"), format!("Alias (duplicates allowed): {alias}"), "Enter submits; Esc cancels without changing the draft.".to_owned(), ], Some(Form::Import { file_name }) => vec![ "Import one selected immediate *.conf entry".to_owned(), format!("Filename: {file_name}"), "Only the configured client directory is searched. Original files are preserved.".to_owned(), ], Some(Form::Confirm(confirm)) => vec![ confirm.title.clone(), confirm.description.clone(), format!("[y] {}", confirm.affirmative), "[n / Esc] Cancel".to_owned(), ], Some(Form::DraftReview) => vec![ "Semantic draft preview".to_owned(), "Client additions, removals, alias changes, and address changes are shown without configuration bytes.".to_owned(), "Esc returns without changing the draft.".to_owned(), ], Some(Form::DeliveryPath { identity, path, include_qr, .. }) => vec![ format!("Deliver client file for {identity}"), format!("Selected absolute destination: {path}"), format!("Optional QR after successful file: {} (Tab toggles)", if *include_qr { "yes" } else { "no" }), "Enter prepares this exact destination; source files are never used as output targets.".to_owned(), ], Some(Form::ReissuePassword { identity, password, .. }) => vec![ format!("Authorize reissue for {identity}"), format!("Password: {}", password.masked()), "This password is used for this action only and is not retained.".to_owned(), ], Some(Form::Backups { items, selected }) => { let mut lines = vec!["Manual backups: select an exact archive; Enter validates a restore preview.".to_owned()]; if items.is_empty() { lines.push("No archives are available.".to_owned()); } for (index, item) in items.iter().enumerate() { lines.push(format!("{} {} {} {:?} historical apply: {:?}", if index == *selected { ">" } else { " " }, safe_form_text(&item.file_name), safe_form_text(&item.created_at_utc), item.reason, item.historical_apply)); } lines } Some(Form::Restore(confirm)) => vec![confirm.title.clone(), confirm.description.clone(), format!("[y] {}", confirm.affirmative), "[n / Esc] Cancel without creating a backup".to_owned()], Some(Form::Disclosure { qr, notice }) => vec![format!("Client configuration QR disclosure ({} modules including quiet zone)", qr.side()), notice.clone(), "Esc closes and clears the QR.".to_owned()], None => Vec::new(), }; if let Some(feedback) = &self.feedback { lines.push(format!("Status: {}", safe_form_text(feedback))); } lines } pub fn handle_key(&mut self, code: KeyCode) -> FormAction { if code == KeyCode::Esc { let cancelled = match self.active.as_ref() { Some(Form::Restore(_)) => FormAction::CancelRestorePreview, Some(Form::Confirm(Confirmation { action: ConfirmAction::Delivery { .. }, .. })) | Some(Form::ReissuePassword { .. }) => FormAction::CancelPreparedDelivery, _ => FormAction::Cancel, }; self.cancel(); return cancelled; } let Some(form) = self.active.as_mut() else { return FormAction::None; }; match form { Form::Setup { password, confirmation, confirmation_focused, } => match code { KeyCode::Tab => *confirmation_focused = !*confirmation_focused, KeyCode::Backspace => { if *confirmation_focused { confirmation.pop(); } else { password.pop(); } } KeyCode::Enter if !*confirmation_focused => *confirmation_focused = true, KeyCode::Enter => { if password.is_empty() { return FormAction::Feedback( "Vault password must not be empty.".to_owned(), ); } if !password.matches(confirmation) { return FormAction::Feedback( "Vault password confirmation does not match.".to_owned(), ); } let password = std::mem::replace(password, SecretInput::new()); self.active = None; return FormAction::SubmitSensitive(SensitiveCommand::PrepareInitialSetup( password.into_secret(), )); } KeyCode::Char(character) => { if *confirmation_focused { confirmation.push(character); } else { password.push(character); } } _ => {} }, Form::Create { alias, ipv4, ipv6, field, } => match code { KeyCode::Tab => *field = (*field + 1) % 3, KeyCode::Backspace => { field_value_mut(*field, alias, ipv4, ipv6).pop(); } KeyCode::Enter if *field < 2 => *field += 1, KeyCode::Enter => { if alias.trim().is_empty() { return FormAction::Feedback("Alias is required.".to_owned()); } let manual_addresses = match (ipv4.trim().is_empty(), ipv6.trim().is_empty()) { (true, true) => None, (false, false) => match (ipv4.trim().parse::(), ipv6.trim().parse::()) { (Ok(ipv4), Ok(ipv6)) => Some(ClientAddresses { ipv4, ipv6: Some(ipv6) }), _ => return FormAction::Feedback("Enter a valid IPv4 and IPv6 address, or leave both blank for automatic allocation.".to_owned()), }, _ => return FormAction::Feedback("Manual addressing requires both IPv4 and IPv6 values.".to_owned()), }; let Some(created_utc) = format_creation_timestamp(time::OffsetDateTime::now_utc()) else { return FormAction::Feedback( "Could not prepare client creation time.".to_owned(), ); }; let command = UiCommand::CreateClient { alias: alias.trim().to_owned(), manual_addresses, created_utc: Some(created_utc), }; self.active = None; return FormAction::Submit(command); } KeyCode::Char(character) => { field_value_mut(*field, alias, ipv4, ipv6).push_bounded(character) } _ => {} }, Form::Rename { identity, alias } => match code { KeyCode::Backspace => { alias.pop(); } KeyCode::Enter => { if alias.trim().is_empty() { return FormAction::Feedback("Alias must not be empty.".to_owned()); } let identity = *identity; let alias = alias.trim().to_owned(); self.active = None; return FormAction::ConfirmRename { identity, alias }; } KeyCode::Char(character) if !character.is_control() => { alias.push_bounded(character) } _ => {} }, Form::Import { file_name } => match code { KeyCode::Backspace => { file_name.pop(); } KeyCode::Enter => { if file_name.trim().is_empty() { return FormAction::Feedback( "A configured-directory filename is required.".to_owned(), ); } let command = UiCommand::ImportSelectedFile { file_name: OsString::from(file_name.trim()), }; self.active = None; return FormAction::Submit(command); } KeyCode::Char(character) if !character.is_control() => { file_name.push_bounded(character) } _ => {} }, Form::DeliveryPath { identity, reissue, attempt, path, include_qr, } => match code { KeyCode::Tab => *include_qr = !*include_qr, KeyCode::Backspace => { path.pop(); } KeyCode::Enter => { let output_path = std::path::PathBuf::from(path.trim()); if !output_path.is_absolute() { return FormAction::Feedback( "Choose an absolute destination path.".to_owned(), ); } let command = UiCommand::PrepareDelivery { identity: *identity, path: output_path, include_qr: *include_qr, reissue: *reissue, attempt: *attempt, }; self.active = None; return FormAction::Submit(command); } KeyCode::Char(character) if !character.is_control() => path.push_bounded(character), _ => {} }, Form::ReissuePassword { identity, password, overwrite_confirmed, prepared_generation, } => match code { KeyCode::Backspace => password.pop(), KeyCode::Enter => { if password.is_empty() { return FormAction::Feedback( "Enter the selected client's vault password.".to_owned(), ); } let password = std::mem::replace(password, SecretInput::new()); let sensitive = SensitiveCommand::ReissueClient { identity: *identity, password: password.into_secret(), overwrite_confirmed: *overwrite_confirmed, prepared_generation: *prepared_generation, }; self.active = None; return FormAction::SubmitSensitive(sensitive); } KeyCode::Char(character) => password.push(character), _ => {} }, Form::Backups { items, selected } => match code { KeyCode::Up => *selected = selected.saturating_sub(1), KeyCode::Down if !items.is_empty() => { *selected = (*selected + 1).min(items.len() - 1) } KeyCode::Enter => { let Some(item) = items.get(*selected) else { return FormAction::Feedback("Select an archive first.".to_owned()); }; let command = UiCommand::PreviewRestore { file_name: item.file_name.clone(), }; self.active = None; return FormAction::Submit(command); } _ => {} }, Form::Restore(confirm) => match code { KeyCode::Char('y') | KeyCode::Enter => { let action = match &confirm.action { ConfirmAction::ConfirmRestore { preview_generation } => { FormAction::Submit(UiCommand::ConfirmRestore { preview_generation: *preview_generation, }) } _ => FormAction::None, }; self.active = None; return action; } KeyCode::Char('n') => { self.active = None; return FormAction::CancelRestorePreview; } _ => {} }, Form::Disclosure { .. } => {} Form::Confirm(confirm) => match code { KeyCode::Char('y') | KeyCode::Enter => { let action = match &confirm.action { ConfirmAction::Rename { identity, alias } => { FormAction::Submit(UiCommand::RenameClient { identity: *identity, alias: alias.clone(), }) } ConfirmAction::Delete(identity) => { FormAction::Submit(UiCommand::DeleteClient { identity: *identity, }) } ConfirmAction::Commit => FormAction::Submit(UiCommand::Commit), ConfirmAction::Discard => FormAction::Submit(UiCommand::DiscardDraft), ConfirmAction::Exit => FormAction::Exit, ConfirmAction::Restart(choice) => { FormAction::Submit(UiCommand::Restart(*choice)) } ConfirmAction::Delivery { summary } => { if summary.reissue { FormAction::StartReissue { identity: summary.identity, overwrite_confirmed: true, prepared_generation: summary.prepared_generation, } } else { FormAction::Submit(UiCommand::DeliverFirstIssue { attempt: summary.attempt, overwrite_confirmed: true, prepared_generation: summary.prepared_generation, }) } } ConfirmAction::ConfirmRestore { preview_generation } => { FormAction::Submit(UiCommand::ConfirmRestore { preview_generation: *preview_generation, }) } }; self.active = None; return action; } KeyCode::Char('n') => { let action = match &confirm.action { ConfirmAction::Delivery { .. } => FormAction::CancelPreparedDelivery, ConfirmAction::ConfirmRestore { .. } => FormAction::CancelRestorePreview, _ => FormAction::Cancel, }; self.cancel(); return action; } _ => {} }, Form::DraftReview => {} } FormAction::None } fn open_confirmation(&mut self, confirmation: Confirmation) { self.active = Some(Form::Confirm(confirmation)); self.feedback = None; } } fn field_value_mut<'a>( field: usize, alias: &'a mut String, ipv4: &'a mut String, ipv6: &'a mut String, ) -> &'a mut String { match field { 0 => alias, 1 => ipv4, _ => ipv6, } } fn format_creation_timestamp(now: time::OffsetDateTime) -> Option { now.replace_nanosecond(0) .ok()? .format(&time::format_description::well_known::Rfc3339) .ok() } trait BoundedString { fn push_bounded(&mut self, character: char); } impl BoundedString for String { fn push_bounded(&mut self, character: char) { if !character.is_control() && self.chars().count() < MAX_INPUT_CHARS { self.push(character); } } } pub fn safe_form_text(text: &str) -> String { text.chars() .flat_map(|character| { if character.is_control() { character .escape_default() .collect::() .chars() .collect::>() } else { vec![character] } }) .collect() } #[cfg(test)] mod tests { use super::*; use crate::backup::{BackupReason, HistoricalApplyStatus}; #[test] fn key_context_matches_tabbed_forms_and_inert_draft_review() { let mut form = FormController::default(); form.open_setup(); assert_eq!( form.key_context(), Some(FormKeyContext { enter_action: Some("next / submit"), supports_tab: true, supports_selection: false, }) ); form.open_create(); assert!(form.key_context().unwrap().supports_tab); assert_eq!( form.key_context().unwrap().enter_action, Some("next / submit") ); form.open_draft_review(); assert_eq!( form.key_context(), Some(FormKeyContext { enter_action: None, supports_tab: false, supports_selection: false, }) ); assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None)); } #[test] fn delivery_path_and_backup_selection_expose_their_actual_keys() { let identity = PublicKey::from_bytes([0x61; 32]); let mut form = FormController::default(); form.open_delivery(identity, false); assert!(form.key_context().unwrap().supports_tab); assert_eq!( form.key_context().unwrap().enter_action, Some("prepare file") ); form.open_backups(vec![ArchiveSummary { file_name: "backup.zip".to_owned(), created_at_utc: "2026-01-01T00:00:00Z".to_owned(), reason: BackupReason::Manual, historical_apply: HistoricalApplyStatus::Unknown, }]); assert_eq!( form.key_context(), Some(FormKeyContext { enter_action: Some("preview restore"), supports_tab: false, supports_selection: true, }) ); form.open_backups(Vec::new()); assert_eq!(form.key_context().unwrap().enter_action, None); } #[test] fn setup_password_is_masked_confirmation_required_and_secret_command_has_no_debug() { let mut form = FormController::default(); form.open_setup(); for ch in "correct horse".chars() { form.handle_key(KeyCode::Char(ch)); } assert!(form.display_lines()[1].contains("••••")); assert!(!form.display_lines()[1].contains("correct")); assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None)); for ch in "correct horse".chars() { form.handle_key(KeyCode::Char(ch)); } assert!(matches!( form.handle_key(KeyCode::Enter), FormAction::SubmitSensitive(_) )); assert!(safe_form_text("a\u{1b}[2J").contains("\\u{1b}")); } #[test] fn manual_addresses_require_both_valid_families_and_cancellation_preserves_no_submission() { let mut form = FormController::default(); form.open_create(); for ch in "device".chars() { form.handle_key(KeyCode::Char(ch)); } form.handle_key(KeyCode::Enter); for ch in "10.0.0.4".chars() { form.handle_key(KeyCode::Char(ch)); } form.handle_key(KeyCode::Enter); assert!(matches!( form.handle_key(KeyCode::Enter), FormAction::Feedback(_) )); assert!(matches!(form.handle_key(KeyCode::Esc), FormAction::Cancel)); } #[test] fn creation_timestamp_discards_fractional_seconds_and_renders_as_valid_metadata() { let instant = time::OffsetDateTime::from_unix_timestamp_nanos(1_700_000_000_123_456_789) .expect("fixed UTC instant is in range"); let timestamp = format_creation_timestamp(instant).expect("timestamp formats"); assert_eq!(timestamp, "2023-11-14T22:13:20Z"); assert!(crate::wireguard::metadata::render_block("device", Some(×tamp)).is_ok()); } #[test] fn rename_requires_full_key_confirmation_and_restart_exposes_exact_draft_choice() { let identity = PublicKey::from_bytes([0x5a; 32]); let identity_text = identity.to_string(); let mut form = FormController::default(); form.open_rename(identity, "old alias"); for _ in 0.."old alias".chars().count() { form.handle_key(KeyCode::Backspace); } for ch in "new alias".chars() { form.handle_key(KeyCode::Char(ch)); } assert!(matches!( form.handle_key(KeyCode::Enter), FormAction::ConfirmRename { .. } )); form.open_rename_confirmation(identity, "new alias".to_owned()); assert!( form.display_lines() .iter() .any(|line| line.contains(&identity_text)) ); assert!( matches!(form.handle_key(KeyCode::Char('y')), FormAction::Submit(UiCommand::RenameClient { identity: key, .. }) if key == identity) ); form.open_restart(RestartPreviewSummary { has_draft: true, interface: "wg-test".to_owned(), systemd_unit: "wg-quick@wg-test.service".to_owned(), }); assert!( form.display_lines() .iter() .any(|line| line.contains("Discard draft and restart")) ); assert!(matches!( form.handle_key(KeyCode::Char('y')), FormAction::Submit(UiCommand::Restart( crate::app::RestartChoice::DiscardDraftAndRestart )) )); } #[test] fn delivery_retry_requires_destination_approval_and_restore_cancel_only_clears_preview() { let identity = PublicKey::from_bytes([0x33; 32]); let mut form = FormController::default(); form.note_first_issue( identity, Some(FirstIssueRetry::OverwriteConfirmationRequired), ); form.open_delivery(identity, false); for ch in "/tmp/client.conf".chars() { form.handle_key(KeyCode::Char(ch)); } form.handle_key(KeyCode::Tab); let FormAction::Submit(UiCommand::PrepareDelivery { path, include_qr, attempt, .. }) = form.handle_key(KeyCode::Enter) else { panic!("path should prepare"); }; assert_eq!(path, std::path::PathBuf::from("/tmp/client.conf")); assert!(include_qr); assert_eq!( attempt, FirstIssueAttempt::Retry { confirm_possible_overwrite: true, confirm_manual_cleanup: false } ); form.open_delivery_confirmation(DeliveryDestinationSummary { identity, path: std::path::PathBuf::from("/tmp/client.conf"), overwrite_required: true, include_qr: true, reissue: false, attempt, prepared_generation: 4, }); assert!(matches!( form.handle_key(KeyCode::Char('n')), FormAction::CancelPreparedDelivery )); let summary = RestorePreviewSummary { file_name: "archive-1.tar".to_owned(), created_at_utc: "2026-10-21T00:00:00Z".to_owned(), reason: BackupReason::Manual, historical_apply: HistoricalApplyStatus::Unknown, changes: crate::app::RestoreChangeSummary { settings_changed: true, settings_fields_changed: vec!["DNS addresses"], server_config_changed: false, vault_changed: false, added_clients: vec![], removed_clients: vec![], changed_clients: vec![], }, preview_generation: 9, }; form.open_restore_confirmation(summary, true); assert!( form.display_lines() .iter() .any(|line| line.contains("current draft will be discarded")) ); assert!(matches!( form.handle_key(KeyCode::Esc), FormAction::CancelRestorePreview )); } #[test] fn reissue_password_is_masked_and_submitted_as_a_sensitive_owner() { let identity = PublicKey::from_bytes([0x44; 32]); let mut form = FormController::default(); form.open_reissue_password(identity, true, 7); for ch in "one action password".chars() { form.handle_key(KeyCode::Char(ch)); } assert!( !form .display_lines() .iter() .any(|line| line.contains("one action password")) ); assert!( matches!(form.handle_key(KeyCode::Enter), FormAction::SubmitSensitive(SensitiveCommand::ReissueClient { identity: found, overwrite_confirmed: true, prepared_generation: 7, .. }) if found == identity) ); } }