Implement structured desktop entry viewer

This commit is contained in:
2026-08-10 16:27:47 +02:00
parent f363081fa2
commit f6e74cbb1d
3 changed files with 428 additions and 11 deletions

View File

@@ -12,14 +12,25 @@ use ironstorage::{
pub struct EntryEditor { pub struct EntryEditor {
document: EntryDocument, document: EntryDocument,
revealed: BTreeSet<EntryFieldId>, revealed: BTreeSet<EntryFieldId>,
focused: Option<EntryFieldId>,
dirty: bool, dirty: bool,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FieldNavigation {
First,
Last,
Next,
Previous,
}
impl EntryEditor { impl EntryEditor {
pub fn new(document: EntryDocument) -> Self { pub fn new(document: EntryDocument) -> Self {
let focused = document.fields().first().map(EntryField::id);
Self { Self {
document, document,
revealed: BTreeSet::new(), revealed: BTreeSet::new(),
focused,
dirty: false, dirty: false,
} }
} }
@@ -44,6 +55,44 @@ impl EntryEditor {
self.revealed.contains(&id) self.revealed.contains(&id)
} }
pub fn focused(&self) -> Option<EntryFieldId> {
self.focused
}
pub fn focused_ratio(&self) -> f32 {
let fields = self.document.fields();
self.focused
.and_then(|id| fields.iter().position(|field| field.id() == id))
.map_or(0.0, |index| {
index as f32 / fields.len().saturating_sub(1).max(1) as f32
})
}
pub fn select(&mut self, id: EntryFieldId) {
if self.document.field(id).is_some() {
self.focused = Some(id);
}
}
pub fn navigate(&mut self, navigation: FieldNavigation) {
let fields = self.document.fields();
if fields.is_empty() {
self.focused = None;
return;
}
let current = self
.focused
.and_then(|id| fields.iter().position(|field| field.id() == id))
.unwrap_or(0);
let index = match navigation {
FieldNavigation::First => 0,
FieldNavigation::Last => fields.len() - 1,
FieldNavigation::Next => (current + 1).min(fields.len() - 1),
FieldNavigation::Previous => current.saturating_sub(1),
};
self.focused = Some(fields[index].id());
}
pub fn toggle_reveal(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { pub fn toggle_reveal(&mut self, id: EntryFieldId) -> Result<(), DocumentError> {
let field = self let field = self
.document .document
@@ -81,14 +130,27 @@ impl EntryEditor {
.position(|field| field.id() == id) .position(|field| field.id() == id)
}) })
.map_or(self.document.fields().len(), |index| index + 1); .map_or(self.document.fields().len(), |index| index + 1);
self.document.add(index, EntryFieldDraft::blank())?; self.focused = Some(self.document.add(index, EntryFieldDraft::blank())?);
self.dirty = true; self.dirty = true;
Ok(()) Ok(())
} }
pub fn remove(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { pub fn remove(&mut self, id: EntryFieldId) -> Result<(), DocumentError> {
let index = self
.document
.fields()
.iter()
.position(|field| field.id() == id)
.ok_or(DocumentError::UnknownField { id })?;
self.document.remove(id)?; self.document.remove(id)?;
self.revealed.remove(&id); self.revealed.remove(&id);
if self.focused == Some(id) {
self.focused = self
.document
.fields()
.get(index.min(self.document.fields().len().saturating_sub(1)))
.map(EntryField::id);
}
self.dirty = true; self.dirty = true;
Ok(()) Ok(())
} }
@@ -148,6 +210,7 @@ impl fmt::Debug for EntryEditor {
.debug_struct("EntryEditor") .debug_struct("EntryEditor")
.field("document", &self.document) .field("document", &self.document)
.field("revealed", &self.revealed) .field("revealed", &self.revealed)
.field("focused", &self.focused)
.field("dirty", &self.dirty) .field("dirty", &self.dirty)
.finish() .finish()
} }

View File

@@ -13,7 +13,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use editor::EntryEditor; use editor::{EntryEditor, FieldNavigation};
use iced::{ use iced::{
Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch, Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch,
widget::{button, column, container, pane_grid, row, scrollable, text, text_input}, widget::{button, column, container, pane_grid, row, scrollable, text, text_input},
@@ -26,7 +26,10 @@ use ironstorage::{
}, },
crypto::KeyInfo, crypto::KeyInfo,
desktop::{DesktopError, DesktopErrorKind, DesktopStorage}, desktop::{DesktopError, DesktopErrorKind, DesktopStorage},
document::{DocumentError, EntryDocument, EntryFieldId, EntryFieldKind, EntrySensitivity}, document::{
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId,
EntryFieldKind, EntrySensitivity,
},
generate::GeneratorConfig, generate::GeneratorConfig,
presentation::{ClipboardWait, NativeClipboardManager}, presentation::{ClipboardWait, NativeClipboardManager},
read::{TreeModel, TreeNodeId}, read::{TreeModel, TreeNodeId},
@@ -69,6 +72,8 @@ enum Message {
Remove(EntryFieldId), Remove(EntryFieldId),
MoveUp(EntryFieldId), MoveUp(EntryFieldId),
MoveDown(EntryFieldId), MoveDown(EntryFieldId),
BeginEdit,
SelectField(EntryFieldId),
ToggleReveal(EntryFieldId), ToggleReveal(EntryFieldId),
RequestGenerate(EntryFieldId), RequestGenerate(EntryFieldId),
ConfirmGenerate, ConfirmGenerate,
@@ -151,6 +156,7 @@ struct App {
after_authentication: Option<PendingAction>, after_authentication: Option<PendingAction>,
entry_path: String, entry_path: String,
editor: Option<EntryEditor>, editor: Option<EntryEditor>,
content_mode: ContentMode,
saving: bool, saving: bool,
confirmation: Option<PendingAction>, confirmation: Option<PendingAction>,
after_save: Option<PendingAction>, after_save: Option<PendingAction>,
@@ -171,6 +177,12 @@ enum PaneFocus {
Content, Content,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ContentMode {
Viewer,
Editor,
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
enum TreeState { enum TreeState {
Loading, Loading,
@@ -237,6 +249,7 @@ impl App {
after_authentication: None, after_authentication: None,
entry_path: String::new(), entry_path: String::new(),
editor: None, editor: None,
content_mode: ContentMode::Viewer,
saving: false, saving: false,
confirmation: None, confirmation: None,
after_save: None, after_save: None,
@@ -303,10 +316,16 @@ impl App {
} }
Message::SidebarNavigate(key) => { Message::SidebarNavigate(key) => {
self.touch_user_activity(); self.touch_user_activity();
if self.pane_focus == PaneFocus::Sidebar { match self.pane_focus {
PaneFocus::Sidebar => {
let intent = self.navigation.navigate(key); let intent = self.navigation.navigate(key);
return self.handle_navigation(intent); return self.handle_navigation(intent);
} }
PaneFocus::Content if self.content_mode == ContentMode::Viewer => {
return self.navigate_viewer(key);
}
PaneFocus::Content => {}
}
} }
Message::TogglePaneFocus => { Message::TogglePaneFocus => {
self.touch_user_activity(); self.touch_user_activity();
@@ -349,8 +368,9 @@ impl App {
let _selected = self.navigation.select_entry_path(&entry); let _selected = self.navigation.select_entry_path(&entry);
self.pane_focus = PaneFocus::Content; self.pane_focus = PaneFocus::Content;
self.editor = Some(EntryEditor::new(document)); self.editor = Some(EntryEditor::new(document));
self.content_mode = ContentMode::Viewer;
self.conflict = false; self.conflict = false;
self.status = format!("Editing {entry}"); self.status = format!("Viewing {entry}");
} }
Err(error) => self.status = format!("Open failed: {error}"), Err(error) => self.status = format!("Open failed: {error}"),
} }
@@ -390,7 +410,36 @@ impl App {
Message::Remove(id) => self.edit(|editor| editor.remove(id)), Message::Remove(id) => self.edit(|editor| editor.remove(id)),
Message::MoveUp(id) => self.edit(|editor| editor.move_up(id)), Message::MoveUp(id) => self.edit(|editor| editor.move_up(id)),
Message::MoveDown(id) => self.edit(|editor| editor.move_down(id)), Message::MoveDown(id) => self.edit(|editor| editor.move_down(id)),
Message::ToggleReveal(id) => self.edit(|editor| editor.toggle_reveal(id)), Message::BeginEdit => {
if authentication_allows_content(&self.authentication)
&& let Some(editor) = &self.editor
{
self.content_mode = ContentMode::Editor;
self.status = format!("Editing {}", editor.entry());
}
}
Message::SelectField(id) => {
if let Some(editor) = self.editor.as_mut() {
editor.select(id);
}
}
Message::ToggleReveal(id) => {
if authentication_allows_content(&self.authentication)
&& let Some(editor) = self.editor.as_mut()
{
match editor.toggle_reveal(id) {
Ok(()) => {
editor.select(id);
self.status = if editor.is_revealed(id) {
"Sensitive value revealed by explicit action.".to_owned()
} else {
"Sensitive value hidden.".to_owned()
};
}
Err(error) => self.status = error.to_string(),
}
}
}
Message::RequestGenerate(id) => { Message::RequestGenerate(id) => {
let has_value = self let has_value = self
.editor .editor
@@ -410,6 +459,9 @@ impl App {
} }
Message::CancelGenerate => self.generate_confirmation = None, Message::CancelGenerate => self.generate_confirmation = None,
Message::Copy(id) => { Message::Copy(id) => {
if !authentication_allows_content(&self.authentication) {
return Task::none();
}
let (Some(storage), Some(editor)) = (&self.storage, &self.editor) else { let (Some(storage), Some(editor)) = (&self.storage, &self.editor) else {
return Task::none(); return Task::none();
}; };
@@ -476,6 +528,7 @@ impl App {
Message::ConfirmDiscard => { Message::ConfirmDiscard => {
let action = self.confirmation.take(); let action = self.confirmation.take();
self.editor = None; self.editor = None;
self.content_mode = ContentMode::Viewer;
self.conflict = false; self.conflict = false;
if let Some(action) = action { if let Some(action) = action {
return self.execute_action(action); return self.execute_action(action);
@@ -485,6 +538,7 @@ impl App {
Message::ReloadConflict => { Message::ReloadConflict => {
if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) {
self.editor = None; self.editor = None;
self.content_mode = ContentMode::Viewer;
self.conflict = false; self.conflict = false;
return self.execute_action(PendingAction::Reload(entry)); return self.execute_action(PendingAction::Reload(entry));
} }
@@ -577,6 +631,7 @@ impl App {
match action { match action {
PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => { PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => {
self.editor = None; self.editor = None;
self.content_mode = ContentMode::Viewer;
self.begin_open(entry) self.begin_open(entry)
} }
PendingAction::CloseWindow(id) => { PendingAction::CloseWindow(id) => {
@@ -631,6 +686,11 @@ impl App {
fn begin_save(&mut self) -> Task<Message> { fn begin_save(&mut self) -> Task<Message> {
self.touch_user_activity(); self.touch_user_activity();
if self.content_mode != ContentMode::Editor
|| !authentication_allows_content(&self.authentication)
{
return Task::none();
}
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
return Task::none(); return Task::none();
}; };
@@ -659,6 +719,11 @@ impl App {
} }
fn edit(&mut self, operation: impl FnOnce(&mut EntryEditor) -> Result<(), DocumentError>) { fn edit(&mut self, operation: impl FnOnce(&mut EntryEditor) -> Result<(), DocumentError>) {
if self.content_mode != ContentMode::Editor
|| !authentication_allows_content(&self.authentication)
{
return;
}
let Some(editor) = self.editor.as_mut() else { let Some(editor) = self.editor.as_mut() else {
return; return;
}; };
@@ -677,6 +742,43 @@ impl App {
} }
} }
fn navigate_viewer(&mut self, navigation: NavigationKey) -> Task<Message> {
if !authentication_allows_content(&self.authentication) {
return Task::none();
}
let Some(editor) = self.editor.as_mut() else {
return Task::none();
};
match navigation {
NavigationKey::Previous => editor.navigate(FieldNavigation::Previous),
NavigationKey::Next => editor.navigate(FieldNavigation::Next),
NavigationKey::First => editor.navigate(FieldNavigation::First),
NavigationKey::Last => editor.navigate(FieldNavigation::Last),
NavigationKey::Activate => {
if let Some(id) = editor.focused()
&& editor.document().field(id).is_some_and(|field| {
field.metadata().sensitivity() == EntrySensitivity::Sensitive
})
{
let _ignored = editor.toggle_reveal(id);
self.status = if editor.is_revealed(id) {
"Sensitive value revealed by explicit keyboard action.".to_owned()
} else {
"Sensitive value hidden.".to_owned()
};
}
}
NavigationKey::Collapse | NavigationKey::Expand => {}
}
iced::widget::operation::snap_to(
viewer_scroll_id(),
scrollable::RelativeOffset {
x: 0.0,
y: editor.focused_ratio(),
},
)
}
fn touch_user_activity(&mut self) { fn touch_user_activity(&mut self) {
if let Some(handle) = &self.handle { if let Some(handle) = &self.handle {
match handle match handle
@@ -696,6 +798,7 @@ impl App {
self.handle = None; self.handle = None;
self.sensitive.clear(); self.sensitive.clear();
self.editor = None; self.editor = None;
self.content_mode = ContentMode::Viewer;
self.saving = false; self.saving = false;
self.confirmation = None; self.confirmation = None;
self.after_save = None; self.after_save = None;
@@ -768,6 +871,10 @@ fn sidebar_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-navigation-tree") iced::widget::Id::new("desktop-navigation-tree")
} }
fn viewer_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-entry-viewer")
}
fn sidebar_view<'a>( fn sidebar_view<'a>(
navigation: &'a NavigationTree, navigation: &'a NavigationTree,
state: &'a TreeState, state: &'a TreeState,
@@ -859,18 +966,27 @@ fn content_view(app: &App) -> Element<'_, Message> {
text_input("Entry path", &app.entry_path) text_input("Entry path", &app.entry_path)
.on_input(Message::EntryPathChanged) .on_input(Message::EntryPathChanged)
.on_submit(Message::OpenEntry), .on_submit(Message::OpenEntry),
button("Edit").on_press(Message::OpenEntry), button("Open").on_press(Message::OpenEntry),
button("Reload").on_press(Message::RequestReload), button("Reload").on_press(Message::RequestReload),
button("Lock").on_press(Message::Lock), button("Lock").on_press(Message::Lock),
] ]
.spacing(8); .spacing(8);
let body: Element<'_, Message> = if app.saving { let body: Element<'_, Message> = if !authentication_allows_content(&app.authentication) {
container(text(
"Protected entry content is locked. Select an entry to authenticate and open it.",
))
.center(Length::Fill)
.into()
} else if app.saving {
container(text("Saving without discarding the draft…")) container(text("Saving without discarding the draft…"))
.center(Length::Fill) .center(Length::Fill)
.into() .into()
} else if let Some(editor) = &app.editor { } else if let Some(editor) = &app.editor {
editor_view(editor, app.conflict) match app.content_mode {
ContentMode::Viewer => viewer_view(editor),
ContentMode::Editor => editor_view(editor, app.conflict),
}
} else { } else {
container(text( container(text(
"Select an entry in the navigation tree. Entry names remain visible while locked; protected content authenticates only when opened.", "Select an entry in the navigation tree. Entry names remain visible while locked; protected content authenticates only when opened.",
@@ -885,6 +1001,130 @@ fn content_view(app: &App) -> Element<'_, Message> {
.into() .into()
} }
fn authentication_allows_content(authentication: &AuthenticationView) -> bool {
matches!(authentication, AuthenticationView::Unlocked(_))
}
#[derive(Debug, Eq, PartialEq)]
enum ViewerValue<'a> {
Masked,
Text(&'a str),
Unavailable,
}
fn viewer_value(field: &EntryField, revealed: bool) -> ViewerValue<'_> {
if field.metadata().sensitivity() == EntrySensitivity::Sensitive && !revealed {
ViewerValue::Masked
} else {
std::str::from_utf8(field.value())
.map(ViewerValue::Text)
.unwrap_or(ViewerValue::Unavailable)
}
}
fn viewer_label(field: &EntryField, index: usize) -> String {
let label = field.metadata().name().map_or_else(
|| match field.metadata().kind() {
EntryFieldKind::Password => "Password".to_owned(),
EntryFieldKind::Username => "Username".to_owned(),
EntryFieldKind::Email => "Email".to_owned(),
EntryFieldKind::Url => "URL".to_owned(),
EntryFieldKind::OtpUri => "One-time password".to_owned(),
EntryFieldKind::Field => "Field".to_owned(),
EntryFieldKind::Note => "Note".to_owned(),
EntryFieldKind::Blank => "Blank line".to_owned(),
},
str::to_owned,
);
format!("{label} · line {}", index + 1)
}
fn viewer_diagnostic(diagnostic: EntryFieldDiagnostic) -> &'static str {
match diagnostic {
EntryFieldDiagnostic::MalformedOtpUri => {
"Malformed OTP URI preserved losslessly; OTP metadata is unavailable."
}
EntryFieldDiagnostic::NonUtf8Value => {
"Non-UTF-8 value preserved losslessly; text display is unavailable."
}
}
}
fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> {
let mut rows = column![
row![
text(editor.entry()).size(22),
button("Edit entry").on_press(Message::BeginEdit),
]
.spacing(8),
text("Use Up/Down/Home/End to select fields; Enter reveals or hides a selected sensitive value.")
.size(12),
]
.spacing(12);
for (index, field) in editor.fields().iter().enumerate() {
let id = field.id();
let label = viewer_label(field, index);
let selected = editor.focused() == Some(id);
let revealed = editor.is_revealed(id);
let value = match viewer_value(field, revealed) {
ViewerValue::Masked => "••••••••",
ViewerValue::Text("") => "(empty)",
ViewerValue::Text(value) => value,
ViewerValue::Unavailable => "(binary value)",
};
let mut actions =
row![button(text(format!("Copy {label}"))).on_press(Message::Copy(id)),].spacing(6);
if field.metadata().sensitivity() == EntrySensitivity::Sensitive {
actions = actions.push(
button(text(format!(
"{} {label}",
if revealed { "Hide" } else { "Reveal" }
)))
.on_press(Message::ToggleReveal(id)),
);
}
let mut field_view = column![
button(text(format!(
"{} {label}",
if selected { "" } else { "" }
)))
.on_press(Message::SelectField(id))
.style(if selected {
button::primary
} else {
button::text
}),
text(value),
actions,
]
.spacing(5);
if let Some(otp) = field.metadata().otp() {
let cadence = otp.period().map_or_else(
|| format!("counter {}", otp.counter().unwrap_or_default()),
|period| format!("{period}s period"),
);
field_view = field_view.push(text(format!(
"{:?} · {} · {} · {:?} · {} digits · {cadence}",
otp.kind(),
otp.issuer().unwrap_or("unknown issuer"),
otp.account(),
otp.algorithm(),
otp.digits(),
)));
}
if let Some(diagnostic) = field.metadata().diagnostic() {
field_view = field_view.push(text(viewer_diagnostic(diagnostic)).size(12));
}
rows = rows.push(container(field_view).padding(10).width(Length::Fill));
}
scrollable(rows)
.id(viewer_scroll_id())
.height(Length::Fill)
.into()
}
fn dirty_decision(editor: Option<&EntryEditor>) -> DirtyDecision { fn dirty_decision(editor: Option<&EntryEditor>) -> DirtyDecision {
if editor.is_some_and(EntryEditor::is_dirty) { if editor.is_some_and(EntryEditor::is_dirty) {
DirtyDecision::Confirm DirtyDecision::Confirm
@@ -1327,6 +1567,7 @@ mod tests {
after_authentication: None, after_authentication: None,
entry_path: String::new(), entry_path: String::new(),
editor, editor,
content_mode: ContentMode::Viewer,
saving: false, saving: false,
confirmation: None, confirmation: None,
after_save: None, after_save: None,
@@ -1399,6 +1640,88 @@ mod tests {
); );
} }
#[test]
fn viewer_covers_lossless_fields_navigation_and_explicit_sensitive_actions() {
let (_temporary, storage) = fixture_storage();
let mut editor = empty_editor(&storage, "documents/viewer");
for value in [
b"password".as_slice(),
b"username: alice",
b"custom: first",
b"custom: second",
b"first note",
b"second note",
b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
b"otpauth://broken",
b"binary: \xff",
] {
let after = editor.fields().last().map(EntryField::id);
editor.add_after(after).expect("add viewer field");
let id = editor.fields().last().expect("new viewer field").id();
editor.update_raw(id, value).expect("populate viewer field");
}
let fields = editor.fields();
assert_eq!(fields[0].metadata().kind(), EntryFieldKind::Password);
assert_eq!(fields[1].metadata().kind(), EntryFieldKind::Username);
assert_eq!(fields[2].metadata().name(), Some("custom"));
assert_eq!(fields[3].metadata().name(), Some("custom"));
assert_ne!(viewer_label(&fields[2], 2), viewer_label(&fields[3], 3));
assert_eq!(fields[4].metadata().kind(), EntryFieldKind::Note);
assert_eq!(fields[5].metadata().kind(), EntryFieldKind::Note);
assert!(fields[6].metadata().otp().is_some());
assert_eq!(
fields[7].metadata().diagnostic(),
Some(EntryFieldDiagnostic::MalformedOtpUri)
);
assert_eq!(
fields[8].metadata().diagnostic(),
Some(EntryFieldDiagnostic::NonUtf8Value)
);
let password = fields[0].id();
assert_eq!(viewer_value(&fields[0], false), ViewerValue::Masked);
editor.toggle_reveal(password).expect("reveal password");
assert_eq!(
viewer_value(&editor.fields()[0], editor.is_revealed(password)),
ViewerValue::Text("password")
);
editor.navigate(FieldNavigation::First);
assert_eq!(editor.focused(), Some(password));
editor.navigate(FieldNavigation::Next);
assert_eq!(editor.focused(), Some(editor.fields()[1].id()));
editor.navigate(FieldNavigation::Last);
assert_eq!(editor.focused(), Some(editor.fields()[8].id()));
assert!(!authentication_allows_content(&AuthenticationView::Locked));
assert!(authentication_allows_content(
&AuthenticationView::Unlocked(Duration::from_secs(1))
));
editor.toggle_reveal(password).expect("hide password");
let mut app = test_app(Some(editor));
let _task = app.update(Message::ToggleReveal(password));
assert!(
!app.editor
.as_ref()
.expect("locked document fixture")
.is_revealed(password)
);
let _task = app.update(Message::BeginEdit);
assert_eq!(app.content_mode, ContentMode::Viewer);
app.authentication = AuthenticationView::Unlocked(Duration::from_secs(1));
let _task = app.update(Message::ToggleReveal(password));
assert!(
app.editor
.as_ref()
.expect("unlocked document fixture")
.is_revealed(password)
);
let _task = app.update(Message::BeginEdit);
assert_eq!(app.content_mode, ContentMode::Editor);
app.authentication_lost("locked".to_owned());
assert!(app.editor.is_none());
}
#[test] #[test]
fn every_destructive_path_uses_the_same_save_discard_cancel_guard() { fn every_destructive_path_uses_the_same_save_discard_cancel_guard() {
let (_temporary, storage) = fixture_storage(); let (_temporary, storage) = fixture_storage();

View File

@@ -41,12 +41,19 @@ pub enum EntrySensitivity {
Empty, Empty,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EntryFieldDiagnostic {
MalformedOtpUri,
NonUtf8Value,
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct EntryFieldMetadata { pub struct EntryFieldMetadata {
kind: EntryFieldKind, kind: EntryFieldKind,
sensitivity: EntrySensitivity, sensitivity: EntrySensitivity,
name: Option<String>, name: Option<String>,
otp: Option<EntryOtpMetadata>, otp: Option<EntryOtpMetadata>,
diagnostic: Option<EntryFieldDiagnostic>,
value: Range<usize>, value: Range<usize>,
} }
@@ -66,6 +73,10 @@ impl EntryFieldMetadata {
pub fn otp(&self) -> Option<&EntryOtpMetadata> { pub fn otp(&self) -> Option<&EntryOtpMetadata> {
self.otp.as_ref() self.otp.as_ref()
} }
pub fn diagnostic(&self) -> Option<EntryFieldDiagnostic> {
self.diagnostic
}
} }
/// Non-secret presentation metadata parsed from a validated `otpauth` URI. /// Non-secret presentation metadata parsed from a validated `otpauth` URI.
@@ -545,6 +556,8 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
sensitivity: EntrySensitivity::Sensitive, sensitivity: EntrySensitivity::Sensitive,
name: Some("password".to_owned()), name: Some("password".to_owned()),
otp: None, otp: None,
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: 0..line.len(), value: 0..line.len(),
}; };
} }
@@ -568,6 +581,17 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
sensitivity: EntrySensitivity::Sensitive, sensitivity: EntrySensitivity::Sensitive,
name: Some("otp".to_owned()), name: Some("otp".to_owned()),
otp: Some(otp), otp: Some(otp),
diagnostic: None,
value: 0..line.len(),
};
}
if line.starts_with(b"otpauth://") {
return EntryFieldMetadata {
kind: EntryFieldKind::OtpUri,
sensitivity: EntrySensitivity::Sensitive,
name: Some("otp".to_owned()),
otp: None,
diagnostic: Some(EntryFieldDiagnostic::MalformedOtpUri),
value: 0..line.len(), value: 0..line.len(),
}; };
} }
@@ -583,6 +607,9 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
sensitivity: field_sensitivity(name, kind), sensitivity: field_sensitivity(name, kind),
name: Some(name.to_owned()), name: Some(name.to_owned()),
otp: None, otp: None,
diagnostic: std::str::from_utf8(&line[value_start..])
.is_err()
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: value_start..line.len(), value: value_start..line.len(),
}; };
} }
@@ -592,6 +619,9 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
sensitivity: EntrySensitivity::Sensitive, sensitivity: EntrySensitivity::Sensitive,
name: None, name: None,
otp: None, otp: None,
diagnostic: std::str::from_utf8(line)
.is_err()
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: 0..line.len(), value: 0..line.len(),
} }
} }
@@ -624,6 +654,7 @@ fn blank_metadata() -> EntryFieldMetadata {
sensitivity: EntrySensitivity::Empty, sensitivity: EntrySensitivity::Empty,
name: None, name: None,
otp: None, otp: None,
diagnostic: None,
value: 0..0, value: 0..0,
} }
} }