Implement the authenticated structured entry viewer
This commit is contained in:
@@ -27,6 +27,12 @@ pub enum Action {
|
|||||||
PreviousMatch,
|
PreviousMatch,
|
||||||
FocusNext,
|
FocusNext,
|
||||||
FocusPrevious,
|
FocusPrevious,
|
||||||
|
Reveal,
|
||||||
|
Hide,
|
||||||
|
Copy,
|
||||||
|
ScrollDown,
|
||||||
|
ScrollUp,
|
||||||
|
CloseEntry,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -222,18 +228,68 @@ pub static ACTIONS: &[ActionSpec] = &[
|
|||||||
},
|
},
|
||||||
ActionSpec {
|
ActionSpec {
|
||||||
action: Action::FocusNext,
|
action: Action::FocusNext,
|
||||||
label: "next pane",
|
label: "next field/pane",
|
||||||
command: "focus-next",
|
command: "focus-next",
|
||||||
bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")),
|
bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")),
|
||||||
modes: UNLOCKED,
|
modes: UNLOCKED,
|
||||||
},
|
},
|
||||||
ActionSpec {
|
ActionSpec {
|
||||||
action: Action::FocusPrevious,
|
action: Action::FocusPrevious,
|
||||||
label: "previous pane",
|
label: "previous field/pane",
|
||||||
command: "focus-previous",
|
command: "focus-previous",
|
||||||
bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")),
|
bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")),
|
||||||
modes: UNLOCKED,
|
modes: UNLOCKED,
|
||||||
},
|
},
|
||||||
|
ActionSpec {
|
||||||
|
action: Action::Reveal,
|
||||||
|
label: "reveal field",
|
||||||
|
command: "reveal",
|
||||||
|
bindings: keys!((KeyCode::Char('v'), KeyModifiers::NONE, "v")),
|
||||||
|
modes: &[Mode::Viewer],
|
||||||
|
},
|
||||||
|
ActionSpec {
|
||||||
|
action: Action::Hide,
|
||||||
|
label: "hide field",
|
||||||
|
command: "hide",
|
||||||
|
bindings: keys!((KeyCode::Char('V'), KeyModifiers::SHIFT, "V")),
|
||||||
|
modes: &[Mode::Viewer],
|
||||||
|
},
|
||||||
|
ActionSpec {
|
||||||
|
action: Action::Copy,
|
||||||
|
label: "copy field",
|
||||||
|
command: "copy",
|
||||||
|
bindings: keys!((KeyCode::Char('y'), KeyModifiers::NONE, "y")),
|
||||||
|
modes: &[Mode::Viewer],
|
||||||
|
},
|
||||||
|
ActionSpec {
|
||||||
|
action: Action::ScrollDown,
|
||||||
|
label: "scroll down",
|
||||||
|
command: "scroll-down",
|
||||||
|
bindings: keys!(
|
||||||
|
(KeyCode::Char('j'), KeyModifiers::NONE, "j"),
|
||||||
|
(KeyCode::Down, KeyModifiers::NONE, "↓"),
|
||||||
|
(KeyCode::PageDown, KeyModifiers::NONE, "PgDn"),
|
||||||
|
),
|
||||||
|
modes: &[Mode::Viewer],
|
||||||
|
},
|
||||||
|
ActionSpec {
|
||||||
|
action: Action::ScrollUp,
|
||||||
|
label: "scroll up",
|
||||||
|
command: "scroll-up",
|
||||||
|
bindings: keys!(
|
||||||
|
(KeyCode::Char('k'), KeyModifiers::NONE, "k"),
|
||||||
|
(KeyCode::Up, KeyModifiers::NONE, "↑"),
|
||||||
|
(KeyCode::PageUp, KeyModifiers::NONE, "PgUp"),
|
||||||
|
),
|
||||||
|
modes: &[Mode::Viewer],
|
||||||
|
},
|
||||||
|
ActionSpec {
|
||||||
|
action: Action::CloseEntry,
|
||||||
|
label: "close entry",
|
||||||
|
command: "close-entry",
|
||||||
|
bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
|
||||||
|
modes: &[Mode::Viewer],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
|
pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ use std::collections::BTreeSet;
|
|||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
config::Config,
|
config::Config,
|
||||||
crypto::KeyInfo,
|
crypto::KeyInfo,
|
||||||
|
document::EntryDocument,
|
||||||
|
presentation::ClipboardDisposition,
|
||||||
read::{FindResults, TreeModel},
|
read::{FindResults, TreeModel},
|
||||||
|
repository::SecretBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
action::Action,
|
action::Action,
|
||||||
sidebar::{Sidebar, SidebarIntent},
|
sidebar::{Sidebar, SidebarIntent},
|
||||||
|
viewer::EntryViewer,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
@@ -56,14 +60,22 @@ pub struct StartupData {
|
|||||||
pub key: KeyInfo,
|
pub key: KeyInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Debug)]
|
||||||
pub enum AsyncPayload {
|
pub enum AsyncPayload {
|
||||||
Startup(Box<StartupData>),
|
Startup(Box<StartupData>),
|
||||||
Refreshed(TreeModel),
|
Refreshed(TreeModel),
|
||||||
Filtered { query: String, results: FindResults },
|
Filtered {
|
||||||
|
query: String,
|
||||||
|
results: FindResults,
|
||||||
|
},
|
||||||
|
DocumentLoaded {
|
||||||
|
entry: String,
|
||||||
|
document: Box<EntryDocument>,
|
||||||
|
},
|
||||||
|
ClipboardFinished(ClipboardDisposition),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Debug)]
|
||||||
pub struct AsyncResult {
|
pub struct AsyncResult {
|
||||||
pub token: RequestToken,
|
pub token: RequestToken,
|
||||||
pub payload: Result<AsyncPayload, String>,
|
pub payload: Result<AsyncPayload, String>,
|
||||||
@@ -75,11 +87,12 @@ pub enum ResultDisposition {
|
|||||||
Stale,
|
Stale,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Debug)]
|
||||||
pub enum AppEffect {
|
pub enum AppEffect {
|
||||||
None,
|
None,
|
||||||
RefreshTree,
|
RefreshTree,
|
||||||
AuthenticateEntry(String),
|
AuthenticateEntry(String),
|
||||||
|
CopyFocused(SecretBytes),
|
||||||
ManualLock,
|
ManualLock,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +106,7 @@ pub struct App {
|
|||||||
default_key: Option<KeyInfo>,
|
default_key: Option<KeyInfo>,
|
||||||
sidebar: Sidebar,
|
sidebar: Sidebar,
|
||||||
selected_entry: Option<String>,
|
selected_entry: Option<String>,
|
||||||
|
viewer: Option<EntryViewer>,
|
||||||
authentication_pending: Option<String>,
|
authentication_pending: Option<String>,
|
||||||
remaining_lease: Option<std::time::Duration>,
|
remaining_lease: Option<std::time::Duration>,
|
||||||
terminal_size: (u16, u16),
|
terminal_size: (u16, u16),
|
||||||
@@ -120,6 +134,7 @@ impl App {
|
|||||||
default_key: None,
|
default_key: None,
|
||||||
sidebar: Sidebar::default(),
|
sidebar: Sidebar::default(),
|
||||||
selected_entry: None,
|
selected_entry: None,
|
||||||
|
viewer: None,
|
||||||
authentication_pending: None,
|
authentication_pending: None,
|
||||||
remaining_lease: None,
|
remaining_lease: None,
|
||||||
terminal_size: (0, 0),
|
terminal_size: (0, 0),
|
||||||
@@ -159,6 +174,10 @@ impl App {
|
|||||||
self.selected_entry.as_deref()
|
self.selected_entry.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn viewer(&self) -> Option<&EntryViewer> {
|
||||||
|
self.viewer.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_key(&self) -> Option<&KeyInfo> {
|
pub fn default_key(&self) -> Option<&KeyInfo> {
|
||||||
self.default_key.as_ref()
|
self.default_key.as_ref()
|
||||||
}
|
}
|
||||||
@@ -239,7 +258,34 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.status = format!("Filter: {query} ({} matches)", results.matches().len());
|
self.status = format!("Filter: {query} ({} matches)", results.matches().len());
|
||||||
}
|
}
|
||||||
Err(error) => self.status = error,
|
Ok(AsyncPayload::DocumentLoaded { entry, document }) => {
|
||||||
|
if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) {
|
||||||
|
return ResultDisposition::Stale;
|
||||||
|
}
|
||||||
|
let field_count = document.fields().len();
|
||||||
|
self.viewer = Some(EntryViewer::new(*document));
|
||||||
|
self.focus = PaneFocus::Main;
|
||||||
|
self.status = format!("Opened {entry} ({field_count} fields)");
|
||||||
|
}
|
||||||
|
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
|
||||||
|
self.status = match disposition {
|
||||||
|
ClipboardDisposition::RestoredPrevious => {
|
||||||
|
"Clipboard restored to its previous value".to_owned()
|
||||||
|
}
|
||||||
|
ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(),
|
||||||
|
ClipboardDisposition::PreservedNewer => {
|
||||||
|
"Clipboard changed; the newer value was preserved".to_owned()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.status = error;
|
||||||
|
if self.mode == Mode::Viewer && self.viewer.is_none() {
|
||||||
|
self.mode = Mode::Browser;
|
||||||
|
self.focus = PaneFocus::Sidebar;
|
||||||
|
self.selected_entry = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ResultDisposition::Applied
|
ResultDisposition::Applied
|
||||||
}
|
}
|
||||||
@@ -295,12 +341,68 @@ impl App {
|
|||||||
Action::Filter => self.sidebar.begin_filter(),
|
Action::Filter => self.sidebar.begin_filter(),
|
||||||
Action::NextMatch => self.sidebar.next_match(),
|
Action::NextMatch => self.sidebar.next_match(),
|
||||||
Action::PreviousMatch => self.sidebar.previous_match(),
|
Action::PreviousMatch => self.sidebar.previous_match(),
|
||||||
|
Action::FocusNext if self.mode == Mode::Viewer => {
|
||||||
|
if let Some(viewer) = self.viewer.as_mut() {
|
||||||
|
viewer.focus_next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::FocusPrevious if self.mode == Mode::Viewer => {
|
||||||
|
if let Some(viewer) = self.viewer.as_mut() {
|
||||||
|
viewer.focus_previous();
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::FocusNext | Action::FocusPrevious => {
|
Action::FocusNext | Action::FocusPrevious => {
|
||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
PaneFocus::Sidebar => PaneFocus::Main,
|
PaneFocus::Sidebar => PaneFocus::Main,
|
||||||
PaneFocus::Main => PaneFocus::Sidebar,
|
PaneFocus::Main => PaneFocus::Sidebar,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Action::Reveal => {
|
||||||
|
if self
|
||||||
|
.viewer
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(EntryViewer::reveal_focused)
|
||||||
|
{
|
||||||
|
self.status = "Focused sensitive field revealed".to_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Hide => {
|
||||||
|
if self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) {
|
||||||
|
self.status = "Sensitive field hidden".to_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Copy => {
|
||||||
|
if let Some(viewer) = self.viewer.as_ref() {
|
||||||
|
match viewer.copy_focused() {
|
||||||
|
Ok(value) => {
|
||||||
|
self.status = self.config.as_ref().map_or_else(
|
||||||
|
|| "Copying focused field…".to_owned(),
|
||||||
|
|config| {
|
||||||
|
format!(
|
||||||
|
"Copied focused field; cleanup in {}s",
|
||||||
|
config.clipboard_timeout().duration().as_secs()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return AppEffect::CopyFocused(value);
|
||||||
|
}
|
||||||
|
Err(error) => self.status = error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::ScrollDown => {
|
||||||
|
if let Some(viewer) = self.viewer.as_mut() {
|
||||||
|
viewer.scroll_down(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::ScrollUp => {
|
||||||
|
if let Some(viewer) = self.viewer.as_mut() {
|
||||||
|
viewer.scroll_up(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::CloseEntry => {
|
||||||
|
self.transition(Transition::CloseEntry);
|
||||||
|
}
|
||||||
Action::Quit => {}
|
Action::Quit => {}
|
||||||
}
|
}
|
||||||
AppEffect::None
|
AppEffect::None
|
||||||
@@ -312,13 +414,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.authentication_pending = None;
|
self.authentication_pending = None;
|
||||||
self.selected_entry = Some(entry);
|
self.selected_entry = Some(entry);
|
||||||
self.status = "Authenticated".to_owned();
|
self.viewer = None;
|
||||||
self.transition(Transition::OpenEntry)
|
self.status = "Authenticated; loading structured entry…".to_owned();
|
||||||
|
let transitioned = self.transition(Transition::OpenEntry);
|
||||||
|
if transitioned {
|
||||||
|
self.focus = PaneFocus::Main;
|
||||||
|
}
|
||||||
|
transitioned
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authentication_failed(&mut self, message: String) {
|
pub fn authentication_failed(&mut self, message: String) {
|
||||||
self.authentication_pending = None;
|
self.authentication_pending = None;
|
||||||
self.selected_entry = None;
|
self.selected_entry = None;
|
||||||
|
self.viewer = None;
|
||||||
self.status = message;
|
self.status = message;
|
||||||
if self.mode != Mode::Browser {
|
if self.mode != Mode::Browser {
|
||||||
self.mode = Mode::Browser;
|
self.mode = Mode::Browser;
|
||||||
@@ -371,16 +479,22 @@ impl App {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
|
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
|
||||||
|
if let Some(viewer) = self.viewer.as_mut() {
|
||||||
|
viewer.hide_revealed();
|
||||||
|
}
|
||||||
self.suspended_mode = Some(current);
|
self.suspended_mode = Some(current);
|
||||||
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
|
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
|
||||||
self.selected_entry = None;
|
self.selected_entry = None;
|
||||||
|
self.viewer = None;
|
||||||
self.authentication_pending = None;
|
self.authentication_pending = None;
|
||||||
self.remaining_lease = None;
|
self.remaining_lease = None;
|
||||||
|
self.focus = PaneFocus::Sidebar;
|
||||||
} else if destination == Mode::Locked {
|
} else if destination == Mode::Locked {
|
||||||
self.suspended_mode = None;
|
self.suspended_mode = None;
|
||||||
self.focus = PaneFocus::Sidebar;
|
self.focus = PaneFocus::Sidebar;
|
||||||
self.invalidate_requests();
|
self.invalidate_requests();
|
||||||
self.selected_entry = None;
|
self.selected_entry = None;
|
||||||
|
self.viewer = None;
|
||||||
self.status = "Locked".to_owned();
|
self.status = "Locked".to_owned();
|
||||||
} else if current == Mode::Locked {
|
} else if current == Mode::Locked {
|
||||||
self.status = "Authentication required".to_owned();
|
self.status = "Authentication required".to_owned();
|
||||||
@@ -393,6 +507,15 @@ impl App {
|
|||||||
self.generation = self.generation.wrapping_add(1);
|
self.generation = self.generation.wrapping_add(1);
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn open_test_document(&mut self, entry: &str, document: EntryDocument) {
|
||||||
|
self.mode = Mode::Viewer;
|
||||||
|
self.focus = PaneFocus::Main;
|
||||||
|
self.selected_entry = Some(entry.to_owned());
|
||||||
|
self.viewer = Some(EntryViewer::new(document));
|
||||||
|
self.status = format!("Opened {entry}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -452,8 +575,14 @@ mod tests {
|
|||||||
token,
|
token,
|
||||||
payload: Err("completed".to_owned()),
|
payload: Err("completed".to_owned()),
|
||||||
};
|
};
|
||||||
assert_eq!(app.apply_result(result.clone()), ResultDisposition::Applied);
|
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
|
||||||
assert_eq!(app.apply_result(result), ResultDisposition::Stale);
|
assert_eq!(
|
||||||
|
app.apply_result(AsyncResult {
|
||||||
|
token,
|
||||||
|
payload: Err("duplicate".to_owned()),
|
||||||
|
}),
|
||||||
|
ResultDisposition::Stale
|
||||||
|
);
|
||||||
|
|
||||||
let stale = app.begin_request();
|
let stale = app.begin_request();
|
||||||
assert!(app.transition(Transition::Lock));
|
assert!(app.transition(Transition::Lock));
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ pub mod runtime;
|
|||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod viewer;
|
||||||
|
|
||||||
use std::{io, time::Duration};
|
use std::{
|
||||||
|
io,
|
||||||
|
sync::mpsc::{self, Sender},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyEventKind};
|
use crossterm::event::{self, Event, KeyEventKind};
|
||||||
use ratatui::DefaultTerminal;
|
use ratatui::DefaultTerminal;
|
||||||
@@ -23,11 +28,29 @@ use crate::{
|
|||||||
|
|
||||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ClipboardCancellations(Vec<Sender<()>>);
|
||||||
|
|
||||||
|
impl ClipboardCancellations {
|
||||||
|
fn register(&mut self, cancellation: Sender<()>) {
|
||||||
|
self.0.push(cancellation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ClipboardCancellations {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for cancellation in self.0.drain(..) {
|
||||||
|
let _ignored = cancellation.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the interactive event loop. Polling keeps input responsive while storage
|
/// Run the interactive event loop. Polling keeps input responsive while storage
|
||||||
/// work runs on the executor and periodic repaints are pending.
|
/// work runs on the executor and periodic repaints are pending.
|
||||||
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
let executor = AsyncExecutor::new();
|
let executor = AsyncExecutor::new();
|
||||||
|
let mut clipboard_cancellations = ClipboardCancellations::default();
|
||||||
let mut authentication = None;
|
let mut authentication = None;
|
||||||
let mut authentication_initialized = false;
|
let mut authentication_initialized = false;
|
||||||
let startup = app.begin_latest_request();
|
let startup = app.begin_latest_request();
|
||||||
@@ -51,7 +74,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
if let Some(coordinator) = authentication.as_mut()
|
if let Some(coordinator) = authentication.as_mut()
|
||||||
&& let Some(event) = coordinator.completion()
|
&& let Some(event) = coordinator.completion()
|
||||||
{
|
{
|
||||||
apply_authentication_event(&mut app, event);
|
apply_authentication_event(&mut app, coordinator, &executor, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
let size = terminal.size()?;
|
let size = terminal.size()?;
|
||||||
@@ -61,7 +84,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
app.tick();
|
app.tick();
|
||||||
if let Some(coordinator) = authentication.as_mut() {
|
if let Some(coordinator) = authentication.as_mut() {
|
||||||
if let Some(event) = coordinator.poll_lease() {
|
if let Some(event) = coordinator.poll_lease() {
|
||||||
apply_authentication_event(&mut app, event);
|
apply_authentication_event(&mut app, coordinator, &executor, event);
|
||||||
}
|
}
|
||||||
app.update_remaining_lease(coordinator.remaining_time());
|
app.update_remaining_lease(coordinator.remaining_time());
|
||||||
}
|
}
|
||||||
@@ -73,7 +96,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
if let Some(coordinator) = authentication.as_mut()
|
if let Some(coordinator) = authentication.as_mut()
|
||||||
&& let Some(event) = coordinator.touch_user_activity()
|
&& let Some(event) = coordinator.touch_user_activity()
|
||||||
{
|
{
|
||||||
apply_authentication_event(&mut app, event);
|
apply_authentication_event(&mut app, coordinator, &executor, event);
|
||||||
}
|
}
|
||||||
if app.sidebar().is_editing_filter() {
|
if app.sidebar().is_editing_filter() {
|
||||||
if handle_filter_key(&mut app, key.code) {
|
if handle_filter_key(&mut app, key.code) {
|
||||||
@@ -98,6 +121,33 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEffect::CopyFocused(value) => {
|
||||||
|
if let Some(config) = app.config().cloned() {
|
||||||
|
let (cancel, cancellation) = mpsc::channel();
|
||||||
|
clipboard_cancellations.register(cancel);
|
||||||
|
let token = app.begin_request();
|
||||||
|
executor.submit(token, move || {
|
||||||
|
let mut clipboard =
|
||||||
|
ironstorage::presentation::NativeClipboardManager::system(
|
||||||
|
config.clipboard_timeout(),
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
clipboard
|
||||||
|
.copy_with(&value, |duration| {
|
||||||
|
match cancellation.recv_timeout(duration) {
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
ironstorage::presentation::ClipboardWait::Elapsed
|
||||||
|
}
|
||||||
|
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
ironstorage::presentation::ClipboardWait::Cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(AsyncPayload::ClipboardFinished)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
AppEffect::ManualLock => {
|
AppEffect::ManualLock => {
|
||||||
if let Some(coordinator) = authentication.as_mut()
|
if let Some(coordinator) = authentication.as_mut()
|
||||||
&& let Err(error) = coordinator.lock()
|
&& let Err(error) = coordinator.lock()
|
||||||
@@ -144,16 +194,52 @@ fn load_startup() -> Result<StartupData, String> {
|
|||||||
Ok(StartupData { config, tree, key })
|
Ok(StartupData { config, tree, key })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_authentication_event(app: &mut App, event: AuthenticationEvent) {
|
fn apply_authentication_event(
|
||||||
|
app: &mut App,
|
||||||
|
coordinator: &AuthenticationCoordinator,
|
||||||
|
executor: &AsyncExecutor,
|
||||||
|
event: AuthenticationEvent,
|
||||||
|
) {
|
||||||
match event {
|
match event {
|
||||||
AuthenticationEvent::Granted(entry) => {
|
AuthenticationEvent::Granted(entry) => {
|
||||||
app.authentication_granted(entry);
|
if !app.authentication_granted(entry.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
|
||||||
|
app.authentication_failed(
|
||||||
|
"authentication completed without an active secure-store lease".to_owned(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let token = app.begin_latest_request();
|
||||||
|
executor.submit(token, move || {
|
||||||
|
load_document(&config, &entry, handle).map(|document| {
|
||||||
|
AsyncPayload::DocumentLoaded {
|
||||||
|
entry,
|
||||||
|
document: Box::new(document),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
AuthenticationEvent::Failed(error) => app.authentication_failed(error),
|
AuthenticationEvent::Failed(error) => app.authentication_failed(error),
|
||||||
AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"),
|
AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_document(
|
||||||
|
config: &ironstorage::config::Config,
|
||||||
|
entry: &str,
|
||||||
|
mut handle: ironstorage::authentication::NativeAuthenticationHandle,
|
||||||
|
) -> Result<ironstorage::document::EntryDocument, String> {
|
||||||
|
let repository = ironstorage::repository::Repository::open(config.vault())
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
ironstorage::document::EntryDocumentService::new(&repository, &keys)
|
||||||
|
.open(entry, &mut handle)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn load_tree(config: &ironstorage::config::Config) -> Result<ironstorage::read::TreeModel, String> {
|
fn load_tree(config: &ironstorage::config::Config) -> Result<ironstorage::read::TreeModel, String> {
|
||||||
let repository = ironstorage::repository::Repository::open(config.vault())
|
let repository = ironstorage::repository::Repository::open(config.vault())
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
//! Small in-process executor for storage calls. It never launches a process.
|
//! Small in-process executor for storage calls. It never launches a process.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
sync::mpsc::{self, Receiver, Sender},
|
sync::{
|
||||||
|
Mutex,
|
||||||
|
mpsc::{self, Receiver, Sender},
|
||||||
|
},
|
||||||
thread,
|
thread,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
@@ -17,6 +20,7 @@ use crate::app::{AsyncPayload, AsyncResult, RequestToken};
|
|||||||
pub struct AsyncExecutor {
|
pub struct AsyncExecutor {
|
||||||
sender: Sender<AsyncResult>,
|
sender: Sender<AsyncResult>,
|
||||||
receiver: Receiver<AsyncResult>,
|
receiver: Receiver<AsyncResult>,
|
||||||
|
tasks: Mutex<Vec<thread::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
@@ -147,7 +151,11 @@ impl Default for AsyncExecutor {
|
|||||||
impl AsyncExecutor {
|
impl AsyncExecutor {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (sender, receiver) = mpsc::channel();
|
let (sender, receiver) = mpsc::channel();
|
||||||
Self { sender, receiver }
|
Self {
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
tasks: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn submit<F>(&self, token: RequestToken, work: F)
|
pub fn submit<F>(&self, token: RequestToken, work: F)
|
||||||
@@ -155,12 +163,16 @@ impl AsyncExecutor {
|
|||||||
F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static,
|
F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static,
|
||||||
{
|
{
|
||||||
let sender = self.sender.clone();
|
let sender = self.sender.clone();
|
||||||
thread::spawn(move || {
|
let task = thread::spawn(move || {
|
||||||
let _ignored = sender.send(AsyncResult {
|
let _ignored = sender.send(AsyncResult {
|
||||||
token,
|
token,
|
||||||
payload: work(),
|
payload: work(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
self.tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
|
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
|
||||||
@@ -168,6 +180,18 @@ impl AsyncExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for AsyncExecutor {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let tasks = self
|
||||||
|
.tasks
|
||||||
|
.get_mut()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
for task in tasks.drain(..) {
|
||||||
|
let _ignored = task.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use ratatui::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
action::available_actions,
|
action::available_actions,
|
||||||
app::{App, Mode, PaneFocus},
|
app::{App, Mode, PaneFocus},
|
||||||
|
viewer::EntryViewer,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MINIMUM_WIDTH: u16 = 40;
|
const MINIMUM_WIDTH: u16 = 40;
|
||||||
@@ -124,13 +125,23 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
.wrap(Wrap { trim: false }),
|
.wrap(Wrap { trim: false }),
|
||||||
panes[0],
|
panes[0],
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
let main = if app.mode() == Mode::Viewer {
|
||||||
|
app.viewer().map_or_else(
|
||||||
|
|| Paragraph::new(main_text(app)),
|
||||||
|
|viewer| {
|
||||||
|
Paragraph::new(viewer_lines(viewer))
|
||||||
|
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Paragraph::new(main_text(app))
|
Paragraph::new(main_text(app))
|
||||||
.block(pane_block(
|
};
|
||||||
mode_title(app.mode()),
|
frame.render_widget(
|
||||||
app.focus() == PaneFocus::Main,
|
main.block(pane_block(
|
||||||
))
|
mode_title(app.mode()),
|
||||||
.wrap(Wrap { trim: true }),
|
app.focus() == PaneFocus::Main,
|
||||||
|
))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
panes[1],
|
panes[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -208,6 +219,76 @@ fn main_text(app: &App) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
|
||||||
|
let focused = viewer.focused_index();
|
||||||
|
if viewer.document().fields().is_empty() {
|
||||||
|
return vec![Line::from("This entry is empty.")];
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer
|
||||||
|
.document()
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, field)| {
|
||||||
|
let metadata = field.metadata();
|
||||||
|
let label = metadata.name().map_or_else(
|
||||||
|
|| match metadata.kind() {
|
||||||
|
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
|
||||||
|
ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1),
|
||||||
|
kind => format!("{kind:?}").to_ascii_lowercase(),
|
||||||
|
},
|
||||||
|
str::to_owned,
|
||||||
|
);
|
||||||
|
let value = if metadata.sensitivity()
|
||||||
|
== ironstorage::document::EntrySensitivity::Sensitive
|
||||||
|
&& !viewer.is_revealed(field.id())
|
||||||
|
{
|
||||||
|
Span::styled("••••••••", Style::default().fg(Color::DarkGray))
|
||||||
|
} else if field.value().is_empty() {
|
||||||
|
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
|
||||||
|
} else {
|
||||||
|
match std::str::from_utf8(field.value()) {
|
||||||
|
Ok(value) => Span::raw(value),
|
||||||
|
Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{label}: "),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
value,
|
||||||
|
];
|
||||||
|
if let Some(otp) = metadata.otp() {
|
||||||
|
let timing = otp.period().map_or_else(
|
||||||
|
|| format!("counter {}", otp.counter().unwrap_or_default()),
|
||||||
|
|period| format!("period {period}s"),
|
||||||
|
);
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(
|
||||||
|
" [{:?}, {}, {:?}, {}, {} digits, {timing}]",
|
||||||
|
otp.kind(),
|
||||||
|
otp.issuer().unwrap_or("no issuer"),
|
||||||
|
otp.algorithm(),
|
||||||
|
otp.account(),
|
||||||
|
otp.digits(),
|
||||||
|
),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let line = Line::from(spans);
|
||||||
|
if focused == Some(index) {
|
||||||
|
line.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||||
|
} else {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn mode_title(mode: Mode) -> &'static str {
|
fn mode_title(mode: Mode) -> &'static str {
|
||||||
match mode {
|
match mode {
|
||||||
Mode::Browser => "Browser",
|
Mode::Browser => "Browser",
|
||||||
@@ -264,6 +345,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::Transition;
|
use crate::app::Transition;
|
||||||
use crate::sidebar::TestTreeNode;
|
use crate::sidebar::TestTreeNode;
|
||||||
|
use crate::viewer::test_support::fixture_document;
|
||||||
|
|
||||||
fn render(width: u16, height: u16, app: &App) -> String {
|
fn render(width: u16, height: u16, app: &App) -> String {
|
||||||
let backend = TestBackend::new(width, height);
|
let backend = TestBackend::new(width, height);
|
||||||
@@ -384,4 +466,77 @@ mod tests {
|
|||||||
let output = render(100, 20, &app);
|
let output = render(100, 20, &app);
|
||||||
assert!(output.contains("locks in 30s"));
|
assert!(output.contains("locks in 30s"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
|
||||||
|
for width in [60, 100, 140] {
|
||||||
|
let output = render(width, 20, &app);
|
||||||
|
assert!(output.contains("password: ••••••••"));
|
||||||
|
assert!(output.contains("login:"));
|
||||||
|
assert!(output.contains('用'));
|
||||||
|
assert!(output.contains("@example.test"));
|
||||||
|
assert!(output.contains("notes: ••••••••"));
|
||||||
|
assert!(!output.contains("pässwörd-猫"));
|
||||||
|
}
|
||||||
|
assert!(!format!("{app:?}").contains("pässwörd-猫"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||||
|
app.dispatch(crate::action::Action::Reveal);
|
||||||
|
let revealed = render(100, 20, &app);
|
||||||
|
assert!(revealed.contains("correct horse fixture"));
|
||||||
|
|
||||||
|
app.dispatch(crate::action::Action::FocusNext);
|
||||||
|
let hidden = render(100, 20, &app);
|
||||||
|
assert!(!hidden.contains("correct horse fixture"));
|
||||||
|
|
||||||
|
app.dispatch(crate::action::Action::FocusPrevious);
|
||||||
|
app.dispatch(crate::action::Action::Reveal);
|
||||||
|
app.dispatch(crate::action::Action::Help);
|
||||||
|
app.dispatch(crate::action::Action::Cancel);
|
||||||
|
let after_overlay = render(100, 20, &app);
|
||||||
|
assert!(!after_overlay.contains("correct horse fixture"));
|
||||||
|
|
||||||
|
app.dispatch(crate::action::Action::Reveal);
|
||||||
|
app.forced_relock("test lock");
|
||||||
|
let locked = render(100, 20, &app);
|
||||||
|
assert!(!locked.contains("correct horse fixture"));
|
||||||
|
assert!(locked.contains("locked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.open_test_document("otp/totp", fixture_document("otp/totp"));
|
||||||
|
app.dispatch(crate::action::Action::FocusNext);
|
||||||
|
let output = render(120, 20, &app);
|
||||||
|
assert!(output.contains("Totp"));
|
||||||
|
assert!(output.contains("IronStorage"));
|
||||||
|
assert!(output.contains("alice@example.test"));
|
||||||
|
assert!(output.contains("period 30s"));
|
||||||
|
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_a_document_preserves_the_sidebar_selection() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
|
||||||
|
path: "email/personal".to_owned(),
|
||||||
|
name: "personal".to_owned(),
|
||||||
|
directory: false,
|
||||||
|
indicators: ironstorage::read::TreeNodeIndicators::default(),
|
||||||
|
children: vec![],
|
||||||
|
}]);
|
||||||
|
let selected = app.sidebar().selected().cloned();
|
||||||
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||||
|
app.dispatch(crate::action::Action::CloseEntry);
|
||||||
|
assert_eq!(app.mode(), Mode::Browser);
|
||||||
|
assert_eq!(app.sidebar().selected(), selected.as_ref());
|
||||||
|
assert!(app.viewer().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
194
apps/tui/src/viewer.rs
Normal file
194
apps/tui/src/viewer.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//! Secret-aware presentation state for one storage-owned entry document.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use ironstorage::{
|
||||||
|
document::{DocumentError, EntryDocument, EntryField, EntryFieldId, EntrySensitivity},
|
||||||
|
repository::SecretBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Focus, masking, and scrolling state for an authenticated document.
|
||||||
|
///
|
||||||
|
/// The document remains the source of field order, labels, kinds, sensitivity,
|
||||||
|
/// and values. This type only tracks transient presentation choices.
|
||||||
|
pub struct EntryViewer {
|
||||||
|
document: EntryDocument,
|
||||||
|
focused: usize,
|
||||||
|
revealed: Option<EntryFieldId>,
|
||||||
|
scroll: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntryViewer {
|
||||||
|
pub fn new(document: EntryDocument) -> Self {
|
||||||
|
Self {
|
||||||
|
document,
|
||||||
|
focused: 0,
|
||||||
|
revealed: None,
|
||||||
|
scroll: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn document(&self) -> &EntryDocument {
|
||||||
|
&self.document
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focused_index(&self) -> Option<usize> {
|
||||||
|
(!self.document.fields().is_empty()).then_some(self.focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focused_field(&self) -> Option<&EntryField> {
|
||||||
|
self.document.fields().get(self.focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_revealed(&self, id: EntryFieldId) -> bool {
|
||||||
|
self.revealed == Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focus_next(&mut self) {
|
||||||
|
self.hide_revealed();
|
||||||
|
if !self.document.fields().is_empty() {
|
||||||
|
self.focused = (self.focused + 1) % self.document.fields().len();
|
||||||
|
self.scroll = self.focused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focus_previous(&mut self) {
|
||||||
|
self.hide_revealed();
|
||||||
|
if !self.document.fields().is_empty() {
|
||||||
|
self.focused = self
|
||||||
|
.focused
|
||||||
|
.checked_sub(1)
|
||||||
|
.unwrap_or(self.document.fields().len() - 1);
|
||||||
|
self.scroll = self.focused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reveal_focused(&mut self) -> bool {
|
||||||
|
let Some(field) = self.focused_field() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if field.metadata().sensitivity() != EntrySensitivity::Sensitive {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.revealed = Some(field.id());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hide_revealed(&mut self) -> bool {
|
||||||
|
self.revealed.take().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_focused(&self) -> Result<SecretBytes, DocumentError> {
|
||||||
|
let field = self.focused_field().ok_or(DocumentError::InvalidIndex {
|
||||||
|
index: self.focused,
|
||||||
|
})?;
|
||||||
|
self.document.copy_field_value(field.id())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll(&self) -> usize {
|
||||||
|
self.scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_down(&mut self, rows: usize) {
|
||||||
|
self.scroll = self.scroll.saturating_add(rows).min(self.scroll_limit());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_up(&mut self, rows: usize) {
|
||||||
|
self.scroll = self.scroll.saturating_sub(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_limit(&self) -> usize {
|
||||||
|
self.document
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.map(|field| field.value().len().saturating_add(1))
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for EntryViewer {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("EntryViewer")
|
||||||
|
.field("document", &self.document)
|
||||||
|
.field("focused", &self.focused)
|
||||||
|
.field("revealed", &self.revealed.map(|_| "[REDACTED]"))
|
||||||
|
.field("scroll", &self.scroll)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_support {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use ironstorage::{
|
||||||
|
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||||
|
document::{EntryDocument, EntryDocumentService},
|
||||||
|
repository::{Repository, SecretBytes},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FixtureSecrets;
|
||||||
|
|
||||||
|
impl SecretProvider for FixtureSecrets {
|
||||||
|
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||||
|
let passphrase = match key.fingerprint().as_str() {
|
||||||
|
"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30" => b"fixture-alice-passphrase".to_vec(),
|
||||||
|
"B37027B56FC406BD3F6A622B2AC03492B992D06F" => b"fixture-bob-passphrase".to_vec(),
|
||||||
|
_ => return Err(SecretProviderError::Unavailable),
|
||||||
|
};
|
||||||
|
Ok(SecretBytes::new(passphrase))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fixture_document(entry: &str) -> EntryDocument {
|
||||||
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
||||||
|
let repository = Repository::open(root.join("stores/basic")).expect("fixture repository");
|
||||||
|
let keys = KeyStore::load(root.join("keys")).expect("fixture keys");
|
||||||
|
EntryDocumentService::new(&repository, &keys)
|
||||||
|
.open(entry, &mut FixtureSecrets)
|
||||||
|
.expect("fixture document")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{test_support::fixture_document, *};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_wraps_and_always_hides_a_revealed_secret() {
|
||||||
|
let mut viewer = EntryViewer::new(fixture_document("email/personal"));
|
||||||
|
assert!(viewer.reveal_focused());
|
||||||
|
let password = viewer.focused_field().expect("password").id();
|
||||||
|
assert!(viewer.is_revealed(password));
|
||||||
|
|
||||||
|
viewer.focus_next();
|
||||||
|
assert!(!viewer.is_revealed(password));
|
||||||
|
assert_eq!(viewer.focused_index(), Some(1));
|
||||||
|
viewer.focus_previous();
|
||||||
|
assert_eq!(viewer.focused_index(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_uses_only_the_focused_structured_value() {
|
||||||
|
let mut viewer = EntryViewer::new(fixture_document("email/personal"));
|
||||||
|
viewer.focus_next();
|
||||||
|
let copied = viewer.copy_focused().expect("copy value");
|
||||||
|
assert_eq!(copied.expose(), b"alice@example.test");
|
||||||
|
assert!(!format!("{viewer:?}").contains("correct horse fixture"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_fields_do_not_gain_reveal_state_and_scroll_saturates() {
|
||||||
|
let mut viewer = EntryViewer::new(fixture_document("unicode/咖啡"));
|
||||||
|
viewer.focus_next();
|
||||||
|
assert!(!viewer.reveal_focused());
|
||||||
|
viewer.scroll_down(usize::MAX);
|
||||||
|
let end = viewer.scroll();
|
||||||
|
viewer.scroll_down(1);
|
||||||
|
assert_eq!(viewer.scroll(), end);
|
||||||
|
viewer.scroll_up(usize::MAX);
|
||||||
|
assert_eq!(viewer.scroll(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use sha2::{Digest as _, Sha256};
|
|||||||
use crate::{
|
use crate::{
|
||||||
command::EditRequest,
|
command::EditRequest,
|
||||||
crypto::{KeyStore, SecretProvider},
|
crypto::{KeyStore, SecretProvider},
|
||||||
otp::OtpUri,
|
otp::{OtpAlgorithm, OtpKind, OtpUri},
|
||||||
recipient::SigningPolicy,
|
recipient::SigningPolicy,
|
||||||
repository::{EntryPath, Repository, SecretBytes},
|
repository::{EntryPath, Repository, SecretBytes},
|
||||||
write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome},
|
write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome},
|
||||||
@@ -46,6 +46,7 @@ pub struct EntryFieldMetadata {
|
|||||||
kind: EntryFieldKind,
|
kind: EntryFieldKind,
|
||||||
sensitivity: EntrySensitivity,
|
sensitivity: EntrySensitivity,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
otp: Option<EntryOtpMetadata>,
|
||||||
value: Range<usize>,
|
value: Range<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,52 @@ impl EntryFieldMetadata {
|
|||||||
pub fn name(&self) -> Option<&str> {
|
pub fn name(&self) -> Option<&str> {
|
||||||
self.name.as_deref()
|
self.name.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn otp(&self) -> Option<&EntryOtpMetadata> {
|
||||||
|
self.otp.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-secret presentation metadata parsed from a validated `otpauth` URI.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct EntryOtpMetadata {
|
||||||
|
kind: OtpKind,
|
||||||
|
issuer: Option<String>,
|
||||||
|
account: String,
|
||||||
|
algorithm: OtpAlgorithm,
|
||||||
|
digits: u32,
|
||||||
|
period: Option<u64>,
|
||||||
|
counter: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntryOtpMetadata {
|
||||||
|
pub fn kind(&self) -> OtpKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issuer(&self) -> Option<&str> {
|
||||||
|
self.issuer.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account(&self) -> &str {
|
||||||
|
&self.account
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn algorithm(&self) -> OtpAlgorithm {
|
||||||
|
self.algorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn digits(&self) -> u32 {
|
||||||
|
self.digits
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn period(&self) -> Option<u64> {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn counter(&self) -> Option<u64> {
|
||||||
|
self.counter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EntryField {
|
pub struct EntryField {
|
||||||
@@ -205,6 +252,16 @@ impl EntryDocument {
|
|||||||
self.fields.first()
|
self.fields.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy one structured field value into an independently zeroizing buffer.
|
||||||
|
///
|
||||||
|
/// Frontends use this for explicit presentation actions without reparsing
|
||||||
|
/// the pass entry or copying label syntax alongside the selected value.
|
||||||
|
pub fn copy_field_value(&self, id: EntryFieldId) -> Result<SecretBytes, DocumentError> {
|
||||||
|
self.field(id)
|
||||||
|
.map(|field| SecretBytes::new(field.value().to_vec()))
|
||||||
|
.ok_or(DocumentError::UnknownField { id })
|
||||||
|
}
|
||||||
|
|
||||||
pub fn conflict_token(&self) -> DocumentConflictToken {
|
pub fn conflict_token(&self) -> DocumentConflictToken {
|
||||||
self.conflict_token
|
self.conflict_token
|
||||||
}
|
}
|
||||||
@@ -440,17 +497,30 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
|||||||
kind: EntryFieldKind::Password,
|
kind: EntryFieldKind::Password,
|
||||||
sensitivity: EntrySensitivity::Sensitive,
|
sensitivity: EntrySensitivity::Sensitive,
|
||||||
name: Some("password".to_owned()),
|
name: Some("password".to_owned()),
|
||||||
|
otp: None,
|
||||||
value: 0..line.len(),
|
value: 0..line.len(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
return blank_metadata();
|
return blank_metadata();
|
||||||
}
|
}
|
||||||
if line.starts_with(b"otpauth://") && OtpUri::parse(SecretBytes::new(line.to_vec())).is_ok() {
|
if line.starts_with(b"otpauth://")
|
||||||
|
&& let Ok(uri) = OtpUri::parse(SecretBytes::new(line.to_vec()))
|
||||||
|
{
|
||||||
|
let otp = EntryOtpMetadata {
|
||||||
|
kind: uri.kind(),
|
||||||
|
issuer: uri.issuer().map(str::to_owned),
|
||||||
|
account: uri.account().to_owned(),
|
||||||
|
algorithm: uri.algorithm(),
|
||||||
|
digits: uri.digits(),
|
||||||
|
period: uri.period(),
|
||||||
|
counter: uri.counter(),
|
||||||
|
};
|
||||||
return EntryFieldMetadata {
|
return EntryFieldMetadata {
|
||||||
kind: EntryFieldKind::OtpUri,
|
kind: EntryFieldKind::OtpUri,
|
||||||
sensitivity: EntrySensitivity::Sensitive,
|
sensitivity: EntrySensitivity::Sensitive,
|
||||||
name: Some("otp".to_owned()),
|
name: Some("otp".to_owned()),
|
||||||
|
otp: Some(otp),
|
||||||
value: 0..line.len(),
|
value: 0..line.len(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -465,6 +535,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
|||||||
kind,
|
kind,
|
||||||
sensitivity: field_sensitivity(name, kind),
|
sensitivity: field_sensitivity(name, kind),
|
||||||
name: Some(name.to_owned()),
|
name: Some(name.to_owned()),
|
||||||
|
otp: None,
|
||||||
value: value_start..line.len(),
|
value: value_start..line.len(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -473,6 +544,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
|||||||
kind: EntryFieldKind::Note,
|
kind: EntryFieldKind::Note,
|
||||||
sensitivity: EntrySensitivity::Sensitive,
|
sensitivity: EntrySensitivity::Sensitive,
|
||||||
name: None,
|
name: None,
|
||||||
|
otp: None,
|
||||||
value: 0..line.len(),
|
value: 0..line.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,6 +576,7 @@ fn blank_metadata() -> EntryFieldMetadata {
|
|||||||
kind: EntryFieldKind::Blank,
|
kind: EntryFieldKind::Blank,
|
||||||
sensitivity: EntrySensitivity::Empty,
|
sensitivity: EntrySensitivity::Empty,
|
||||||
name: None,
|
name: None,
|
||||||
|
otp: None,
|
||||||
value: 0..0,
|
value: 0..0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,19 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
document.fields()[4].metadata().sensitivity(),
|
document.fields()[4].metadata().sensitivity(),
|
||||||
EntrySensitivity::Sensitive
|
EntrySensitivity::Sensitive
|
||||||
);
|
);
|
||||||
|
let otp = document.fields()[4]
|
||||||
|
.metadata()
|
||||||
|
.otp()
|
||||||
|
.expect("validated OTP metadata");
|
||||||
|
assert_eq!(otp.kind(), ironstorage::otp::OtpKind::Totp);
|
||||||
|
assert_eq!(otp.issuer(), Some("Example"));
|
||||||
|
assert_eq!(otp.account(), "alice");
|
||||||
|
assert_eq!(otp.algorithm(), ironstorage::otp::OtpAlgorithm::Sha1);
|
||||||
|
assert_eq!(otp.digits(), 6);
|
||||||
|
assert_eq!(otp.period(), Some(30));
|
||||||
|
assert_eq!(otp.counter(), None);
|
||||||
|
let copied = document.copy_field_value(document.fields()[2].id())?;
|
||||||
|
assert_eq!(copied.expose(), b"one");
|
||||||
assert!(!format!("{document:?}").contains("pässwörd"));
|
assert!(!format!("{document:?}").contains("pässwörd"));
|
||||||
assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP"));
|
assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP"));
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user