1188 lines
36 KiB
Rust
1188 lines
36 KiB
Rust
//! Editable, shell-free `:` command input over the shared storage command contract.
|
|
|
|
use std::{error::Error, fmt};
|
|
|
|
use ironstorage::command::{CliAction, CommandRequest, GitRequest, OtpRequest, parse_from};
|
|
|
|
use crate::action::{ACTIONS, Action};
|
|
|
|
const HISTORY_LIMIT: usize = 100;
|
|
|
|
const ROOT_COMMANDS: &[&str] = &[
|
|
"init",
|
|
"list",
|
|
"show",
|
|
"find",
|
|
"grep",
|
|
"insert",
|
|
"edit",
|
|
"generate",
|
|
"remove",
|
|
"move",
|
|
"copy",
|
|
"import-kdbx",
|
|
"git",
|
|
"otp",
|
|
"lock",
|
|
"unlock",
|
|
"help",
|
|
"version",
|
|
"quit",
|
|
];
|
|
const GIT_COMMANDS: &[&str] = &[
|
|
"init",
|
|
"status",
|
|
"log",
|
|
"diff",
|
|
"add",
|
|
"commit",
|
|
"remote",
|
|
"config",
|
|
"fetch",
|
|
"pull",
|
|
"push",
|
|
"sync",
|
|
"resolve-local",
|
|
"resolve-remote",
|
|
];
|
|
const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"];
|
|
const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"];
|
|
const HELP_TOPICS: &[&str] = &[
|
|
"init",
|
|
"list",
|
|
"show",
|
|
"find",
|
|
"grep",
|
|
"insert",
|
|
"edit",
|
|
"generate",
|
|
"remove",
|
|
"move",
|
|
"copy",
|
|
"import-kdbx",
|
|
"git",
|
|
"otp",
|
|
];
|
|
|
|
/// Auditable mapping from every milestone-01 command family to the TUI command surface.
|
|
pub const COMMAND_COVERAGE: &[CommandCoverage] = &[
|
|
coverage("initialize recipients", ":init GPG-ID…"),
|
|
coverage("list a directory", ":list [PATH]"),
|
|
coverage("show an entry", ":show [OPTIONS] [ENTRY]"),
|
|
coverage("find names", ":find TERM…"),
|
|
coverage("grep decrypted entries", ":grep [OPTIONS] PATTERN"),
|
|
coverage("insert an entry", ":insert [OPTIONS] ENTRY"),
|
|
coverage("edit an entry", ":edit ENTRY"),
|
|
coverage("generate a password", ":generate [OPTIONS] ENTRY [LENGTH]"),
|
|
coverage("remove entries or directories", ":remove [OPTIONS] ENTRY"),
|
|
coverage(
|
|
"move entries or directories",
|
|
":move [OPTIONS] SOURCE DESTINATION",
|
|
),
|
|
coverage(
|
|
"copy entries or directories",
|
|
":copy [OPTIONS] SOURCE DESTINATION",
|
|
),
|
|
coverage("import a KeePass database", ":import-kdbx [OPTIONS] SOURCE"),
|
|
coverage("initialize Git", ":git init"),
|
|
coverage("show Git status", ":git status"),
|
|
coverage("show Git log", ":git log [OPTIONS]"),
|
|
coverage("show Git diff", ":git diff [PATH]…"),
|
|
coverage("stage Git paths", ":git add PATH…"),
|
|
coverage("create a Git commit", ":git commit -m MESSAGE"),
|
|
coverage("manage Git remotes", ":git remote [COMMAND]"),
|
|
coverage(
|
|
"manage Git configuration",
|
|
":git config (--get KEY | KEY VALUE)",
|
|
),
|
|
coverage("fetch Git remote", ":git fetch [REMOTE]"),
|
|
coverage("pull Git remote", ":git pull [REMOTE] [BRANCH]"),
|
|
coverage("push Git remote", ":git push [REMOTE] [BRANCH]"),
|
|
coverage("synchronize Git remote", ":git sync [REMOTE]"),
|
|
coverage("resolve Git conflicts locally", ":git resolve-local"),
|
|
coverage("resolve Git conflicts remotely", ":git resolve-remote"),
|
|
coverage("generate an OTP code", ":otp code [OPTIONS] ENTRY"),
|
|
coverage("insert an OTP entry", ":otp insert [OPTIONS] [ENTRY]"),
|
|
coverage("append OTP data", ":otp append [OPTIONS] ENTRY"),
|
|
coverage("present an OTP URI", ":otp uri [OPTIONS] ENTRY"),
|
|
coverage("validate an OTP URI", ":otp validate URI"),
|
|
coverage("show pass-otp version", ":otp version"),
|
|
coverage("show help", ":help [TOPIC]"),
|
|
coverage("show version", ":version"),
|
|
coverage("lock the TUI", ":lock"),
|
|
coverage("unlock the TUI", ":unlock"),
|
|
];
|
|
|
|
/// Executable feature matrix for the complete upstream pass and pass-otp
|
|
/// surface. `None` means the command prompt is the intentional keyboard UI;
|
|
/// otherwise the action registry supplies the default hotkey and help entry.
|
|
pub const TUI_COVERAGE: &[TuiCoverage] = &[
|
|
tui(
|
|
"initialize recipients",
|
|
"recipient form",
|
|
Some(Action::Initialize),
|
|
":init GPG-ID…",
|
|
),
|
|
tui(
|
|
"list a directory",
|
|
"password tree",
|
|
Some(Action::Activate),
|
|
":list [PATH]",
|
|
),
|
|
tui(
|
|
"show an entry",
|
|
"structured viewer",
|
|
Some(Action::Activate),
|
|
":show [ENTRY]",
|
|
),
|
|
tui(
|
|
"copy a selected entry line",
|
|
"clipboard feedback",
|
|
Some(Action::Copy),
|
|
":show --clip [LINE] ENTRY",
|
|
),
|
|
tui(
|
|
"show a selected entry line as QR",
|
|
"resizable QR popup",
|
|
None,
|
|
":show --qrcode [LINE] ENTRY",
|
|
),
|
|
tui(
|
|
"find names",
|
|
"incremental tree filter",
|
|
Some(Action::Filter),
|
|
":find TERM…",
|
|
),
|
|
tui(
|
|
"grep decrypted entries",
|
|
"decrypted result pane",
|
|
Some(Action::Grep),
|
|
":grep [OPTIONS] PATTERN",
|
|
),
|
|
tui(
|
|
"insert an entry",
|
|
"masked insert form",
|
|
Some(Action::InsertEntry),
|
|
":insert [OPTIONS] ENTRY",
|
|
),
|
|
tui(
|
|
"edit an entry",
|
|
"structured editor",
|
|
Some(Action::EditEntry),
|
|
":edit ENTRY",
|
|
),
|
|
tui(
|
|
"generate a password",
|
|
"generation form",
|
|
Some(Action::GenerateEntry),
|
|
":generate [OPTIONS] ENTRY [LENGTH]",
|
|
),
|
|
tui(
|
|
"present a generated password",
|
|
"generation presentation selector",
|
|
Some(Action::GenerateEntry),
|
|
":generate (--clip | --qrcode) ENTRY [LENGTH]",
|
|
),
|
|
tui(
|
|
"remove entries or directories",
|
|
"confirmed removal form",
|
|
Some(Action::RemoveEntry),
|
|
":remove [OPTIONS] ENTRY",
|
|
),
|
|
tui(
|
|
"move entries or directories",
|
|
"move form",
|
|
Some(Action::MoveEntry),
|
|
":move [OPTIONS] SOURCE DESTINATION",
|
|
),
|
|
tui(
|
|
"copy entries or directories",
|
|
"copy form",
|
|
Some(Action::CopyEntry),
|
|
":copy [OPTIONS] SOURCE DESTINATION",
|
|
),
|
|
tui(
|
|
"import a KeePass database",
|
|
"KDBX import form",
|
|
Some(Action::ImportKdbx),
|
|
":import-kdbx [OPTIONS] SOURCE",
|
|
),
|
|
tui("initialize Git", "Git dashboard", None, ":git init"),
|
|
tui("show Git status", "Git dashboard", None, ":git status"),
|
|
tui("show Git log", "Git dashboard", None, ":git log [OPTIONS]"),
|
|
tui(
|
|
"show Git diff",
|
|
"zeroizing Git detail pane",
|
|
None,
|
|
":git diff [PATH]…",
|
|
),
|
|
tui("stage Git paths", "Git dashboard", None, ":git add PATH…"),
|
|
tui(
|
|
"create a Git commit",
|
|
"Git dashboard",
|
|
None,
|
|
":git commit -m MESSAGE",
|
|
),
|
|
tui(
|
|
"manage Git remotes",
|
|
"Git dashboard",
|
|
None,
|
|
":git remote [COMMAND]",
|
|
),
|
|
tui(
|
|
"manage Git configuration",
|
|
"Git dashboard",
|
|
None,
|
|
":git config (--get KEY | KEY VALUE)",
|
|
),
|
|
tui(
|
|
"fetch Git remote",
|
|
"Git progress view",
|
|
None,
|
|
":git fetch [REMOTE]",
|
|
),
|
|
tui(
|
|
"pull Git remote",
|
|
"Git progress view",
|
|
Some(Action::GitPull),
|
|
":git pull [REMOTE] [BRANCH]",
|
|
),
|
|
tui(
|
|
"push Git remote",
|
|
"Git progress view",
|
|
Some(Action::GitPush),
|
|
":git push [REMOTE] [BRANCH]",
|
|
),
|
|
tui(
|
|
"synchronize Git remote",
|
|
"Git progress view",
|
|
None,
|
|
":git sync [REMOTE]",
|
|
),
|
|
tui(
|
|
"resolve Git conflicts locally",
|
|
"Git conflict view",
|
|
None,
|
|
":git resolve-local",
|
|
),
|
|
tui(
|
|
"resolve Git conflicts remotely",
|
|
"Git conflict view",
|
|
None,
|
|
":git resolve-remote",
|
|
),
|
|
tui(
|
|
"generate an OTP code",
|
|
"focused OTP field",
|
|
Some(Action::OtpCode),
|
|
":otp code [OPTIONS] ENTRY",
|
|
),
|
|
tui(
|
|
"copy an OTP code",
|
|
"clipboard feedback",
|
|
Some(Action::OtpCopyCode),
|
|
":otp code --clip ENTRY",
|
|
),
|
|
tui(
|
|
"insert an OTP entry",
|
|
"masked OTP insert form",
|
|
Some(Action::OtpInsert),
|
|
":otp insert [OPTIONS] [ENTRY]",
|
|
),
|
|
tui(
|
|
"append OTP data",
|
|
"masked OTP append form",
|
|
Some(Action::OtpAppend),
|
|
":otp append [OPTIONS] ENTRY",
|
|
),
|
|
tui(
|
|
"present an OTP URI",
|
|
"secret URI popup",
|
|
Some(Action::OtpUri),
|
|
":otp uri ENTRY",
|
|
),
|
|
tui(
|
|
"copy an OTP URI",
|
|
"clipboard feedback",
|
|
Some(Action::OtpCopyUri),
|
|
":otp uri --clip ENTRY",
|
|
),
|
|
tui(
|
|
"present an OTP URI as QR",
|
|
"resizable QR popup",
|
|
Some(Action::OtpQr),
|
|
":otp uri --qrcode ENTRY",
|
|
),
|
|
tui(
|
|
"validate an OTP URI",
|
|
"masked validation form",
|
|
Some(Action::OtpValidate),
|
|
":otp validate URI",
|
|
),
|
|
tui("show pass-otp version", "status line", None, ":otp version"),
|
|
tui(
|
|
"show help",
|
|
"contextual help overlay",
|
|
Some(Action::Help),
|
|
":help [TOPIC]",
|
|
),
|
|
tui("show version", "status line", None, ":version"),
|
|
tui("lock the TUI", "locked screen", Some(Action::Lock), ":lock"),
|
|
tui(
|
|
"unlock the TUI",
|
|
"locked screen",
|
|
Some(Action::Unlock),
|
|
":unlock",
|
|
),
|
|
];
|
|
|
|
const fn tui(
|
|
operation: &'static str,
|
|
element: &'static str,
|
|
action: Option<Action>,
|
|
command: &'static str,
|
|
) -> TuiCoverage {
|
|
TuiCoverage {
|
|
operation,
|
|
element,
|
|
action,
|
|
command,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct TuiCoverage {
|
|
pub operation: &'static str,
|
|
pub element: &'static str,
|
|
pub action: Option<Action>,
|
|
pub command: &'static str,
|
|
}
|
|
|
|
impl TuiCoverage {
|
|
pub fn default_hotkey(self) -> Option<&'static str> {
|
|
let action = self.action?;
|
|
ACTIONS
|
|
.iter()
|
|
.find(|spec| spec.action == action)
|
|
.and_then(|spec| spec.bindings.first())
|
|
.map(|binding| binding.display)
|
|
}
|
|
}
|
|
|
|
const fn coverage(operation: &'static str, command: &'static str) -> CommandCoverage {
|
|
CommandCoverage { operation, command }
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct CommandCoverage {
|
|
pub operation: &'static str,
|
|
pub command: &'static str,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum CommandInvocation {
|
|
Storage(CommandRequest),
|
|
Display(String),
|
|
Lock,
|
|
Unlock,
|
|
Quit,
|
|
ResolveGit(ironstorage::git::GitConflictChoice),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct CommandError {
|
|
message: String,
|
|
}
|
|
|
|
impl CommandError {
|
|
fn new(message: impl Into<String>) -> Self {
|
|
Self {
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for CommandError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(&self.message)
|
|
}
|
|
}
|
|
|
|
impl Error for CommandError {}
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct CommandLine {
|
|
input: String,
|
|
cursor: usize,
|
|
history: Vec<String>,
|
|
history_position: Option<usize>,
|
|
history_draft: String,
|
|
completion: Option<CompletionCycle>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct CompletionCycle {
|
|
start: usize,
|
|
end: usize,
|
|
candidates: Vec<String>,
|
|
selected: usize,
|
|
}
|
|
|
|
impl CommandLine {
|
|
pub fn input(&self) -> &str {
|
|
&self.input
|
|
}
|
|
|
|
pub fn cursor(&self) -> usize {
|
|
self.cursor
|
|
}
|
|
|
|
pub fn history(&self) -> &[String] {
|
|
&self.history
|
|
}
|
|
|
|
pub fn display(&self) -> String {
|
|
if is_secret_bearing(&self.input) {
|
|
let command = tokenize(&self.input)
|
|
.ok()
|
|
.and_then(|tokens| {
|
|
let keep = if tokens.first().is_some_and(|token| token == "otp") {
|
|
2
|
|
} else {
|
|
1
|
|
};
|
|
(!tokens.is_empty()).then(|| {
|
|
format!(
|
|
"{} [hidden secret-bearing arguments]",
|
|
tokens[..tokens.len().min(keep)].join(" ")
|
|
)
|
|
})
|
|
})
|
|
.unwrap_or_else(|| "[hidden secret-bearing command]".to_owned());
|
|
return command;
|
|
}
|
|
let mut displayed = self.input.clone();
|
|
displayed.insert(self.cursor, '▏');
|
|
displayed
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.input.clear();
|
|
self.cursor = 0;
|
|
self.history_position = None;
|
|
self.history_draft.clear();
|
|
self.completion = None;
|
|
}
|
|
|
|
pub fn insert(&mut self, character: char) {
|
|
if character.is_control() {
|
|
return;
|
|
}
|
|
self.reset_navigation();
|
|
self.input.insert(self.cursor, character);
|
|
self.cursor += character.len_utf8();
|
|
}
|
|
|
|
pub fn backspace(&mut self) {
|
|
self.reset_navigation();
|
|
if let Some(previous) = previous_boundary(&self.input, self.cursor) {
|
|
self.input.drain(previous..self.cursor);
|
|
self.cursor = previous;
|
|
}
|
|
}
|
|
|
|
pub fn delete(&mut self) {
|
|
self.reset_navigation();
|
|
if let Some(next) = next_boundary(&self.input, self.cursor) {
|
|
self.input.drain(self.cursor..next);
|
|
}
|
|
}
|
|
|
|
pub fn move_left(&mut self) {
|
|
self.completion = None;
|
|
if let Some(previous) = previous_boundary(&self.input, self.cursor) {
|
|
self.cursor = previous;
|
|
}
|
|
}
|
|
|
|
pub fn move_right(&mut self) {
|
|
self.completion = None;
|
|
if let Some(next) = next_boundary(&self.input, self.cursor) {
|
|
self.cursor = next;
|
|
}
|
|
}
|
|
|
|
pub fn move_home(&mut self) {
|
|
self.completion = None;
|
|
self.cursor = 0;
|
|
}
|
|
|
|
pub fn move_end(&mut self) {
|
|
self.completion = None;
|
|
self.cursor = self.input.len();
|
|
}
|
|
|
|
pub fn previous_history(&mut self) {
|
|
self.completion = None;
|
|
if self.history.is_empty() {
|
|
return;
|
|
}
|
|
let position = match self.history_position {
|
|
None => {
|
|
self.history_draft.clone_from(&self.input);
|
|
self.history.len() - 1
|
|
}
|
|
Some(position) => position.saturating_sub(1),
|
|
};
|
|
self.history_position = Some(position);
|
|
self.input.clone_from(&self.history[position]);
|
|
self.cursor = self.input.len();
|
|
}
|
|
|
|
pub fn next_history(&mut self) {
|
|
self.completion = None;
|
|
let Some(position) = self.history_position else {
|
|
return;
|
|
};
|
|
if position + 1 < self.history.len() {
|
|
self.history_position = Some(position + 1);
|
|
self.input.clone_from(&self.history[position + 1]);
|
|
} else {
|
|
self.history_position = None;
|
|
self.input.clone_from(&self.history_draft);
|
|
}
|
|
self.cursor = self.input.len();
|
|
}
|
|
|
|
pub fn complete(&mut self, paths: &[String], reverse: bool) -> bool {
|
|
if let Some(cycle) = self.completion.as_mut() {
|
|
cycle.selected = if reverse {
|
|
cycle
|
|
.selected
|
|
.checked_sub(1)
|
|
.unwrap_or(cycle.candidates.len() - 1)
|
|
} else {
|
|
(cycle.selected + 1) % cycle.candidates.len()
|
|
};
|
|
let replacement = cycle.candidates[cycle.selected].clone();
|
|
self.input
|
|
.replace_range(cycle.start..cycle.end, &replacement);
|
|
cycle.end = cycle.start + replacement.len();
|
|
self.cursor = cycle.end;
|
|
return true;
|
|
}
|
|
|
|
let fragment = completion_fragment(&self.input, self.cursor);
|
|
let preceding = tokenize(&self.input[..fragment.start]).unwrap_or_default();
|
|
let candidates = completion_candidates(&preceding, &fragment.value, paths)
|
|
.into_iter()
|
|
.map(|candidate| quote_argument(&candidate))
|
|
.collect::<Vec<_>>();
|
|
if candidates.is_empty() {
|
|
return false;
|
|
}
|
|
let selected = if reverse { candidates.len() - 1 } else { 0 };
|
|
let replacement = if candidates.len() == 1 {
|
|
format!("{} ", candidates[0])
|
|
} else {
|
|
candidates[selected].clone()
|
|
};
|
|
self.input
|
|
.replace_range(fragment.start..fragment.end, &replacement);
|
|
self.cursor = fragment.start + replacement.len();
|
|
if candidates.len() > 1 {
|
|
self.completion = Some(CompletionCycle {
|
|
start: fragment.start,
|
|
end: self.cursor,
|
|
candidates,
|
|
selected,
|
|
});
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn submit(&mut self) -> Result<CommandInvocation, CommandError> {
|
|
let raw = self.input.trim().to_owned();
|
|
let result = parse_command(&raw);
|
|
if !raw.is_empty() && !is_secret_bearing(&raw) && self.history.last() != Some(&raw) {
|
|
self.history.push(raw);
|
|
if self.history.len() > HISTORY_LIMIT {
|
|
self.history.remove(0);
|
|
}
|
|
}
|
|
self.clear();
|
|
result
|
|
}
|
|
|
|
fn reset_navigation(&mut self) {
|
|
self.history_position = None;
|
|
self.history_draft.clear();
|
|
self.completion = None;
|
|
}
|
|
}
|
|
|
|
pub fn parse_command(input: &str) -> Result<CommandInvocation, CommandError> {
|
|
let input = input.trim().strip_prefix(':').unwrap_or(input.trim());
|
|
if input.is_empty() {
|
|
return Err(CommandError::new("command is empty"));
|
|
}
|
|
let sensitive = is_secret_bearing(input);
|
|
let tokens = tokenize(input).map_err(|error| {
|
|
if sensitive {
|
|
CommandError::new("invalid secret-bearing command; arguments omitted")
|
|
} else {
|
|
error
|
|
}
|
|
})?;
|
|
let Some(command) = tokens.first().map(String::as_str) else {
|
|
return Err(CommandError::new("command is empty"));
|
|
};
|
|
if tokens.as_slice() == ["git", "resolve-local"] {
|
|
return Ok(CommandInvocation::ResolveGit(
|
|
ironstorage::git::GitConflictChoice::Local,
|
|
));
|
|
}
|
|
if tokens.as_slice() == ["git", "resolve-remote"] {
|
|
return Ok(CommandInvocation::ResolveGit(
|
|
ironstorage::git::GitConflictChoice::Remote,
|
|
));
|
|
}
|
|
let builtin = match command {
|
|
"lock" => Some(CommandInvocation::Lock),
|
|
"unlock" => Some(CommandInvocation::Unlock),
|
|
"quit" | "q" => Some(CommandInvocation::Quit),
|
|
"git-resolve-local" => Some(CommandInvocation::ResolveGit(
|
|
ironstorage::git::GitConflictChoice::Local,
|
|
)),
|
|
"git-resolve-remote" => Some(CommandInvocation::ResolveGit(
|
|
ironstorage::git::GitConflictChoice::Remote,
|
|
)),
|
|
_ => None,
|
|
};
|
|
if let Some(invocation) = builtin {
|
|
if tokens.len() != 1 {
|
|
return Err(CommandError::new(format!(
|
|
":{command} does not accept arguments"
|
|
)));
|
|
}
|
|
return Ok(invocation);
|
|
}
|
|
|
|
let arguments = std::iter::once("ironstorage".to_owned()).chain(tokens);
|
|
match parse_from(arguments) {
|
|
Ok(CliAction::Display(text)) => Ok(CommandInvocation::Display(text)),
|
|
Ok(CliAction::Run(invocation)) => {
|
|
let (config, request) = invocation.into_parts();
|
|
if config.is_some() {
|
|
return Err(CommandError::new(
|
|
"the TUI uses its loaded configuration; --config is unavailable in command mode",
|
|
));
|
|
}
|
|
if matches!(request, CommandRequest::Completion { .. }) {
|
|
return Err(CommandError::new(
|
|
"shell completion scripts are unavailable inside the interactive TUI",
|
|
));
|
|
}
|
|
Ok(CommandInvocation::Storage(request))
|
|
}
|
|
Err(_error) if sensitive => Err(CommandError::new(
|
|
"invalid secret-bearing command; arguments omitted",
|
|
)),
|
|
Err(error) => Err(CommandError::new(error.to_string().trim().to_owned())),
|
|
}
|
|
}
|
|
|
|
pub fn operation_name(request: &CommandRequest) -> &'static str {
|
|
match request {
|
|
CommandRequest::Init(_) => "initialization",
|
|
CommandRequest::List(_) => "list",
|
|
CommandRequest::Show(_) => "show",
|
|
CommandRequest::Find(_) => "find",
|
|
CommandRequest::Grep(_) => "grep",
|
|
CommandRequest::Insert(_) => "insert",
|
|
CommandRequest::Edit(_) => "edit",
|
|
CommandRequest::Generate(_) => "generate",
|
|
CommandRequest::Remove(_) => "remove",
|
|
CommandRequest::Move(_) => "move",
|
|
CommandRequest::Copy(_) => "copy",
|
|
CommandRequest::ImportKdbx(_) => "KDBX import",
|
|
CommandRequest::Git(request) => match request {
|
|
GitRequest::Init => "Git init",
|
|
GitRequest::Status => "Git status",
|
|
GitRequest::Log { .. } => "Git log",
|
|
GitRequest::Diff { .. } => "Git diff",
|
|
GitRequest::Add { .. } => "Git add",
|
|
GitRequest::Commit { .. } => "Git commit",
|
|
GitRequest::Remote(_) => "Git remote",
|
|
GitRequest::Config(_) => "Git config",
|
|
GitRequest::Fetch { .. } => "Git fetch",
|
|
GitRequest::Pull { .. } => "Git pull",
|
|
GitRequest::Push { .. } => "Git push",
|
|
GitRequest::Sync { .. } => "Git sync",
|
|
},
|
|
CommandRequest::Otp(request) => match request {
|
|
OtpRequest::Code(_) => "OTP code",
|
|
OtpRequest::Insert(_) => "OTP insert",
|
|
OtpRequest::Append(_) => "OTP append",
|
|
OtpRequest::Uri(_) => "OTP URI",
|
|
OtpRequest::Validate { .. } => "OTP validation",
|
|
OtpRequest::Help => "OTP help",
|
|
OtpRequest::Version => "OTP version",
|
|
},
|
|
CommandRequest::Completion { .. } => "completion",
|
|
CommandRequest::Help { .. } => "help",
|
|
CommandRequest::Version => "version",
|
|
}
|
|
}
|
|
|
|
fn tokenize(input: &str) -> Result<Vec<String>, CommandError> {
|
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
|
enum Quote {
|
|
None,
|
|
Single,
|
|
Double,
|
|
}
|
|
|
|
let mut tokens = Vec::new();
|
|
let mut token = String::new();
|
|
let mut quote = Quote::None;
|
|
let mut escaped = false;
|
|
let mut started = false;
|
|
for character in input.chars() {
|
|
if escaped {
|
|
token.push(character);
|
|
escaped = false;
|
|
started = true;
|
|
continue;
|
|
}
|
|
match (quote, character) {
|
|
(Quote::None | Quote::Double, '\\') => {
|
|
escaped = true;
|
|
started = true;
|
|
}
|
|
(Quote::None, '\'') => {
|
|
quote = Quote::Single;
|
|
started = true;
|
|
}
|
|
(Quote::Single, '\'') => quote = Quote::None,
|
|
(Quote::None, '"') => {
|
|
quote = Quote::Double;
|
|
started = true;
|
|
}
|
|
(Quote::Double, '"') => quote = Quote::None,
|
|
(Quote::None, character) if character.is_whitespace() => {
|
|
if started {
|
|
tokens.push(std::mem::take(&mut token));
|
|
started = false;
|
|
}
|
|
}
|
|
(_, character) => {
|
|
token.push(character);
|
|
started = true;
|
|
}
|
|
}
|
|
}
|
|
if escaped {
|
|
return Err(CommandError::new("command ends with an incomplete escape"));
|
|
}
|
|
if quote != Quote::None {
|
|
return Err(CommandError::new("command contains an unterminated quote"));
|
|
}
|
|
if started {
|
|
tokens.push(token);
|
|
}
|
|
Ok(tokens)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CompletionFragment {
|
|
start: usize,
|
|
end: usize,
|
|
value: String,
|
|
}
|
|
|
|
fn completion_fragment(input: &str, cursor: usize) -> CompletionFragment {
|
|
let mut start = 0;
|
|
let mut quote = None;
|
|
let mut escaped = false;
|
|
for (index, character) in input[..cursor].char_indices() {
|
|
if escaped {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
match (quote, character) {
|
|
(Some('"'), '\\') | (None, '\\') => escaped = true,
|
|
(None, '\'' | '"') => quote = Some(character),
|
|
(Some(active), character) if active == character => quote = None,
|
|
(None, character) if character.is_whitespace() => {
|
|
start = index + character.len_utf8();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let raw = &input[start..cursor];
|
|
let value = tokenize(raw)
|
|
.ok()
|
|
.and_then(|mut tokens| tokens.pop())
|
|
.unwrap_or_else(|| raw.trim_start_matches(['\'', '"']).replace("\\ ", " "));
|
|
CompletionFragment {
|
|
start,
|
|
end: cursor,
|
|
value,
|
|
}
|
|
}
|
|
|
|
fn completion_candidates(preceding: &[String], fragment: &str, paths: &[String]) -> Vec<String> {
|
|
let candidates: &[&str] = match preceding {
|
|
[] => ROOT_COMMANDS,
|
|
[command] if command == "git" => GIT_COMMANDS,
|
|
[command, remote] if command == "git" && remote == "remote" => GIT_REMOTE_COMMANDS,
|
|
[command] if command == "otp" => OTP_COMMANDS,
|
|
[command] if command == "help" => HELP_TOPICS,
|
|
[command, ..] if command == "init" && fragment.starts_with('-') => &["--path"],
|
|
[command, ..] if command == "show" && fragment.starts_with('-') => &["--clip", "--qrcode"],
|
|
[command, ..] if command == "grep" && fragment.starts_with('-') => &[
|
|
"--ignore-case",
|
|
"--invert-match",
|
|
"--line-number",
|
|
"--fixed-strings",
|
|
],
|
|
[command, ..] if command == "generate" && fragment.starts_with('-') => &[
|
|
"--no-symbols",
|
|
"--clip",
|
|
"--qrcode",
|
|
"--in-place",
|
|
"--force",
|
|
],
|
|
[command, ..] if command == "insert" && fragment.starts_with('-') => {
|
|
&["--echo", "--multiline", "--force"]
|
|
}
|
|
[command, ..] if command == "remove" && fragment.starts_with('-') => {
|
|
&["--recursive", "--force"]
|
|
}
|
|
[command, ..]
|
|
if matches!(command.as_str(), "move" | "copy") && fragment.starts_with('-') =>
|
|
{
|
|
&["--force"]
|
|
}
|
|
[git, subcommand, ..]
|
|
if git == "git" && subcommand == "log" && fragment.starts_with('-') =>
|
|
{
|
|
&["--max-count"]
|
|
}
|
|
[git, subcommand, ..]
|
|
if git == "git" && subcommand == "commit" && fragment.starts_with('-') =>
|
|
{
|
|
&["--message"]
|
|
}
|
|
[git, subcommand, ..]
|
|
if git == "git" && subcommand == "config" && fragment.starts_with('-') =>
|
|
{
|
|
&["--get"]
|
|
}
|
|
[otp, subcommand, ..]
|
|
if otp == "otp" && subcommand == "code" && fragment.starts_with('-') =>
|
|
{
|
|
&["--clip"]
|
|
}
|
|
[otp, subcommand, ..]
|
|
if otp == "otp"
|
|
&& matches!(subcommand.as_str(), "insert" | "append")
|
|
&& fragment.starts_with('-') =>
|
|
{
|
|
&["--force", "--echo", "--secret", "--issuer", "--account"]
|
|
}
|
|
[otp, subcommand, ..]
|
|
if otp == "otp" && subcommand == "uri" && fragment.starts_with('-') =>
|
|
{
|
|
&["--clip", "--qrcode"]
|
|
}
|
|
_ => &[],
|
|
};
|
|
let mut matches = candidates
|
|
.iter()
|
|
.filter(|candidate| candidate.starts_with(fragment))
|
|
.map(|candidate| (*candidate).to_owned())
|
|
.collect::<Vec<_>>();
|
|
if candidates.is_empty() && command_accepts_path(preceding) {
|
|
matches.extend(
|
|
paths
|
|
.iter()
|
|
.filter(|path| path.starts_with(fragment))
|
|
.cloned(),
|
|
);
|
|
}
|
|
matches.sort();
|
|
matches.dedup();
|
|
matches
|
|
}
|
|
|
|
fn command_accepts_path(tokens: &[String]) -> bool {
|
|
matches!(
|
|
tokens.first().map(String::as_str),
|
|
Some(
|
|
"list"
|
|
| "ls"
|
|
| "show"
|
|
| "insert"
|
|
| "edit"
|
|
| "generate"
|
|
| "remove"
|
|
| "rm"
|
|
| "move"
|
|
| "mv"
|
|
| "copy"
|
|
| "cp"
|
|
)
|
|
) || matches!(
|
|
tokens,
|
|
[command, subcommand, ..]
|
|
if (command == "otp"
|
|
&& matches!(subcommand.as_str(), "code" | "show" | "insert" | "append" | "uri"))
|
|
|| (command == "git" && matches!(subcommand.as_str(), "diff" | "add"))
|
|
)
|
|
}
|
|
|
|
fn quote_argument(argument: &str) -> String {
|
|
if !argument.is_empty()
|
|
&& argument
|
|
.chars()
|
|
.all(|character| character.is_alphanumeric() || "/._-:@".contains(character))
|
|
{
|
|
return argument.to_owned();
|
|
}
|
|
format!("'{}'", argument.replace('\'', "'\\''"))
|
|
}
|
|
|
|
fn is_secret_bearing(input: &str) -> bool {
|
|
let lowercase = input.to_ascii_lowercase();
|
|
if lowercase.contains("otpauth://") {
|
|
return true;
|
|
}
|
|
let tokens = tokenize(input).unwrap_or_else(|_| {
|
|
input
|
|
.split_whitespace()
|
|
.map(str::to_owned)
|
|
.collect::<Vec<_>>()
|
|
});
|
|
matches!(tokens.as_slice(), [otp, validate, ..] if otp == "otp" && validate == "validate")
|
|
|| matches!(tokens.as_slice(), [git, config, ..] if git == "git" && config == "config")
|
|
|| matches!(tokens.as_slice(), [git, remote, action, ..]
|
|
if git == "git" && remote == "remote" && matches!(action.as_str(), "add" | "set-url"))
|
|
}
|
|
|
|
fn previous_boundary(value: &str, cursor: usize) -> Option<usize> {
|
|
value[..cursor]
|
|
.char_indices()
|
|
.next_back()
|
|
.map(|(index, _)| index)
|
|
}
|
|
|
|
fn next_boundary(value: &str, cursor: usize) -> Option<usize> {
|
|
value[cursor..].char_indices().nth(1).map_or_else(
|
|
|| (cursor < value.len()).then_some(value.len()),
|
|
|(index, _)| Some(cursor + index),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ironstorage::command::{GitRemoteRequest, RemoveRequest};
|
|
|
|
#[test]
|
|
fn quoting_and_paths_with_spaces_use_the_shared_request_parser() {
|
|
assert!(matches!(
|
|
parse_command("show 'team accounts/mail primary'"),
|
|
Ok(CommandInvocation::Storage(CommandRequest::Show(request)))
|
|
if request.entry.as_deref() == Some("team accounts/mail primary")
|
|
));
|
|
assert!(matches!(
|
|
parse_command("git commit -m \"rotate signing key\""),
|
|
Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Commit { message })))
|
|
if message == "rotate signing key"
|
|
));
|
|
assert!(parse_command("show 'unfinished").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn editing_history_and_secret_omission_are_unicode_safe() {
|
|
let mut line = CommandLine::default();
|
|
for character in "show café/mail".chars() {
|
|
line.insert(character);
|
|
}
|
|
line.move_left();
|
|
line.backspace();
|
|
line.insert('i');
|
|
assert_eq!(line.input(), "show café/mail");
|
|
assert!(line.submit().is_ok());
|
|
assert_eq!(line.history(), &["show café/mail"]);
|
|
|
|
for character in "otp validate otpauth://totp/a?secret=TOPSECRET".chars() {
|
|
line.insert(character);
|
|
}
|
|
assert!(!line.display().contains("TOPSECRET"));
|
|
assert!(line.submit().is_ok());
|
|
assert_eq!(line.history(), &["show café/mail"]);
|
|
line.previous_history();
|
|
assert_eq!(line.input(), "show café/mail");
|
|
line.next_history();
|
|
assert!(line.input().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn completion_is_context_aware_and_quotes_paths() {
|
|
let paths = vec!["team accounts/mail primary".to_owned()];
|
|
let mut line = CommandLine::default();
|
|
for character in "gi".chars() {
|
|
line.insert(character);
|
|
}
|
|
assert!(line.complete(&paths, false));
|
|
assert_eq!(line.input(), "git ");
|
|
for character in "pu".chars() {
|
|
line.insert(character);
|
|
}
|
|
assert!(line.complete(&paths, false));
|
|
assert!(matches!(line.input(), "git pull" | "git push"));
|
|
|
|
line.clear();
|
|
for character in "show team".chars() {
|
|
line.insert(character);
|
|
}
|
|
assert!(line.complete(&paths, false));
|
|
assert_eq!(line.input(), "show 'team accounts/mail primary' ");
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_flags_cancellation_inputs_and_unavailable_meta_actions_are_typed() {
|
|
assert!(parse_command("generate --clip --qrcode entry").is_err());
|
|
assert!(
|
|
parse_command("completion bash")
|
|
.expect_err("interactive completion is unavailable")
|
|
.to_string()
|
|
.contains("unavailable")
|
|
);
|
|
assert!(matches!(
|
|
parse_command(":lock"),
|
|
Ok(CommandInvocation::Lock)
|
|
));
|
|
assert!(matches!(
|
|
parse_command(":unlock"),
|
|
Ok(CommandInvocation::Unlock)
|
|
));
|
|
assert!(matches!(
|
|
parse_command(":quit"),
|
|
Ok(CommandInvocation::Quit)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn destructive_and_nested_requests_preserve_typed_storage_contracts() {
|
|
assert_eq!(
|
|
parse_command("git resolve-local"),
|
|
Ok(CommandInvocation::ResolveGit(
|
|
ironstorage::git::GitConflictChoice::Local
|
|
))
|
|
);
|
|
assert_eq!(
|
|
parse_command("git resolve-remote"),
|
|
Ok(CommandInvocation::ResolveGit(
|
|
ironstorage::git::GitConflictChoice::Remote
|
|
))
|
|
);
|
|
assert_eq!(
|
|
parse_command("remove -r folder"),
|
|
Ok(CommandInvocation::Storage(CommandRequest::Remove(
|
|
RemoveRequest {
|
|
entry: "folder".to_owned(),
|
|
recursive: true,
|
|
force: false,
|
|
}
|
|
)))
|
|
);
|
|
assert!(matches!(
|
|
parse_command("git remote set-url origin https://example.test/repo"),
|
|
Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Remote(
|
|
GitRemoteRequest::SetUrl { name, url }
|
|
)))) if name == "origin" && url == "https://example.test/repo"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn coverage_table_has_every_command_family_and_no_duplicates() {
|
|
assert!(COMMAND_COVERAGE.len() >= 33);
|
|
for (index, row) in COMMAND_COVERAGE.iter().enumerate() {
|
|
assert!(row.command.starts_with(':'));
|
|
assert!(!row.operation.is_empty());
|
|
assert!(!COMMAND_COVERAGE[index + 1..].contains(row));
|
|
}
|
|
for command in [
|
|
":init",
|
|
":list",
|
|
":show",
|
|
":find",
|
|
":grep",
|
|
":insert",
|
|
":edit",
|
|
":generate",
|
|
":remove",
|
|
":move",
|
|
":copy",
|
|
":git",
|
|
":otp",
|
|
":lock",
|
|
":unlock",
|
|
":help",
|
|
":version",
|
|
] {
|
|
assert!(
|
|
COMMAND_COVERAGE
|
|
.iter()
|
|
.any(|row| row.command.starts_with(command))
|
|
);
|
|
}
|
|
|
|
for command_row in COMMAND_COVERAGE {
|
|
assert!(
|
|
TUI_COVERAGE
|
|
.iter()
|
|
.any(|row| row.operation == command_row.operation),
|
|
"missing TUI coverage for {}",
|
|
command_row.operation
|
|
);
|
|
}
|
|
for (index, row) in TUI_COVERAGE.iter().enumerate() {
|
|
assert!(!row.operation.is_empty());
|
|
assert!(!row.element.is_empty());
|
|
assert!(row.command.starts_with(':'));
|
|
assert!(
|
|
!TUI_COVERAGE[index + 1..]
|
|
.iter()
|
|
.any(|other| other.operation == row.operation),
|
|
"duplicate TUI operation {}",
|
|
row.operation
|
|
);
|
|
if row.action.is_some() {
|
|
assert!(
|
|
row.default_hotkey().is_some(),
|
|
"{} has no registered default hotkey",
|
|
row.operation
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn command_mode_source_has_no_process_launch_boundary() {
|
|
let source = include_str!("command.rs");
|
|
let process_type = ["process", "::Command"].concat();
|
|
let constructor = ["Command", "::new("].concat();
|
|
for forbidden in [process_type, constructor] {
|
|
assert!(!source.contains(&forbidden));
|
|
}
|
|
for executable in ["pass ", "git ", "gpg ", "ironstorage "] {
|
|
assert!(!source.contains(&format!("{executable}--")));
|
|
}
|
|
}
|
|
}
|