Files
wireguard-admin/src/tui/forms.rs
T
2026-10-27 20:42:12 +02:00

1207 lines
44 KiB
Rust

//! 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<ArchiveSummary>,
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<String>);
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<Form>,
feedback: Option<String>,
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<FormKeyContext> {
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<FirstIssueRetry>) {
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<ArchiveSummary>) {
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::<Vec<_>>()
.join(", ")
));
}
if !changes.removed_clients.is_empty() {
description.push_str(&format!(
" Removed full keys: {}.",
changes
.removed_clients
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
if !changes.changed_clients.is_empty() {
description.push_str(&format!(
" Changed peer routes: {}.",
changes
.changed_clients
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.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(&notice),
});
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<String>) {
self.feedback = Some(message.into());
}
pub fn display_lines(&self) -> Vec<String> {
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::<Ipv4Addr>(), ipv6.trim().parse::<Ipv6Addr>()) {
(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<String> {
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::<String>()
.chars()
.collect::<Vec<_>>()
} 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(&timestamp)).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)
);
}
}