Implement TUI init insert and generate workflows
This commit is contained in:
570
apps/tui/src/workflow.rs
Normal file
570
apps/tui/src/workflow.rs
Normal file
@@ -0,0 +1,570 @@
|
||||
//! Keyboard-only presentation state for storage-owned write workflows.
|
||||
|
||||
use std::{fmt, num::NonZeroUsize};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use ironstorage::{
|
||||
command::{GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest},
|
||||
crypto::KeyInfo,
|
||||
write::{InsertContent, OverwriteDecision},
|
||||
};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum InsertMode {
|
||||
Hidden,
|
||||
Echoed,
|
||||
Multiline,
|
||||
}
|
||||
|
||||
impl InsertMode {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Hidden => Self::Echoed,
|
||||
Self::Echoed => Self::Multiline,
|
||||
Self::Multiline => Self::Hidden,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hidden => "single line (confirmed)",
|
||||
Self::Echoed => "single line",
|
||||
Self::Multiline => "multiline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SecretText {
|
||||
characters: Vec<char>,
|
||||
}
|
||||
|
||||
impl SecretText {
|
||||
fn push(&mut self, character: char) {
|
||||
self.characters.push(character);
|
||||
}
|
||||
|
||||
fn pop(&mut self) {
|
||||
self.characters.pop();
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Vec<u8> {
|
||||
self.characters.iter().collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.characters.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SecretText {
|
||||
fn drop(&mut self) {
|
||||
self.characters.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretText {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("SecretText([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct RecipientChoice {
|
||||
identity: String,
|
||||
label: String,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InitForm {
|
||||
path: String,
|
||||
recipients: Vec<RecipientChoice>,
|
||||
focus: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InsertForm {
|
||||
entry: String,
|
||||
mode: InsertMode,
|
||||
secret: SecretText,
|
||||
confirmation: SecretText,
|
||||
overwrite: bool,
|
||||
focus: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GenerateForm {
|
||||
entry: String,
|
||||
length: String,
|
||||
no_symbols: bool,
|
||||
overwrite: bool,
|
||||
in_place: bool,
|
||||
focus: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WorkflowForm {
|
||||
Init(InitForm),
|
||||
Insert(InsertForm),
|
||||
Generate(GenerateForm),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WorkflowSubmission {
|
||||
Init(InitRequest),
|
||||
Insert {
|
||||
request: InsertRequest,
|
||||
contents: InsertContent,
|
||||
overwrite: OverwriteDecision,
|
||||
},
|
||||
Generate {
|
||||
request: GenerateRequest,
|
||||
overwrite: OverwriteDecision,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum WorkflowInput {
|
||||
Consumed,
|
||||
Cancel,
|
||||
Lock,
|
||||
Submit,
|
||||
}
|
||||
|
||||
impl WorkflowForm {
|
||||
pub fn init(keys: &[KeyInfo], default: Option<&KeyInfo>, request: Option<InitRequest>) -> Self {
|
||||
let request = request.unwrap_or_else(|| InitRequest {
|
||||
path: None,
|
||||
key_identities: default
|
||||
.map(|key| vec![key.fingerprint().to_string()])
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
let mut recipients = keys
|
||||
.iter()
|
||||
.filter(|key| key.can_encrypt())
|
||||
.map(|key| {
|
||||
let identity = key.fingerprint().to_string();
|
||||
let user = key
|
||||
.user_ids()
|
||||
.first()
|
||||
.map_or("unknown identity", String::as_str);
|
||||
RecipientChoice {
|
||||
selected: request.key_identities.iter().any(|item| item == &identity),
|
||||
label: format!("{} — {user}", key.key_id()),
|
||||
identity,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for identity in &request.key_identities {
|
||||
if !recipients.iter().any(|choice| &choice.identity == identity) {
|
||||
recipients.push(RecipientChoice {
|
||||
identity: identity.clone(),
|
||||
label: format!("requested identity {identity}"),
|
||||
selected: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Self::Init(InitForm {
|
||||
path: request.path.unwrap_or_default(),
|
||||
recipients,
|
||||
focus: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert(request: Option<InsertRequest>) -> Self {
|
||||
let request = request.unwrap_or(InsertRequest {
|
||||
entry: String::new(),
|
||||
input: InsertInput::HiddenConfirmed,
|
||||
force: false,
|
||||
});
|
||||
let mode = match request.input {
|
||||
InsertInput::HiddenConfirmed => InsertMode::Hidden,
|
||||
InsertInput::EchoedLine => InsertMode::Echoed,
|
||||
InsertInput::Multiline => InsertMode::Multiline,
|
||||
};
|
||||
Self::Insert(InsertForm {
|
||||
entry: request.entry,
|
||||
mode,
|
||||
secret: SecretText::default(),
|
||||
confirmation: SecretText::default(),
|
||||
overwrite: request.force,
|
||||
focus: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate(request: Option<GenerateRequest>) -> Self {
|
||||
let request = request.unwrap_or(GenerateRequest {
|
||||
entry: String::new(),
|
||||
length: None,
|
||||
no_symbols: false,
|
||||
force: false,
|
||||
in_place: false,
|
||||
presentation: GeneratedPresentation::Terminal,
|
||||
});
|
||||
Self::Generate(GenerateForm {
|
||||
entry: request.entry,
|
||||
length: request
|
||||
.length
|
||||
.map_or_else(String::new, |length| length.to_string()),
|
||||
no_symbols: request.no_symbols,
|
||||
overwrite: request.force,
|
||||
in_place: request.in_place,
|
||||
focus: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Init(_) => "Initialize recipients",
|
||||
Self::Insert(_) => "Insert entry",
|
||||
Self::Generate(_) => "Generate password",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Init(form) => {
|
||||
let mut rows = vec![row(
|
||||
form.focus == 0,
|
||||
"Path",
|
||||
if form.path.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
&form.path
|
||||
},
|
||||
)];
|
||||
rows.push("Recipients (Space toggles):".to_owned());
|
||||
rows.extend(form.recipients.iter().enumerate().map(|(index, choice)| {
|
||||
row(
|
||||
form.focus == index + 1,
|
||||
if choice.selected { "[x]" } else { "[ ]" },
|
||||
&choice.label,
|
||||
)
|
||||
}));
|
||||
rows
|
||||
}
|
||||
Self::Insert(form) => vec![
|
||||
row(form.focus == 0, "Entry", &form.entry),
|
||||
row(form.focus == 1, "Input", form.mode.label()),
|
||||
row(
|
||||
form.focus == 2,
|
||||
"Secret",
|
||||
if form.secret.is_empty() {
|
||||
"(empty)"
|
||||
} else {
|
||||
"••••••••"
|
||||
},
|
||||
),
|
||||
row(
|
||||
form.focus == 3,
|
||||
"Confirm",
|
||||
if form.mode != InsertMode::Hidden {
|
||||
"(not required)"
|
||||
} else if form.confirmation.is_empty() {
|
||||
"(empty)"
|
||||
} else {
|
||||
"••••••••"
|
||||
},
|
||||
),
|
||||
row(form.focus == 4, "Overwrite", yes_no(form.overwrite)),
|
||||
],
|
||||
Self::Generate(form) => vec![
|
||||
row(form.focus == 0, "Entry", &form.entry),
|
||||
row(
|
||||
form.focus == 1,
|
||||
"Length",
|
||||
if form.length.is_empty() {
|
||||
"pass default"
|
||||
} else {
|
||||
&form.length
|
||||
},
|
||||
),
|
||||
row(form.focus == 2, "Symbols", yes_no(!form.no_symbols)),
|
||||
row(form.focus == 3, "Overwrite", yes_no(form.overwrite)),
|
||||
row(form.focus == 4, "In place", yes_no(form.in_place)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> WorkflowInput {
|
||||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return match code {
|
||||
KeyCode::Char('s') => WorkflowInput::Submit,
|
||||
KeyCode::Char('l' | 'z') => WorkflowInput::Lock,
|
||||
_ => WorkflowInput::Consumed,
|
||||
};
|
||||
}
|
||||
match code {
|
||||
KeyCode::Esc => return WorkflowInput::Cancel,
|
||||
KeyCode::Tab | KeyCode::Down => self.move_focus(true),
|
||||
KeyCode::BackTab | KeyCode::Up => self.move_focus(false),
|
||||
KeyCode::Char(' ') => self.space(),
|
||||
KeyCode::Enter => self.enter(),
|
||||
KeyCode::Backspace => self.backspace(),
|
||||
KeyCode::Char(character) if !character.is_control() => self.character(character),
|
||||
_ => {}
|
||||
}
|
||||
WorkflowInput::Consumed
|
||||
}
|
||||
|
||||
pub fn submission(&self) -> Result<WorkflowSubmission, String> {
|
||||
match self {
|
||||
Self::Init(form) => {
|
||||
let key_identities = form
|
||||
.recipients
|
||||
.iter()
|
||||
.filter(|choice| choice.selected)
|
||||
.map(|choice| choice.identity.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if key_identities.is_empty() {
|
||||
return Err("Select at least one encryption recipient".to_owned());
|
||||
}
|
||||
Ok(WorkflowSubmission::Init(InitRequest {
|
||||
path: (!form.path.trim().is_empty()).then(|| form.path.trim().to_owned()),
|
||||
key_identities,
|
||||
}))
|
||||
}
|
||||
Self::Insert(form) => {
|
||||
if form.entry.trim().is_empty() {
|
||||
return Err("Entry path is required".to_owned());
|
||||
}
|
||||
let (input, contents) = match form.mode {
|
||||
InsertMode::Hidden => (
|
||||
InsertInput::HiddenConfirmed,
|
||||
InsertContent::hidden(form.secret.bytes(), form.confirmation.bytes()),
|
||||
),
|
||||
InsertMode::Echoed => (
|
||||
InsertInput::EchoedLine,
|
||||
InsertContent::echoed(form.secret.bytes()),
|
||||
),
|
||||
InsertMode::Multiline => (
|
||||
InsertInput::Multiline,
|
||||
Ok(InsertContent::multiline(form.secret.bytes())),
|
||||
),
|
||||
};
|
||||
Ok(WorkflowSubmission::Insert {
|
||||
request: InsertRequest {
|
||||
entry: form.entry.trim().to_owned(),
|
||||
input,
|
||||
force: form.overwrite,
|
||||
},
|
||||
contents: contents.map_err(|error| error.to_string())?,
|
||||
overwrite: if form.overwrite {
|
||||
OverwriteDecision::Allow
|
||||
} else {
|
||||
OverwriteDecision::Decline
|
||||
},
|
||||
})
|
||||
}
|
||||
Self::Generate(form) => {
|
||||
if form.entry.trim().is_empty() {
|
||||
return Err("Entry path is required".to_owned());
|
||||
}
|
||||
let length = if form.length.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
form.length
|
||||
.parse::<NonZeroUsize>()
|
||||
.map_err(|_| "Length must be a positive integer".to_owned())?,
|
||||
)
|
||||
};
|
||||
Ok(WorkflowSubmission::Generate {
|
||||
request: GenerateRequest {
|
||||
entry: form.entry.trim().to_owned(),
|
||||
length,
|
||||
no_symbols: form.no_symbols,
|
||||
force: form.overwrite,
|
||||
in_place: form.in_place,
|
||||
presentation: GeneratedPresentation::Terminal,
|
||||
},
|
||||
overwrite: if form.overwrite || form.in_place {
|
||||
OverwriteDecision::Allow
|
||||
} else {
|
||||
OverwriteDecision::Decline
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Init(form) => form.recipients.len() + 1,
|
||||
Self::Insert(_) | Self::Generate(_) => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn move_focus(&mut self, forward: bool) {
|
||||
let count = self.focus_count();
|
||||
let focus = match self {
|
||||
Self::Init(form) => &mut form.focus,
|
||||
Self::Insert(form) => &mut form.focus,
|
||||
Self::Generate(form) => &mut form.focus,
|
||||
};
|
||||
*focus = if forward {
|
||||
(*focus + 1) % count
|
||||
} else {
|
||||
(*focus + count - 1) % count
|
||||
};
|
||||
}
|
||||
|
||||
fn space(&mut self) {
|
||||
match self {
|
||||
Self::Init(form) if form.focus > 0 => form.recipients[form.focus - 1].selected ^= true,
|
||||
Self::Insert(form) if form.focus == 1 => form.mode = form.mode.next(),
|
||||
Self::Insert(form) if form.focus == 4 => form.overwrite ^= true,
|
||||
Self::Generate(form) if form.focus == 2 => form.no_symbols ^= true,
|
||||
Self::Generate(form) if form.focus == 3 => {
|
||||
form.overwrite ^= true;
|
||||
if form.overwrite {
|
||||
form.in_place = false;
|
||||
}
|
||||
}
|
||||
Self::Generate(form) if form.focus == 4 => {
|
||||
form.in_place ^= true;
|
||||
if form.in_place {
|
||||
form.overwrite = false;
|
||||
}
|
||||
}
|
||||
_ => self.character(' '),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter(&mut self) {
|
||||
match self {
|
||||
Self::Insert(form) if form.focus == 2 && form.mode == InsertMode::Multiline => {
|
||||
form.secret.push('\n')
|
||||
}
|
||||
_ => self.space(),
|
||||
}
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
match self {
|
||||
Self::Init(form) if form.focus == 0 => {
|
||||
form.path.pop();
|
||||
}
|
||||
Self::Insert(form) if form.focus == 0 => {
|
||||
form.entry.pop();
|
||||
}
|
||||
Self::Insert(form) if form.focus == 2 => form.secret.pop(),
|
||||
Self::Insert(form) if form.focus == 3 && form.mode == InsertMode::Hidden => {
|
||||
form.confirmation.pop()
|
||||
}
|
||||
Self::Generate(form) if form.focus == 0 => {
|
||||
form.entry.pop();
|
||||
}
|
||||
Self::Generate(form) if form.focus == 1 => {
|
||||
form.length.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn character(&mut self, character: char) {
|
||||
match self {
|
||||
Self::Init(form) if form.focus == 0 => form.path.push(character),
|
||||
Self::Insert(form) if form.focus == 0 => form.entry.push(character),
|
||||
Self::Insert(form)
|
||||
if form.focus == 2 && (form.mode == InsertMode::Multiline || character != '\n') =>
|
||||
{
|
||||
form.secret.push(character)
|
||||
}
|
||||
Self::Insert(form)
|
||||
if form.focus == 3 && form.mode == InsertMode::Hidden && character != '\n' =>
|
||||
{
|
||||
form.confirmation.push(character)
|
||||
}
|
||||
Self::Generate(form) if form.focus == 0 => form.entry.push(character),
|
||||
Self::Generate(form) if form.focus == 1 && character.is_ascii_digit() => {
|
||||
form.length.push(character)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn row(focused: bool, label: &str, value: &str) -> String {
|
||||
format!("{} {label}: {value}", if focused { ">" } else { " " })
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> &'static str {
|
||||
if value { "yes" } else { "no" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn type_text(form: &mut WorkflowForm, text: &str) {
|
||||
for character in text.chars() {
|
||||
form.handle_key(KeyCode::Char(character), KeyModifiers::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
|
||||
let mut form = WorkflowForm::insert(None);
|
||||
type_text(&mut form, "nested/account");
|
||||
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
type_text(&mut form, "do-not-display");
|
||||
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
type_text(&mut form, "do-not-display");
|
||||
let rendered = form.rows().join("\n");
|
||||
let debug = format!("{form:?}");
|
||||
assert!(!rendered.contains("do-not-display"));
|
||||
assert!(!debug.contains("do-not-display"));
|
||||
assert!(matches!(
|
||||
form.submission(),
|
||||
Ok(WorkflowSubmission::Insert { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_and_generation_validation_are_storage_compatible() {
|
||||
let mut insert = WorkflowForm::insert(None);
|
||||
type_text(&mut insert, "entry");
|
||||
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
type_text(&mut insert, "first");
|
||||
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
type_text(&mut insert, "second");
|
||||
assert!(insert.submission().unwrap_err().contains("match"));
|
||||
|
||||
let mut generate = WorkflowForm::generate(None);
|
||||
type_text(&mut generate, "entry");
|
||||
generate.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
assert!(generate.submission().is_ok());
|
||||
type_text(&mut generate, "0");
|
||||
assert!(generate.submission().unwrap_err().contains("positive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_input_and_generate_options_are_preserved() {
|
||||
let mut insert = WorkflowForm::insert(Some(InsertRequest {
|
||||
entry: "notes/item".into(),
|
||||
input: InsertInput::Multiline,
|
||||
force: true,
|
||||
}));
|
||||
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
insert.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
||||
type_text(&mut insert, "first");
|
||||
insert.handle_key(KeyCode::Enter, KeyModifiers::NONE);
|
||||
type_text(&mut insert, "second");
|
||||
match insert.submission().expect("submission") {
|
||||
WorkflowSubmission::Insert {
|
||||
contents,
|
||||
overwrite,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(contents.expose(), b"first\nsecond");
|
||||
assert_eq!(overwrite, OverwriteDecision::Allow);
|
||||
}
|
||||
_ => panic!("wrong submission"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user