1207 lines
44 KiB
Rust
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(¬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<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(×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)
|
|
);
|
|
}
|
|
}
|