Files
IronStorage/apps/desktop/src/main.rs

1616 lines
58 KiB
Rust

#![forbid(unsafe_code)]
#![deny(clippy::disallowed_types)]
mod editor;
mod navigation;
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use editor::EntryEditor;
use iced::{
Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch,
widget::{button, column, container, pane_grid, row, scrollable, text, text_input},
window,
};
use ironstorage::{
authentication::{
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
NativeAuthenticationHandle, NativeAuthenticationSession,
},
config::Config,
crypto::{KeyInfo, KeyStore},
document::{DocumentError, EntryDocument, EntryFieldId, EntryFieldKind, EntrySensitivity},
generate::GeneratorConfig,
git::{AutomaticEntryCommitter, GitIdentity},
presentation::{ClipboardWait, NativeClipboardManager},
read::{TreeModel, TreeNodeId},
repository::{Repository, SecretBytes},
secret_store::{SecretProtectionPolicy, SecretStoreBackend},
write::{WriteError, WriteOutcome},
};
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
use zeroize::Zeroizing;
type OpenCompletion = Arc<Mutex<Option<Result<EntryDocument, String>>>>;
type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, SaveFailure>)>>>;
type TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
#[derive(Clone)]
enum Message {
StartupLoaded(Box<Result<(Config, NativeAuthenticationSession, KeyInfo), String>>),
TreeLoaded {
generation: u64,
completion: TreeCompletion,
},
RefreshTree,
SidebarActivate(TreeNodeId),
SidebarNavigate(NavigationKey),
TogglePaneFocus,
PaneResized(pane_grid::ResizeEvent),
EntryPathChanged(String),
OpenEntry,
OpenFinished {
generation: u64,
entry: String,
completion: OpenCompletion,
},
AuthenticationFinished {
generation: u64,
result: Result<NativeAuthenticationHandle, String>,
},
FieldChanged(EntryFieldId, Zeroizing<String>),
AddAfter(Option<EntryFieldId>),
Remove(EntryFieldId),
MoveUp(EntryFieldId),
MoveDown(EntryFieldId),
ToggleReveal(EntryFieldId),
RequestGenerate(EntryFieldId),
ConfirmGenerate,
CancelGenerate,
Copy(EntryFieldId),
CopyFinished {
generation: u64,
result: Result<String, String>,
},
Save,
SaveFinished {
generation: u64,
completion: SaveCompletion,
},
RequestReload,
RequestClose(window::Id),
ConfirmSave,
ConfirmDiscard,
CancelDiscard,
ReloadConflict,
KeepConflictDraft,
UserActivity,
Tick,
Lock,
}
#[derive(Debug)]
enum AuthenticationView {
Loading,
Locked,
Authenticating,
Unlocked(Duration),
Unavailable(String),
}
#[derive(Default)]
struct SensitiveUiState {
clipboard_cancel: Option<Arc<AtomicBool>>,
clipboard_generation: u64,
}
impl SensitiveUiState {
fn clear(&mut self) {
self.clipboard_generation = self.clipboard_generation.wrapping_add(1);
if let Some(cancel) = self.clipboard_cancel.take() {
cancel.store(true, Ordering::Release);
}
}
fn begin_copy(&mut self) -> (u64, Arc<AtomicBool>) {
self.clear();
let cancel = Arc::new(AtomicBool::new(false));
self.clipboard_cancel = Some(Arc::clone(&cancel));
(self.clipboard_generation, cancel)
}
fn finish_copy(&mut self, generation: u64) -> bool {
if generation != self.clipboard_generation {
return false;
}
self.clipboard_cancel = None;
true
}
}
struct App {
authentication: AuthenticationView,
config: Option<Config>,
session: Option<NativeAuthenticationSession>,
key: Option<KeyInfo>,
handle: Option<NativeAuthenticationHandle>,
sensitive: SensitiveUiState,
authentication_generation: u64,
operation_generation: u64,
tree_generation: u64,
panes: pane_grid::State<PaneKind>,
pane_focus: PaneFocus,
navigation: NavigationTree,
tree_state: TreeState,
after_authentication: Option<PendingAction>,
entry_path: String,
editor: Option<EntryEditor>,
saving: bool,
confirmation: Option<PendingAction>,
after_save: Option<PendingAction>,
generate_confirmation: Option<EntryFieldId>,
conflict: bool,
status: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PaneKind {
Sidebar,
Content,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PaneFocus {
Sidebar,
Content,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum TreeState {
Loading,
Ready,
Error(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum PendingAction {
OpenEntry(String),
Reload(String),
CloseWindow(window::Id),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DirtyDecision {
Confirm,
Execute,
}
#[derive(Debug, Eq, PartialEq)]
enum LeasePoll {
Idle,
Active(Duration),
Expired,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SaveFailureKind {
Conflict,
Unchanged,
Storage,
}
#[derive(Debug)]
struct SaveFailure {
kind: SaveFailureKind,
message: String,
}
fn main() -> iced::Result {
iced::application(App::new, App::update, App::view)
.title(ironstorage::PRODUCT_NAME)
.subscription(App::subscription)
.exit_on_close_request(false)
.window(window::Settings {
size: Size::new(1_080.0, 720.0),
min_size: Some(Size::new(720.0, 480.0)),
..window::Settings::default()
})
.run()
}
impl App {
fn new() -> (Self, Task<Message>) {
(
Self {
authentication: AuthenticationView::Loading,
config: None,
session: None,
key: None,
handle: None,
sensitive: SensitiveUiState::default(),
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
}),
pane_focus: PaneFocus::Sidebar,
navigation: NavigationTree::default(),
tree_state: TreeState::Loading,
after_authentication: None,
entry_path: String::new(),
editor: None,
saving: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
conflict: false,
status: "Loading shared configuration…".to_owned(),
},
Task::perform(load_authentication(), |result| {
Message::StartupLoaded(Box::new(result))
}),
)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::StartupLoaded(result) => match *result {
Ok((config, session, key)) => {
self.config = Some(config);
self.session = Some(session);
self.key = Some(key);
self.authentication = AuthenticationView::Locked;
self.status = "Entry names remain available while protected content is locked."
.to_owned();
return self.begin_tree_refresh();
}
Err(error) => {
self.tree_state = TreeState::Error(error.clone());
self.authentication = AuthenticationView::Unavailable(error.clone());
self.status = error;
}
},
Message::TreeLoaded {
generation,
completion,
} => {
let Some(result) = take_completion(&completion) else {
return Task::none();
};
if generation != self.tree_generation {
return Task::none();
}
match result {
Ok(model) => {
self.navigation.replace(&model);
self.tree_state = TreeState::Ready;
self.status = if self.navigation.is_empty() {
"The password store is empty.".to_owned()
} else {
"Password-store tree refreshed.".to_owned()
};
}
Err(error) => {
self.tree_state = TreeState::Error(error.clone());
self.status = format!("Tree refresh failed: {error}");
}
}
}
Message::RefreshTree => return self.begin_tree_refresh(),
Message::SidebarActivate(id) => {
self.touch_user_activity();
self.pane_focus = PaneFocus::Sidebar;
let intent = self.navigation.activate(id);
return self.handle_navigation(intent);
}
Message::SidebarNavigate(key) => {
self.touch_user_activity();
if self.pane_focus == PaneFocus::Sidebar {
let intent = self.navigation.navigate(key);
return self.handle_navigation(intent);
}
}
Message::TogglePaneFocus => {
self.touch_user_activity();
self.pane_focus = match self.pane_focus {
PaneFocus::Sidebar => PaneFocus::Content,
PaneFocus::Content => PaneFocus::Sidebar,
};
}
Message::PaneResized(event) => {
self.panes
.resize(event.split, event.ratio.clamp(0.18, 0.55));
}
Message::EntryPathChanged(path) => {
self.pane_focus = PaneFocus::Content;
self.entry_path = path;
}
Message::OpenEntry => {
self.pane_focus = PaneFocus::Content;
let entry = self.entry_path.trim().to_owned();
if entry.is_empty() {
self.status = "Enter an entry path first.".to_owned();
} else {
return self.request_action(PendingAction::OpenEntry(entry));
}
}
Message::OpenFinished {
generation,
entry,
completion,
} => {
let Some(result) = take_completion(&completion) else {
return Task::none();
};
if generation != self.operation_generation {
return Task::none();
}
match result {
Ok(document) => {
self.entry_path = entry.clone();
let _selected = self.navigation.select_entry_path(&entry);
self.pane_focus = PaneFocus::Content;
self.editor = Some(EntryEditor::new(document));
self.conflict = false;
self.status = format!("Editing {entry}");
}
Err(error) => self.status = format!("Open failed: {error}"),
}
}
Message::AuthenticationFinished { generation, result } => {
if generation != self.authentication_generation {
if result.is_ok()
&& let Some(session) = &self.session
{
let _ignored = session.manual_lock();
}
return Task::none();
}
match result {
Ok(handle) => match handle.remaining_time() {
Ok(remaining) => {
self.handle = Some(handle);
self.authentication = AuthenticationView::Unlocked(remaining);
self.status = "Protected content is unlocked.".to_owned();
if let Some(action) = self.after_authentication.take() {
return self.execute_action(action);
}
}
Err(error) => self.authentication_lost(error.to_string()),
},
Err(error) => {
self.authentication_lost(error.clone());
self.status = format!("Authentication failed: {error}");
}
}
}
Message::FieldChanged(id, value) => {
self.pane_focus = PaneFocus::Content;
self.edit(|editor| editor.update_raw(id, value.as_bytes()));
}
Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)),
Message::Remove(id) => self.edit(|editor| editor.remove(id)),
Message::MoveUp(id) => self.edit(|editor| editor.move_up(id)),
Message::MoveDown(id) => self.edit(|editor| editor.move_down(id)),
Message::ToggleReveal(id) => self.edit(|editor| editor.toggle_reveal(id)),
Message::RequestGenerate(id) => {
let has_value = self
.editor
.as_ref()
.and_then(|editor| editor.document().field(id))
.is_some_and(|field| !field.value().is_empty());
if has_value {
self.generate_confirmation = Some(id);
} else {
self.generate(id);
}
}
Message::ConfirmGenerate => {
if let Some(id) = self.generate_confirmation.take() {
self.generate(id);
}
}
Message::CancelGenerate => self.generate_confirmation = None,
Message::Copy(id) => {
let (Some(config), Some(editor)) = (&self.config, &self.editor) else {
return Task::none();
};
let value = match editor.copy_value(id) {
Ok(value) => value,
Err(error) => {
self.status = error.to_string();
return Task::none();
}
};
let (generation, cancel) = self.sensitive.begin_copy();
self.status = "Copied; automatic clipboard cleanup is active.".to_owned();
return Task::perform(
copy_to_clipboard(value, config.clipboard_timeout(), cancel),
move |result| Message::CopyFinished { generation, result },
);
}
Message::CopyFinished { generation, result } => {
if self.sensitive.finish_copy(generation) {
self.status = result.unwrap_or_else(|error| format!("Clipboard: {error}"));
}
}
Message::Save => return self.begin_save(),
Message::SaveFinished {
generation,
completion,
} => {
let Some((editor, result)) = take_completion(&completion) else {
return Task::none();
};
if generation != self.operation_generation {
return Task::none();
}
self.saving = false;
match result {
Ok(outcome) => {
self.editor = None;
self.conflict = false;
if let Some(action) = self.after_save.take() {
return self.execute_action(action);
}
return self.begin_open(outcome.path().to_string());
}
Err(error) => {
self.conflict = error.kind == SaveFailureKind::Conflict;
self.status = format!("Save failed: {}. Draft retained.", error.message);
self.editor = Some(editor);
self.confirmation = self.after_save.take();
}
}
}
Message::RequestReload => {
if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) {
return self.request_action(PendingAction::Reload(entry));
}
}
Message::RequestClose(id) => {
return self.request_action(PendingAction::CloseWindow(id));
}
Message::ConfirmSave => {
self.after_save = self.confirmation.take();
return self.begin_save();
}
Message::ConfirmDiscard => {
let action = self.confirmation.take();
self.editor = None;
self.conflict = false;
if let Some(action) = action {
return self.execute_action(action);
}
}
Message::CancelDiscard => self.confirmation = None,
Message::ReloadConflict => {
if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) {
self.editor = None;
self.conflict = false;
return self.execute_action(PendingAction::Reload(entry));
}
}
Message::KeepConflictDraft => self.conflict = false,
Message::UserActivity => self.touch_user_activity(),
Message::Tick => {
if let Some(session) = &self.session {
match poll_lease(session, &mut self.handle, &mut self.sensitive) {
Ok(LeasePoll::Active(remaining)) => {
self.authentication = AuthenticationView::Unlocked(remaining);
}
Ok(LeasePoll::Expired) => {
self.authentication_lost("the authentication lease expired".to_owned());
}
Ok(LeasePoll::Idle) => {}
Err(error) => self.authentication_lost(error.to_string()),
}
}
}
Message::Lock => {
self.authentication_generation = self.authentication_generation.wrapping_add(1);
let result = self
.session
.as_ref()
.map_or(Ok(()), NativeAuthenticationSession::manual_lock);
self.authentication_lost("manually locked".to_owned());
if let Err(error) = result {
self.authentication = AuthenticationView::Unavailable(error.to_string());
self.status = format!("Lock failed: {error}");
}
}
}
Task::none()
}
fn begin_tree_refresh(&mut self) -> Task<Message> {
let Some(config) = self.config.clone() else {
return Task::none();
};
self.tree_generation = self.tree_generation.wrapping_add(1);
let generation = self.tree_generation;
self.tree_state = TreeState::Loading;
Task::perform(
async move { Arc::new(Mutex::new(Some(load_tree(&config)))) },
move |completion| Message::TreeLoaded {
generation,
completion,
},
)
}
fn handle_navigation(&mut self, intent: NavigationIntent) -> Task<Message> {
match intent {
NavigationIntent::OpenEntry(entry) => {
self.request_action(PendingAction::OpenEntry(entry))
}
NavigationIntent::None => iced::widget::operation::snap_to(
sidebar_scroll_id(),
scrollable::RelativeOffset {
x: 0.0,
y: self.navigation.selected_ratio(),
},
),
}
}
fn request_action(&mut self, action: PendingAction) -> Task<Message> {
if self.saving {
self.after_save = Some(action);
self.status = "Waiting for the active save to finish…".to_owned();
return Task::none();
}
if dirty_decision(self.editor.as_ref()) == DirtyDecision::Confirm {
if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) {
let _restored = self.navigation.select_entry_path(&entry);
}
self.confirmation = Some(action);
Task::none()
} else {
self.execute_action(action)
}
}
fn execute_action(&mut self, action: PendingAction) -> Task<Message> {
match action {
PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => {
self.editor = None;
self.begin_open(entry)
}
PendingAction::CloseWindow(id) => {
self.sensitive.clear();
window::close(id)
}
}
}
fn begin_open(&mut self, entry: String) -> Task<Message> {
let (Some(config), Some(handle)) = (self.config.clone(), self.handle.clone()) else {
self.after_authentication = Some(PendingAction::OpenEntry(entry));
return self.begin_authentication();
};
self.operation_generation = self.operation_generation.wrapping_add(1);
let generation = self.operation_generation;
self.status = format!("Opening {entry}");
Task::perform(
async move {
let result = load_document(&config, &entry, handle);
let completion = Arc::new(Mutex::new(Some(result)));
(entry, completion)
},
move |(entry, completion)| Message::OpenFinished {
generation,
entry,
completion,
},
)
}
fn begin_authentication(&mut self) -> Task<Message> {
let (Some(session), Some(key)) = (self.session.clone(), self.key.clone()) else {
return Task::none();
};
if matches!(self.authentication, AuthenticationView::Authenticating) {
return Task::none();
}
self.authentication_generation = self.authentication_generation.wrapping_add(1);
let generation = self.authentication_generation;
self.authentication = AuthenticationView::Authenticating;
self.status = "Waiting for secure-storage authentication…".to_owned();
Task::perform(
async move {
session
.authenticate(&key)
.map_err(|error| error.to_string())
},
move |result| Message::AuthenticationFinished { generation, result },
)
}
fn begin_save(&mut self) -> Task<Message> {
self.touch_user_activity();
let (Some(config), Some(handle)) = (self.config.clone(), self.handle.clone()) else {
return Task::none();
};
let Some(editor) = self.editor.take() else {
return Task::none();
};
if !editor.is_dirty() {
self.editor = Some(editor);
self.status = "The document has no changes to save.".to_owned();
return Task::none();
}
self.operation_generation = self.operation_generation.wrapping_add(1);
let generation = self.operation_generation;
self.saving = true;
self.status = format!("Saving {}", editor.entry());
Task::perform(
async move {
let result = handle
.ensure_active()
.map_err(|error| SaveFailure::storage(error.to_string()))
.and_then(|()| save_document(&config, &editor));
Arc::new(Mutex::new(Some((editor, result))))
},
move |completion| Message::SaveFinished {
generation,
completion,
},
)
}
fn edit(&mut self, operation: impl FnOnce(&mut EntryEditor) -> Result<(), DocumentError>) {
let Some(editor) = self.editor.as_mut() else {
return;
};
if let Err(error) = operation(editor) {
self.status = error.to_string();
} else {
self.status = format!("Editing {} (unsaved changes)", editor.entry());
self.conflict = false;
}
}
fn generate(&mut self, id: EntryFieldId) {
match GeneratorConfig::pass_defaults().generate_secret(None, false) {
Ok(password) => self.edit(|editor| editor.replace_value(id, password)),
Err(error) => self.status = format!("Password generation failed: {error}"),
}
}
fn touch_user_activity(&mut self) {
if let Some(handle) = &self.handle {
match handle
.touch_user_activity()
.and_then(|()| handle.remaining_time())
{
Ok(remaining) => {
self.authentication = AuthenticationView::Unlocked(remaining);
}
Err(error) => self.authentication_lost(error.to_string()),
}
}
}
fn authentication_lost(&mut self, reason: String) {
self.operation_generation = self.operation_generation.wrapping_add(1);
self.handle = None;
self.sensitive.clear();
self.editor = None;
self.saving = false;
self.confirmation = None;
self.after_save = None;
self.after_authentication = None;
self.generate_confirmation = None;
self.conflict = false;
self.authentication = AuthenticationView::Locked;
self.status = reason;
}
fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
time::every(Duration::from_secs(1)).map(|_| Message::Tick),
event::listen_with(|event, _status, _window| event_message(&event)),
window::close_requests().map(Message::RequestClose),
])
}
fn view(&self) -> Element<'_, Message> {
if let Some(action) = &self.confirmation {
return confirmation_view(action);
}
if let Some(id) = self.generate_confirmation {
return generate_confirmation_view(id);
}
let authentication = match &self.authentication {
AuthenticationView::Loading => "Loading".to_owned(),
AuthenticationView::Locked => "Locked".to_owned(),
AuthenticationView::Authenticating => "Authenticating".to_owned(),
AuthenticationView::Unlocked(remaining) => {
format!("Unlocked · {}s", remaining.as_secs())
}
AuthenticationView::Unavailable(error) => format!("Unavailable · {error}"),
};
let panes = pane_grid(&self.panes, |_pane, kind, _maximized| {
pane_grid::Content::new(match kind {
PaneKind::Sidebar => sidebar_view(
&self.navigation,
&self.tree_state,
self.pane_focus == PaneFocus::Sidebar,
),
PaneKind::Content => content_view(self),
})
})
.spacing(1)
.min_size(220)
.on_resize(8, Message::PaneResized);
container(
column![
row![
text(authentication),
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
.spacing(16)
.padding(10),
panes,
]
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
fn sidebar_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-navigation-tree")
}
fn sidebar_view<'a>(
navigation: &'a NavigationTree,
state: &'a TreeState,
focused: bool,
) -> Element<'a, Message> {
let mut rows = column![
row![
text(if focused {
"Password Store · focused"
} else {
"Password Store"
})
.size(20),
button("Refresh").on_press(Message::RefreshTree),
]
.spacing(8)
]
.spacing(4)
.padding(10);
match state {
TreeState::Loading => {
rows = rows.push(text("Loading password-store tree…").size(13));
}
TreeState::Error(error) => {
rows = rows.push(text(format!("Tree unavailable: {error}")).size(13));
}
TreeState::Ready if navigation.is_empty() => {
rows = rows.push(text("This password store is empty.").size(13));
}
TreeState::Ready => {}
}
let selected = navigation.selected();
for node in navigation.rows() {
let marker = if node.id.is_directory() {
if node.expanded { "" } else { "" }
} else {
""
};
let mut flags = Vec::new();
if node.indicators.has_conflict() {
flags.push("conflict");
}
if node.indicators.is_changed() {
flags.push("changed");
}
if node.indicators.is_locked() {
flags.push("locked");
}
let suffix = if flags.is_empty() {
String::new()
} else {
format!(" · {}", flags.join(", "))
};
let selected = selected == Some(&node.id);
let item = button(
row![
container(text("")).width(Length::Fixed((node.depth * 16) as f32)),
text(marker),
text(format!("{}{}", node.name, suffix)),
]
.spacing(5),
)
.width(Length::Fill)
.on_press(Message::SidebarActivate(node.id))
.style(if selected {
button::primary
} else {
button::text
});
rows = rows.push(item);
}
scrollable(rows)
.id(sidebar_scroll_id())
.height(Length::Fill)
.into()
}
fn content_view(app: &App) -> Element<'_, Message> {
let open = row![
text(if app.pane_focus == PaneFocus::Content {
"Content · focused"
} else {
"Content"
})
.size(20),
text_input("Entry path", &app.entry_path)
.on_input(Message::EntryPathChanged)
.on_submit(Message::OpenEntry),
button("Edit").on_press(Message::OpenEntry),
button("Reload").on_press(Message::RequestReload),
button("Lock").on_press(Message::Lock),
]
.spacing(8);
let body: Element<'_, Message> = if app.saving {
container(text("Saving without discarding the draft…"))
.center(Length::Fill)
.into()
} else if let Some(editor) = &app.editor {
editor_view(editor, app.conflict)
} else {
container(text(
"Select an entry in the navigation tree. Entry names remain visible while locked; protected content authenticates only when opened.",
))
.center(Length::Fill)
.into()
};
container(column![open, body].spacing(12).padding(12))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
impl SaveFailure {
fn storage(message: String) -> Self {
Self {
kind: SaveFailureKind::Storage,
message,
}
}
}
fn dirty_decision(editor: Option<&EntryEditor>) -> DirtyDecision {
if editor.is_some_and(EntryEditor::is_dirty) {
DirtyDecision::Confirm
} else {
DirtyDecision::Execute
}
}
fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
let mut fields = column![
row![
text(format!(
"{}{}",
editor.entry(),
if editor.is_dirty() { "" } else { "" }
))
.size(22),
button("Save").on_press(Message::Save),
button("Add line").on_press(Message::AddAfter(None)),
]
.spacing(8)
]
.spacing(10);
if conflict {
fields = fields.push(
row![
text("The stored entry changed. Reload it or keep the complete local draft."),
button("Reload stored entry").on_press(Message::ReloadConflict),
button("Keep draft").on_press(Message::KeepConflictDraft),
]
.spacing(8),
);
}
for field in editor.fields() {
let id = field.id();
let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive;
let value = std::str::from_utf8(field.contents().expose()).ok();
let label = field
.metadata()
.name()
.map(str::to_owned)
.unwrap_or_else(|| format!("{:?}", field.metadata().kind()));
let input = text_input(
if value.is_some() {
"Entry line"
} else {
"Non-UTF-8 line preserved"
},
value.unwrap_or(""),
)
.secure(sensitive && !editor.is_revealed(id))
.on_input_maybe(
value
.is_some()
.then_some(move |value| Message::FieldChanged(id, Zeroizing::new(value))),
)
.on_submit(Message::AddAfter(Some(id)));
let mut actions = row![
button("").on_press(Message::MoveUp(id)),
button("").on_press(Message::MoveDown(id)),
button("Add below").on_press(Message::AddAfter(Some(id))),
button("Remove").on_press(Message::Remove(id)),
button("Copy value").on_press(Message::Copy(id)),
]
.spacing(6);
if sensitive {
actions = actions.push(
button(if editor.is_revealed(id) {
"Hide"
} else {
"Reveal"
})
.on_press(Message::ToggleReveal(id)),
);
}
if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri {
actions = actions.push(button("Generate").on_press(Message::RequestGenerate(id)));
}
fields = fields.push(column![text(label).size(14), input, actions].spacing(4));
}
scrollable(fields).height(Length::Fill).into()
}
fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
let description = match action {
PendingAction::OpenEntry(entry) => format!("Open {entry}"),
PendingAction::Reload(entry) => format!("Reload {entry}"),
PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(),
};
container(
column![
text("Save changes?").size(26),
text(format!(
"{description} would discard the current structured editor draft."
)),
row![
button("Save").on_press(Message::ConfirmSave),
button("Discard").on_press(Message::ConfirmDiscard),
button("Cancel").on_press(Message::CancelDiscard),
]
.spacing(8),
]
.spacing(12),
)
.center(Length::Fill)
.into()
}
fn generate_confirmation_view(id: EntryFieldId) -> Element<'static, Message> {
container(
column![
text("Replace the current value?").size(26),
text("The generated password replaces only this storage-provided field value."),
row![
button("Replace").on_press(Message::ConfirmGenerate),
button("Cancel").on_press(Message::CancelGenerate),
]
.spacing(8),
text(format!("Field {}", id.value())).size(12),
]
.spacing(12),
)
.center(Length::Fill)
.into()
}
async fn load_authentication() -> Result<(Config, NativeAuthenticationSession, KeyInfo), String> {
let config = Config::load(None).map_err(|error| error.to_string())?;
let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?;
let handle = keys
.resolve(config.default_key().as_str())
.map_err(|error| error.to_string())?;
let key = keys
.infos()
.find(|key| key.fingerprint() == handle.fingerprint())
.ok_or_else(|| "the configured GPG key is unavailable".to_owned())?;
let session = NativeAuthenticationSession::system(
SecretProtectionPolicy::default(),
config.authentication_timeout(),
)
.map_err(|error| error.to_string())?;
Ok((config, session, key))
}
fn load_document(
config: &Config,
entry: &str,
mut handle: NativeAuthenticationHandle,
) -> Result<EntryDocument, String> {
let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?;
let keys = 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: &Config) -> Result<TreeModel, String> {
let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?;
let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?;
ironstorage::read::VaultReader::new(&repository, &keys)
.list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| error.to_string())
}
fn save_document(config: &Config, editor: &EntryEditor) -> Result<WriteOutcome, SaveFailure> {
let repository = Repository::open(config.vault())
.map_err(|error| SaveFailure::storage(error.to_string()))?;
let keys = KeyStore::load(config.key_material())
.map_err(|error| SaveFailure::storage(error.to_string()))?;
let entry = editor.entry();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage())
.map_err(|error| SaveFailure::storage(error.to_string()))?;
ironstorage::document::EntryDocumentService::new(&repository, &keys)
.save_recoverable(editor.document(), None, &mut committer)
.map_err(|error| SaveFailure {
kind: match &error {
DocumentError::Write(WriteError::ConcurrentModification { .. }) => {
SaveFailureKind::Conflict
}
DocumentError::Write(WriteError::Unchanged) => SaveFailureKind::Unchanged,
_ => SaveFailureKind::Storage,
},
message: error.to_string(),
})
}
async fn copy_to_clipboard(
value: SecretBytes,
timeout: ironstorage::presentation::ClipboardTimeout,
cancel: Arc<AtomicBool>,
) -> Result<String, String> {
let mut clipboard =
NativeClipboardManager::system(timeout).map_err(|error| error.to_string())?;
let disposition = clipboard
.copy_with(&value, |duration| {
let deadline = Instant::now() + duration;
while Instant::now() < deadline {
if cancel.load(Ordering::Acquire) {
return ClipboardWait::Cancelled;
}
thread::sleep(
deadline
.saturating_duration_since(Instant::now())
.min(Duration::from_millis(50)),
);
}
ClipboardWait::Elapsed
})
.map_err(|error| error.to_string())?;
Ok(format!("Clipboard cleanup complete: {disposition:?}"))
}
fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
session: &AuthenticationSession<B, C>,
handle: &mut Option<AuthenticationHandle<B, C>>,
sensitive: &mut SensitiveUiState,
) -> Result<LeasePoll, AuthenticationError> {
if session.expire()? {
*handle = None;
sensitive.clear();
return Ok(LeasePoll::Expired);
}
handle
.as_ref()
.map(AuthenticationHandle::remaining_time)
.transpose()
.map(|remaining| remaining.map_or(LeasePoll::Idle, LeasePoll::Active))
}
fn event_message(event: &Event) -> Option<Message> {
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
if modifiers.command() && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) {
return Some(Message::Save);
}
let navigation = match key.as_ref() {
keyboard::Key::Named(keyboard::key::Named::Tab) => {
return Some(Message::TogglePaneFocus);
}
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => NavigationKey::Previous,
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => NavigationKey::Next,
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => NavigationKey::Collapse,
keyboard::Key::Named(keyboard::key::Named::ArrowRight) => NavigationKey::Expand,
keyboard::Key::Named(keyboard::key::Named::Enter) => NavigationKey::Activate,
keyboard::Key::Named(keyboard::key::Named::Home) => NavigationKey::First,
keyboard::Key::Named(keyboard::key::Named::End) => NavigationKey::Last,
_ => return is_deliberate_activity(event).then_some(Message::UserActivity),
};
return Some(Message::SidebarNavigate(navigation));
}
is_deliberate_activity(event).then_some(Message::UserActivity)
}
fn is_deliberate_activity(event: &Event) -> bool {
matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed { .. })
| Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. })
| Event::Touch(
touch::Event::FingerPressed { .. }
| touch::Event::FingerMoved { .. }
| touch::Event::FingerLifted { .. }
)
)
}
fn take_completion<T>(completion: &Arc<Mutex<Option<T>>>) -> Option<T> {
completion.lock().ok()?.take()
}
#[cfg(test)]
mod tests {
use std::{
collections::BTreeMap,
fs,
path::Path,
sync::{Arc, Mutex},
};
use iced::{Point, window};
use ironstorage::{
authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT},
crypto::{SecretProvider, SecretProviderError},
document::EntryDocumentService,
repository::EntryPath,
secret_store::{
SecretCachePolicy, SecretLocator, SecretProtection, SecretReference, SecretStore,
SecretStoreError,
},
};
use super::*;
#[derive(Clone, Default)]
struct ManualClock(Arc<Mutex<Duration>>);
impl ManualClock {
fn advance(&self, duration: Duration) {
*self.0.lock().expect("test clock") += duration;
}
}
impl AuthenticationClock for ManualClock {
fn now(&self) -> Duration {
*self.0.lock().expect("test clock")
}
}
#[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
impl SecretStoreBackend for MemoryBackend {
fn create(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
self.0
.lock()
.expect("test backend")
.insert(locator.clone(), SecretBytes::new(value.to_vec()));
Ok(())
}
fn retrieve(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> {
self.0
.lock()
.expect("test backend")
.get(locator)
.map(|value| SecretBytes::new(value.expose().to_vec()))
.ok_or(SecretStoreError::Missing)
}
fn replace(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
value: &[u8],
) -> Result<(), SecretStoreError> {
self.create(locator, SecretProtection::DeviceUnlocked, value)
}
fn delete(
&self,
locator: &SecretLocator,
_protection: SecretProtection,
) -> Result<(), SecretStoreError> {
self.0.lock().expect("test backend").remove(locator);
Ok(())
}
fn lock(&self) -> Result<(), SecretStoreError> {
Ok(())
}
fn unlock(&self) -> Result<(), SecretStoreError> {
Ok(())
}
}
struct FixtureSecrets;
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
Ok(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
}
}
fn session(
timeout: Duration,
) -> (
AuthenticationSession<MemoryBackend, ManualClock>,
KeyInfo,
ManualClock,
) {
let mut keys = KeyStore::new();
let [key] = keys
.import(include_bytes!(
"../../../crates/storage/tests/fixtures/compatibility/keys/alice-secret.asc"
))
.expect("fixture key")
.try_into()
.expect("one fixture key");
let backend = MemoryBackend::default();
let store = SecretStore::new(
backend.clone(),
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
);
store.unlock().expect("unlock fixture store");
store
.create(
&SecretReference::openpgp_passphrase(key.fingerprint().as_str())
.expect("fixture reference"),
SecretBytes::new(b"fixture-alice-passphrase".to_vec()),
)
.expect("provision fixture passphrase");
store.lock().expect("lock fixture store");
let clock = ManualClock::default();
let session = AuthenticationSession::with_clock(
backend,
SecretProtectionPolicy::device_unlocked(),
AuthenticationTimeout::new(timeout).expect("valid timeout"),
clock.clone(),
);
(session, key, clock)
}
fn fixture_config() -> (tempfile::TempDir, Config) {
let temporary = tempfile::tempdir().expect("temporary vault");
let vault = temporary.path().join("vault");
let native = temporary.path().join("native");
fs::create_dir_all(&vault).expect("vault");
fs::create_dir_all(&native).expect("native");
let keys = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility/keys");
fs::write(
vault.join(".gpg-id"),
b"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\n",
)
.expect("recipient policy");
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
vault, keys
),
)
.expect("configuration");
let config = Config::load(Some(&config_path)).expect("load configuration");
(temporary, config)
}
fn empty_editor(config: &Config, entry: &str) -> EntryEditor {
let repository = Repository::open(config.vault()).expect("repository");
let keys = KeyStore::load(config.key_material()).expect("keys");
let document = EntryDocumentService::new(&repository, &keys)
.open(entry, &mut FixtureSecrets)
.expect("document");
EntryEditor::new(document)
}
fn test_app(editor: Option<EntryEditor>) -> App {
App {
authentication: AuthenticationView::Locked,
config: None,
session: None,
key: None,
handle: None,
sensitive: SensitiveUiState::default(),
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
}),
pane_focus: PaneFocus::Sidebar,
navigation: NavigationTree::default(),
tree_state: TreeState::Loading,
after_authentication: None,
entry_path: String::new(),
editor,
saving: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
conflict: false,
status: String::new(),
}
}
#[test]
fn structured_fields_save_round_trip_and_stale_drafts_remain_recoverable() {
let (_temporary, config) = fixture_config();
let mut editor = empty_editor(&config, "documents/editable");
editor.add_after(None).expect("password line");
let password = editor.fields()[0].id();
editor.update_raw(password, b"password").expect("password");
editor.add_after(Some(password)).expect("username line");
let username = editor.fields()[1].id();
editor
.update_raw(username, b"username: alice")
.expect("username");
editor.add_after(Some(username)).expect("first note line");
let first_note = editor.fields()[2].id();
editor
.update_raw(first_note, b"first note line")
.expect("first note");
editor
.add_after(Some(first_note))
.expect("second note line");
let second_note = editor.fields()[3].id();
editor
.update_raw(second_note, b"second note line")
.expect("second note");
editor
.add_after(Some(second_note))
.expect("unknown field line");
let unknown = editor.fields()[4].id();
editor
.update_raw(unknown, b"custom-field: opaque")
.expect("unknown field");
editor.move_up(unknown).expect("reorder unknown field");
editor.add_after(None).expect("temporary line");
let temporary = editor.fields().last().expect("temporary field").id();
editor.remove(temporary).expect("remove temporary line");
let expected =
b"password\nusername: alice\nfirst note line\ncustom-field: opaque\nsecond note line";
assert_eq!(editor.document().serialize().expose(), expected);
save_document(&config, &editor).expect("initial save");
let reopened = empty_editor(&config, "documents/editable");
assert_eq!(reopened.document().serialize().expose(), expected);
let mut winner = empty_editor(&config, "documents/editable");
let mut stale = empty_editor(&config, "documents/editable");
let winner_password = winner.fields()[0].id();
winner
.update_raw(winner_password, b"winner")
.expect("winner edit");
save_document(&config, &winner).expect("winner save");
let stale_password = stale.fields()[0].id();
stale
.update_raw(stale_password, b"complete stale draft")
.expect("stale edit");
let failure = save_document(&config, &stale).expect_err("stale save");
assert_eq!(failure.kind, SaveFailureKind::Conflict);
assert!(stale.is_dirty());
assert_eq!(
stale.document().serialize().expose(),
b"complete stale draft\nusername: alice\nfirst note line\ncustom-field: opaque\nsecond note line"
);
let repository = Repository::open(config.vault()).expect("repository");
assert!(
repository
.read_entry(&EntryPath::parse("documents/editable").expect("path"))
.is_ok()
);
}
#[test]
fn every_destructive_path_uses_the_same_save_discard_cancel_guard() {
let (_temporary, config) = fixture_config();
let mut editor = empty_editor(&config, "draft");
editor.add_after(None).expect("line");
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
for action in [
PendingAction::OpenEntry("other".to_owned()),
PendingAction::Reload("draft".to_owned()),
PendingAction::CloseWindow(window::Id::unique()),
] {
assert!(matches!(
action,
PendingAction::OpenEntry(_)
| PendingAction::Reload(_)
| PendingAction::CloseWindow(_)
));
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
}
assert_eq!(dirty_decision(None), DirtyDecision::Execute);
let draft = editor.document().serialize().expose().to_vec();
let mut app = test_app(Some(editor));
let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path"));
let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path"));
app.navigation.replace_test_nodes(vec![
navigation::TestNode {
id: draft_id,
name: "draft".to_owned(),
children: Vec::new(),
},
navigation::TestNode {
id: other_id.clone(),
name: "other".to_owned(),
children: Vec::new(),
},
]);
assert!(app.navigation.select_entry_path("draft"));
let intent = app.navigation.activate(other_id);
let _task = app.handle_navigation(intent);
assert_eq!(
app.navigation.selected().map(TreeNodeId::path),
Some(Path::new("draft"))
);
let _task = app.update(Message::CancelDiscard);
assert!(app.confirmation.is_none());
assert_eq!(
app.editor
.as_ref()
.expect("cancel retains editor")
.document()
.serialize()
.expose(),
draft
);
let _task = app.request_action(PendingAction::OpenEntry("other".to_owned()));
let _task = app.update(Message::ConfirmDiscard);
assert!(app.editor.is_none());
assert_eq!(
app.after_authentication,
Some(PendingAction::OpenEntry("other".to_owned()))
);
}
#[test]
fn lock_and_expiry_drop_the_complete_editor_and_clipboard_state() {
let (_temporary, config) = fixture_config();
let mut editor = empty_editor(&config, "dirty");
editor.add_after(None).expect("dirty line");
let mut app = test_app(Some(editor));
app.sensitive.clipboard_cancel = Some(Arc::new(AtomicBool::new(false)));
app.authentication_lost("locked".to_owned());
assert!(app.editor.is_none());
assert!(app.sensitive.clipboard_cancel.is_none());
let (generation, cancel) = app.sensitive.begin_copy();
app.sensitive.clear();
assert!(cancel.load(Ordering::Acquire));
assert!(!app.sensitive.finish_copy(generation));
let timeout = DEFAULT_AUTHENTICATION_TIMEOUT;
let (session, key, clock) = session(timeout);
let mut handle = Some(session.authenticate(&key).expect("authenticate"));
let mut sensitive = SensitiveUiState {
clipboard_cancel: Some(Arc::new(AtomicBool::new(false))),
clipboard_generation: 0,
};
clock.advance(timeout);
assert_eq!(
poll_lease(&session, &mut handle, &mut sensitive).expect("poll"),
LeasePoll::Expired
);
assert!(handle.is_none());
assert!(sensitive.clipboard_cancel.is_none());
}
#[test]
fn deliberate_input_and_primary_save_are_distinct_from_passive_events() {
assert!(!is_deliberate_activity(&Event::Window(
window::Event::Focused
)));
assert!(!is_deliberate_activity(&Event::Mouse(
mouse::Event::CursorMoved {
position: Point::ORIGIN,
}
)));
let click = Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left));
assert!(matches!(event_message(&click), Some(Message::UserActivity)));
let save = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character("s".into()),
modified_key: keyboard::Key::Character("s".into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyS),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::COMMAND,
text: Some("s".into()),
repeat: false,
});
assert!(matches!(event_message(&save), Some(Message::Save)));
let tab = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Tab),
modified_key: keyboard::Key::Named(keyboard::key::Named::Tab),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Tab),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::NONE,
text: None,
repeat: false,
});
assert!(matches!(
event_message(&tab),
Some(Message::TogglePaneFocus)
));
}
#[test]
fn pane_focus_and_storage_errors_are_handled_without_repository_access() {
let mut app = test_app(None);
assert_eq!(app.pane_focus, PaneFocus::Sidebar);
let _task = app.update(Message::TogglePaneFocus);
assert_eq!(app.pane_focus, PaneFocus::Content);
app.tree_generation = 7;
let completion = Arc::new(Mutex::new(Some(Err("injected tree failure".to_owned()))));
let _task = app.update(Message::TreeLoaded {
generation: 7,
completion,
});
assert_eq!(
app.tree_state,
TreeState::Error("injected tree failure".to_owned())
);
assert!(app.navigation.is_empty());
}
}