1095 lines
37 KiB
Rust
1095 lines
37 KiB
Rust
//! Keyboard-only presentation state for storage-owned write workflows.
|
|
|
|
use std::{fmt, num::NonZeroUsize};
|
|
|
|
use crossterm::event::{KeyCode, KeyModifiers};
|
|
use ironstorage::{
|
|
command::{
|
|
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
|
|
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest,
|
|
RemoveRequest,
|
|
},
|
|
crypto::KeyInfo,
|
|
otp::OtpInput,
|
|
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,
|
|
presentation: GeneratedPresentation,
|
|
focus: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct GrepForm {
|
|
pattern: String,
|
|
ignore_case: bool,
|
|
invert_match: bool,
|
|
line_number: bool,
|
|
fixed_strings: bool,
|
|
focus: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RemoveForm {
|
|
target: String,
|
|
recursive: bool,
|
|
force: bool,
|
|
confirmed: bool,
|
|
focus: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TransferForm {
|
|
source: String,
|
|
destination: String,
|
|
destination_directory: bool,
|
|
overwrite: bool,
|
|
focus: usize,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum OtpFormKind {
|
|
Insert,
|
|
Append,
|
|
Validate,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct OtpForm {
|
|
kind: OtpFormKind,
|
|
entry: String,
|
|
uri: SecretText,
|
|
force: bool,
|
|
confirmed: bool,
|
|
focus: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum WorkflowForm {
|
|
Init(InitForm),
|
|
Insert(InsertForm),
|
|
Generate(GenerateForm),
|
|
Grep(GrepForm),
|
|
Remove(RemoveForm),
|
|
Move(TransferForm),
|
|
Copy(TransferForm),
|
|
Otp(OtpForm),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum WorkflowSubmission {
|
|
Init(InitRequest),
|
|
Insert {
|
|
request: InsertRequest,
|
|
contents: InsertContent,
|
|
overwrite: OverwriteDecision,
|
|
},
|
|
Generate {
|
|
request: GenerateRequest,
|
|
overwrite: OverwriteDecision,
|
|
},
|
|
Grep(GrepRequest),
|
|
Remove(RemoveRequest),
|
|
Move {
|
|
request: MoveRequest,
|
|
overwrite: OverwriteDecision,
|
|
},
|
|
Copy {
|
|
request: CopyRequest,
|
|
overwrite: OverwriteDecision,
|
|
},
|
|
OtpInsert {
|
|
request: OtpInsertRequest,
|
|
input: OtpInput,
|
|
},
|
|
OtpAppend {
|
|
request: OtpAppendRequest,
|
|
input: OtpInput,
|
|
},
|
|
OtpValidate {
|
|
uri: OtpInput,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum WorkflowInput {
|
|
Consumed,
|
|
Cancel,
|
|
Lock,
|
|
Submit,
|
|
}
|
|
|
|
impl WorkflowForm {
|
|
pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
|
|
Self::Otp(OtpForm {
|
|
kind,
|
|
entry: entry.unwrap_or_default(),
|
|
uri: SecretText::default(),
|
|
force,
|
|
confirmed: false,
|
|
focus: 0,
|
|
})
|
|
}
|
|
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,
|
|
presentation: request.presentation,
|
|
focus: 0,
|
|
})
|
|
}
|
|
|
|
pub fn grep(request: Option<GrepRequest>) -> Self {
|
|
let request = request.unwrap_or(GrepRequest {
|
|
pattern: String::new(),
|
|
ignore_case: false,
|
|
invert_match: false,
|
|
line_number: true,
|
|
fixed_strings: false,
|
|
});
|
|
Self::Grep(GrepForm {
|
|
pattern: request.pattern,
|
|
ignore_case: request.ignore_case,
|
|
invert_match: request.invert_match,
|
|
line_number: request.line_number,
|
|
fixed_strings: request.fixed_strings,
|
|
focus: 0,
|
|
})
|
|
}
|
|
|
|
pub fn remove(request: Option<RemoveRequest>, selected: Option<(&str, bool)>) -> Self {
|
|
let request = request.unwrap_or_else(|| RemoveRequest {
|
|
entry: selected.map_or_else(String::new, |(path, _)| path.to_owned()),
|
|
recursive: selected.is_some_and(|(_, directory)| directory),
|
|
force: false,
|
|
});
|
|
Self::Remove(RemoveForm {
|
|
target: request.entry,
|
|
recursive: request.recursive,
|
|
force: request.force,
|
|
confirmed: false,
|
|
focus: 0,
|
|
})
|
|
}
|
|
|
|
pub fn move_entry(request: Option<MoveRequest>, selected: Option<&str>) -> Self {
|
|
let request = request.unwrap_or_else(|| MoveRequest {
|
|
source: selected.unwrap_or_default().to_owned(),
|
|
destination: String::new(),
|
|
force: false,
|
|
});
|
|
Self::Move(transfer_form(
|
|
request.source,
|
|
request.destination,
|
|
request.force,
|
|
))
|
|
}
|
|
|
|
pub fn copy_entry(request: Option<CopyRequest>, selected: Option<&str>) -> Self {
|
|
let request = request.unwrap_or_else(|| CopyRequest {
|
|
source: selected.unwrap_or_default().to_owned(),
|
|
destination: String::new(),
|
|
force: false,
|
|
});
|
|
Self::Copy(transfer_form(
|
|
request.source,
|
|
request.destination,
|
|
request.force,
|
|
))
|
|
}
|
|
|
|
pub fn title(&self) -> &'static str {
|
|
match self {
|
|
Self::Init(_) => "Initialize recipients",
|
|
Self::Insert(_) => "Insert entry",
|
|
Self::Generate(_) => "Generate password",
|
|
Self::Grep(_) => "Search decrypted entries",
|
|
Self::Remove(_) => "Remove entry or folder",
|
|
Self::Move(_) => "Move or rename",
|
|
Self::Copy(_) => "Copy entry or folder",
|
|
Self::Otp(form) => match form.kind {
|
|
OtpFormKind::Insert => "Insert OTP URI",
|
|
OtpFormKind::Append => "Append OTP URI",
|
|
OtpFormKind::Validate => "Validate OTP URI",
|
|
},
|
|
}
|
|
}
|
|
|
|
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)),
|
|
row(
|
|
form.focus == 5,
|
|
"Presentation",
|
|
generated_presentation_label(form.presentation),
|
|
),
|
|
],
|
|
Self::Grep(form) => vec![
|
|
row(form.focus == 0, "Pattern", &form.pattern),
|
|
row(form.focus == 1, "Ignore case", yes_no(form.ignore_case)),
|
|
row(form.focus == 2, "Invert match", yes_no(form.invert_match)),
|
|
row(form.focus == 3, "Line numbers", yes_no(form.line_number)),
|
|
row(form.focus == 4, "Fixed string", yes_no(form.fixed_strings)),
|
|
],
|
|
Self::Remove(form) => vec![
|
|
row(form.focus == 0, "Target", &form.target),
|
|
row(form.focus == 1, "Recursive folder", yes_no(form.recursive)),
|
|
row(form.focus == 2, "Force", yes_no(form.force)),
|
|
row(
|
|
form.focus == 3,
|
|
"Confirm permanent removal",
|
|
yes_no(form.confirmed),
|
|
),
|
|
],
|
|
Self::Move(form) | Self::Copy(form) => vec![
|
|
row(form.focus == 0, "Source", &form.source),
|
|
row(form.focus == 1, "Destination", &form.destination),
|
|
row(
|
|
form.focus == 2,
|
|
"Destination is existing directory",
|
|
yes_no(form.destination_directory),
|
|
),
|
|
row(
|
|
form.focus == 3,
|
|
"Overwrite collision",
|
|
yes_no(form.overwrite),
|
|
),
|
|
],
|
|
Self::Otp(form) => {
|
|
vec![
|
|
row(
|
|
form.focus == 0,
|
|
"Entry",
|
|
if form.kind == OtpFormKind::Validate {
|
|
"(validation only)"
|
|
} else if form.entry.is_empty() {
|
|
"(derive from URI)"
|
|
} else {
|
|
&form.entry
|
|
},
|
|
),
|
|
row(
|
|
form.focus == 1,
|
|
"OTP URI",
|
|
if form.uri.is_empty() {
|
|
"(empty)"
|
|
} else {
|
|
"••••••••"
|
|
},
|
|
),
|
|
row(
|
|
form.focus == 2,
|
|
"Force replacement",
|
|
if form.kind == OtpFormKind::Validate {
|
|
"(not used)"
|
|
} else {
|
|
yes_no(form.force)
|
|
},
|
|
),
|
|
row(
|
|
form.focus == 3,
|
|
"Confirm write",
|
|
if form.kind == OtpFormKind::Validate {
|
|
"(not required)"
|
|
} else {
|
|
yes_no(form.confirmed)
|
|
},
|
|
),
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
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: form.presentation,
|
|
},
|
|
overwrite: if form.overwrite || form.in_place {
|
|
OverwriteDecision::Allow
|
|
} else {
|
|
OverwriteDecision::Decline
|
|
},
|
|
})
|
|
}
|
|
Self::Grep(form) => {
|
|
if form.pattern.is_empty() {
|
|
return Err("Search pattern is required".to_owned());
|
|
}
|
|
Ok(WorkflowSubmission::Grep(GrepRequest {
|
|
pattern: form.pattern.clone(),
|
|
ignore_case: form.ignore_case,
|
|
invert_match: form.invert_match,
|
|
line_number: form.line_number,
|
|
fixed_strings: form.fixed_strings,
|
|
}))
|
|
}
|
|
Self::Remove(form) => {
|
|
if form.target.trim().is_empty() {
|
|
return Err("Removal target is required".to_owned());
|
|
}
|
|
if !form.confirmed {
|
|
return Err("Explicitly confirm permanent removal".to_owned());
|
|
}
|
|
Ok(WorkflowSubmission::Remove(RemoveRequest {
|
|
entry: form.target.trim().to_owned(),
|
|
recursive: form.recursive,
|
|
force: form.force,
|
|
}))
|
|
}
|
|
Self::Move(form) => {
|
|
let (source, destination, overwrite) = transfer_submission(form)?;
|
|
Ok(WorkflowSubmission::Move {
|
|
request: MoveRequest {
|
|
source,
|
|
destination,
|
|
force: form.overwrite,
|
|
},
|
|
overwrite,
|
|
})
|
|
}
|
|
Self::Copy(form) => {
|
|
let (source, destination, overwrite) = transfer_submission(form)?;
|
|
Ok(WorkflowSubmission::Copy {
|
|
request: CopyRequest {
|
|
source,
|
|
destination,
|
|
force: form.overwrite,
|
|
},
|
|
overwrite,
|
|
})
|
|
}
|
|
Self::Otp(form) => {
|
|
if form.uri.is_empty() {
|
|
return Err("OTP URI is required".to_owned());
|
|
}
|
|
let input = OtpInput::line(form.uri.bytes()).map_err(|error| error.to_string())?;
|
|
match form.kind {
|
|
OtpFormKind::Validate => Ok(WorkflowSubmission::OtpValidate { uri: input }),
|
|
OtpFormKind::Insert => {
|
|
if !form.confirmed {
|
|
return Err("Explicitly confirm the OTP write".to_owned());
|
|
}
|
|
Ok(WorkflowSubmission::OtpInsert {
|
|
request: OtpInsertRequest {
|
|
entry: (!form.entry.trim().is_empty())
|
|
.then(|| form.entry.trim().to_owned()),
|
|
force: form.force,
|
|
echo: false,
|
|
source: OtpInputSource::Uri,
|
|
},
|
|
input,
|
|
})
|
|
}
|
|
OtpFormKind::Append => {
|
|
if form.entry.trim().is_empty() {
|
|
return Err("Entry path is required".to_owned());
|
|
}
|
|
if !form.confirmed {
|
|
return Err("Explicitly confirm the OTP write".to_owned());
|
|
}
|
|
Ok(WorkflowSubmission::OtpAppend {
|
|
request: OtpAppendRequest {
|
|
entry: form.entry.trim().to_owned(),
|
|
force: form.force,
|
|
echo: false,
|
|
source: OtpInputSource::Uri,
|
|
},
|
|
input,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn focus_count(&self) -> usize {
|
|
match self {
|
|
Self::Init(form) => form.recipients.len() + 1,
|
|
Self::Insert(_) => 5,
|
|
Self::Generate(_) => 6,
|
|
Self::Grep(_) => 5,
|
|
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
|
|
Self::Otp(_) => 4,
|
|
}
|
|
}
|
|
|
|
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,
|
|
Self::Grep(form) => &mut form.focus,
|
|
Self::Remove(form) => &mut form.focus,
|
|
Self::Move(form) | Self::Copy(form) => &mut form.focus,
|
|
Self::Otp(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::Generate(form) if form.focus == 5 => {
|
|
form.presentation = match form.presentation {
|
|
GeneratedPresentation::Terminal => GeneratedPresentation::Clipboard,
|
|
GeneratedPresentation::Clipboard => GeneratedPresentation::QrCode,
|
|
GeneratedPresentation::QrCode => GeneratedPresentation::Terminal,
|
|
}
|
|
}
|
|
Self::Grep(form) if form.focus == 1 => form.ignore_case ^= true,
|
|
Self::Grep(form) if form.focus == 2 => form.invert_match ^= true,
|
|
Self::Grep(form) if form.focus == 3 => form.line_number ^= true,
|
|
Self::Grep(form) if form.focus == 4 => form.fixed_strings ^= true,
|
|
Self::Remove(form) if form.focus == 1 => form.recursive ^= true,
|
|
Self::Remove(form) if form.focus == 2 => form.force ^= true,
|
|
Self::Remove(form) if form.focus == 3 => form.confirmed ^= true,
|
|
Self::Move(form) | Self::Copy(form) if form.focus == 2 => {
|
|
form.destination_directory ^= true
|
|
}
|
|
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
|
|
Self::Otp(form) if form.focus == 2 => form.force ^= true,
|
|
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
|
|
_ => 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();
|
|
}
|
|
Self::Grep(form) if form.focus == 0 => {
|
|
form.pattern.pop();
|
|
}
|
|
Self::Remove(form) if form.focus == 0 => {
|
|
form.target.pop();
|
|
}
|
|
Self::Move(form) | Self::Copy(form) if form.focus == 0 => {
|
|
form.source.pop();
|
|
}
|
|
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
|
|
form.destination.pop();
|
|
}
|
|
Self::Otp(form) if form.focus == 0 => {
|
|
form.entry.pop();
|
|
}
|
|
Self::Otp(form) if form.focus == 1 => {
|
|
form.uri.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)
|
|
}
|
|
Self::Grep(form) if form.focus == 0 => form.pattern.push(character),
|
|
Self::Remove(form) if form.focus == 0 => form.target.push(character),
|
|
Self::Move(form) | Self::Copy(form) if form.focus == 0 => form.source.push(character),
|
|
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
|
|
form.destination.push(character)
|
|
}
|
|
Self::Otp(form) if form.focus == 0 => form.entry.push(character),
|
|
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn transfer_form(source: String, mut destination: String, overwrite: bool) -> TransferForm {
|
|
let destination_directory = destination.ends_with('/');
|
|
while destination.ends_with('/') {
|
|
destination.pop();
|
|
}
|
|
TransferForm {
|
|
source,
|
|
destination,
|
|
destination_directory,
|
|
overwrite,
|
|
focus: 0,
|
|
}
|
|
}
|
|
|
|
fn transfer_submission(form: &TransferForm) -> Result<(String, String, OverwriteDecision), String> {
|
|
if form.source.trim().is_empty() {
|
|
return Err("Source path is required".to_owned());
|
|
}
|
|
if form.destination.trim().is_empty() {
|
|
return Err("Destination path is required".to_owned());
|
|
}
|
|
let mut destination = form.destination.trim().to_owned();
|
|
if form.destination_directory {
|
|
destination.push('/');
|
|
}
|
|
Ok((
|
|
form.source.trim().to_owned(),
|
|
destination,
|
|
if form.overwrite {
|
|
OverwriteDecision::Allow
|
|
} else {
|
|
OverwriteDecision::Decline
|
|
},
|
|
))
|
|
}
|
|
|
|
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" }
|
|
}
|
|
|
|
fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static str {
|
|
match presentation {
|
|
GeneratedPresentation::Terminal => "masked viewer",
|
|
GeneratedPresentation::Clipboard => "clipboard with timeout",
|
|
GeneratedPresentation::QrCode => "terminal QR",
|
|
}
|
|
}
|
|
|
|
#[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"));
|
|
|
|
let qr = WorkflowForm::generate(Some(GenerateRequest {
|
|
entry: "entry".to_owned(),
|
|
length: None,
|
|
no_symbols: false,
|
|
force: false,
|
|
in_place: false,
|
|
presentation: GeneratedPresentation::QrCode,
|
|
}));
|
|
assert!(matches!(
|
|
qr.submission(),
|
|
Ok(WorkflowSubmission::Generate {
|
|
request: GenerateRequest {
|
|
presentation: GeneratedPresentation::QrCode,
|
|
..
|
|
},
|
|
..
|
|
})
|
|
));
|
|
}
|
|
|
|
#[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"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_and_mutation_forms_preserve_every_explicit_storage_choice() {
|
|
let grep = WorkflowForm::grep(Some(GrepRequest {
|
|
pattern: "literal.*value".to_owned(),
|
|
ignore_case: true,
|
|
invert_match: true,
|
|
line_number: true,
|
|
fixed_strings: true,
|
|
}));
|
|
assert!(matches!(
|
|
grep.submission(),
|
|
Ok(WorkflowSubmission::Grep(GrepRequest {
|
|
ignore_case: true,
|
|
invert_match: true,
|
|
line_number: true,
|
|
fixed_strings: true,
|
|
..
|
|
}))
|
|
));
|
|
|
|
let mut remove = WorkflowForm::remove(
|
|
Some(RemoveRequest {
|
|
entry: "nested/folder".to_owned(),
|
|
recursive: true,
|
|
force: true,
|
|
}),
|
|
None,
|
|
);
|
|
assert!(remove.submission().unwrap_err().contains("confirm"));
|
|
for _ in 0..3 {
|
|
remove.handle_key(KeyCode::Tab, KeyModifiers::NONE);
|
|
}
|
|
remove.handle_key(KeyCode::Char(' '), KeyModifiers::NONE);
|
|
assert!(matches!(
|
|
remove.submission(),
|
|
Ok(WorkflowSubmission::Remove(RemoveRequest {
|
|
recursive: true,
|
|
force: true,
|
|
..
|
|
}))
|
|
));
|
|
|
|
let copied = WorkflowForm::copy_entry(
|
|
Some(CopyRequest {
|
|
source: "source/entry".to_owned(),
|
|
destination: "existing/folder/".to_owned(),
|
|
force: true,
|
|
}),
|
|
None,
|
|
);
|
|
assert!(
|
|
copied
|
|
.rows()
|
|
.join("\n")
|
|
.contains("Destination is existing directory: yes")
|
|
);
|
|
assert!(matches!(
|
|
copied.submission(),
|
|
Ok(WorkflowSubmission::Copy {
|
|
request: CopyRequest {
|
|
ref destination,
|
|
force: true,
|
|
..
|
|
},
|
|
overwrite: OverwriteDecision::Allow,
|
|
}) if destination == "existing/folder/"
|
|
));
|
|
|
|
let moved = WorkflowForm::move_entry(
|
|
Some(MoveRequest {
|
|
source: "old".to_owned(),
|
|
destination: "new".to_owned(),
|
|
force: false,
|
|
}),
|
|
None,
|
|
);
|
|
assert!(matches!(
|
|
moved.submission(),
|
|
Ok(WorkflowSubmission::Move {
|
|
overwrite: OverwriteDecision::Decline,
|
|
..
|
|
})
|
|
));
|
|
}
|
|
}
|