Implement native iPhone entry editor
This commit is contained in:
@@ -320,6 +320,10 @@ impl EntryDocument {
|
||||
self.conflict_token
|
||||
}
|
||||
|
||||
pub fn is_modified(&self) -> bool {
|
||||
self.modified
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&mut self,
|
||||
index: usize,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Shared mobile authentication state; Swift only supplies input and presents results.
|
||||
|
||||
use std::{error::Error, fmt, sync::Mutex};
|
||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex};
|
||||
|
||||
use crate::{
|
||||
authentication::{
|
||||
@@ -10,9 +10,17 @@ use crate::{
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
|
||||
git::{AutomaticEntryCommitter, GitIdentity},
|
||||
mobile_entry::{MobileEntryPage, MobileEntryValueError, field_value},
|
||||
repository::{EntryPath, Repository, RepositoryError, SecretBytes},
|
||||
mobile_entry::{
|
||||
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
||||
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
||||
MobileEntryValueError, field_value,
|
||||
},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||
},
|
||||
secret_store::{SecretProtectionPolicy, SecretStoreError},
|
||||
write::{VaultWriter, WriteError},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -26,6 +34,7 @@ pub enum MobileAuthenticationErrorKind {
|
||||
Entry,
|
||||
SecureStorage,
|
||||
Expired,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -150,6 +159,8 @@ struct ActiveMobileLease {
|
||||
struct MobileAuthenticationStatus {
|
||||
biometric_unlock_enabled: bool,
|
||||
active: Option<ActiveMobileLease>,
|
||||
next_editor_id: u64,
|
||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||
}
|
||||
|
||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||
@@ -181,6 +192,8 @@ impl MobileAuthentication {
|
||||
status: Mutex::new(MobileAuthenticationStatus {
|
||||
biometric_unlock_enabled: config.biometric_unlock_enabled(),
|
||||
active: None,
|
||||
next_editor_id: 0,
|
||||
editors: BTreeMap::new(),
|
||||
}),
|
||||
config,
|
||||
repository,
|
||||
@@ -197,17 +210,26 @@ impl MobileAuthentication {
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||
let entry = EntryPath::parse(path).map_err(entry_error)?;
|
||||
let ciphertext = self.repository.read_entry(&entry).map_err(entry_error)?;
|
||||
let candidates = self.keys.decrypting_keys(&ciphertext).map_err(key_error)?;
|
||||
self.unlock_ciphertext(&ciphertext, passphrase)
|
||||
}
|
||||
|
||||
fn unlock_ciphertext(
|
||||
&self,
|
||||
ciphertext: &EncryptedEntry,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||
let candidates = self.keys.decrypting_keys(ciphertext).map_err(key_error)?;
|
||||
|
||||
if let Some(active) = self.take_active()? {
|
||||
let mut provider = KeyOnlyProvider::new(active.handle.clone(), &active.key);
|
||||
if self.keys.decrypt(&ciphertext, &mut provider).is_ok() {
|
||||
if self.keys.decrypt(ciphertext, &mut provider).is_ok() {
|
||||
self.restore_active(active)?;
|
||||
return self.state();
|
||||
}
|
||||
self.session
|
||||
.manual_lock()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
self.status()?.editors.clear();
|
||||
}
|
||||
|
||||
let biometric_enabled = self.status()?.biometric_unlock_enabled;
|
||||
@@ -220,7 +242,7 @@ impl MobileAuthentication {
|
||||
.authenticate_with_passphrase(key, candidate)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
let mut provider = KeyOnlyProvider::new(handle.clone(), key);
|
||||
match self.keys.decrypt(&ciphertext, &mut provider) {
|
||||
match self.keys.decrypt(ciphertext, &mut provider) {
|
||||
Ok(plaintext) => {
|
||||
drop(plaintext);
|
||||
if biometric_enabled {
|
||||
@@ -257,7 +279,7 @@ impl MobileAuthentication {
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle.clone(), key);
|
||||
if let Ok(plaintext) = self.keys.decrypt(&ciphertext, &mut provider) {
|
||||
if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) {
|
||||
drop(plaintext);
|
||||
self.set_active(handle, key.clone())?;
|
||||
return self.state();
|
||||
@@ -312,6 +334,7 @@ impl MobileAuthentication {
|
||||
let mut status = self.status()?;
|
||||
status.biometric_unlock_enabled = false;
|
||||
status.active = None;
|
||||
status.editors.clear();
|
||||
}
|
||||
self.state()
|
||||
}
|
||||
@@ -324,6 +347,7 @@ impl MobileAuthentication {
|
||||
let mut status = self.status()?;
|
||||
if remaining.is_none() {
|
||||
status.active = None;
|
||||
status.editors.clear();
|
||||
}
|
||||
Ok(MobileAuthenticationState {
|
||||
unlocked: status.active.is_some(),
|
||||
@@ -391,11 +415,191 @@ impl MobileAuthentication {
|
||||
Ok(MobileEntryPage::from_document(&document))
|
||||
}
|
||||
|
||||
pub fn begin_entry_editor(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
let document = self.open_active_document(path)?;
|
||||
self.store_editor(MobileEntryDraft::new(document, false))
|
||||
}
|
||||
|
||||
pub fn begin_create_entry(
|
||||
&self,
|
||||
directory: &str,
|
||||
name: &str,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
let directory = DirectoryPath::parse(directory).map_err(entry_error)?;
|
||||
let leaf = EntryPath::parse(name).map_err(entry_error)?;
|
||||
if leaf
|
||||
.as_path()
|
||||
.parent()
|
||||
.is_some_and(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Entry Name Is Invalid",
|
||||
"enter a name without a folder separator",
|
||||
));
|
||||
}
|
||||
let path =
|
||||
EntryPath::parse(directory.as_path().join(leaf.as_path())).map_err(entry_error)?;
|
||||
let path_text = path.to_string();
|
||||
if VaultWriter::new(&self.repository, &self.keys)
|
||||
.entry_exists(&path_text)
|
||||
.map_err(|error| entry_detail("Password Entry Is Unavailable", error))?
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Password Already Exists",
|
||||
"Choose a different entry name.",
|
||||
));
|
||||
}
|
||||
let recipients = RecipientPolicyManager::new(&self.repository, &self.keys)
|
||||
.resolve_for_entry(&path, None)
|
||||
.map_err(|error| entry_detail("Recipients Are Unavailable", error))?;
|
||||
let probe = self
|
||||
.keys
|
||||
.encrypt(
|
||||
SecretBytes::new(b"IronStorage mobile entry creation".to_vec()),
|
||||
recipients.recipients(),
|
||||
)
|
||||
.map_err(key_error)?;
|
||||
self.unlock_ciphertext(&probe, passphrase)?;
|
||||
let mut document = self.open_active_document(&path_text)?;
|
||||
document
|
||||
.add(
|
||||
0,
|
||||
crate::document::EntryFieldDraft::line(Vec::new()).map_err(document_error)?,
|
||||
)
|
||||
.map_err(document_error)?;
|
||||
self.store_editor(MobileEntryDraft::new(document, true))
|
||||
}
|
||||
|
||||
pub fn entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
self.status()?
|
||||
.editors
|
||||
.get(&editor)
|
||||
.map(MobileEntryDraft::page)
|
||||
.ok_or_else(editor_missing)
|
||||
}
|
||||
|
||||
pub fn update_entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
fields: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
draft.apply(fields).map_err(editor_error)?;
|
||||
Ok(draft.page())
|
||||
}
|
||||
|
||||
pub fn add_entry_editor_field(
|
||||
&self,
|
||||
editor: u64,
|
||||
kind: MobileEntryEditorFieldKind,
|
||||
name: Option<String>,
|
||||
value: String,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
draft.add(kind, name, value).map_err(editor_error)?;
|
||||
Ok(draft.page())
|
||||
}
|
||||
|
||||
pub fn remove_entry_editor_field(
|
||||
&self,
|
||||
editor: u64,
|
||||
field: u64,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
draft.remove(field).map_err(editor_error)?;
|
||||
Ok(draft.page())
|
||||
}
|
||||
|
||||
pub fn reorder_entry_editor_field(
|
||||
&self,
|
||||
editor: u64,
|
||||
field: u64,
|
||||
index: usize,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
draft.reorder(field, index).map_err(editor_error)?;
|
||||
Ok(draft.page())
|
||||
}
|
||||
|
||||
pub fn generate_entry_editor_password(
|
||||
&self,
|
||||
editor: u64,
|
||||
length: Option<u32>,
|
||||
no_symbols: bool,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
draft
|
||||
.generate_password(length, no_symbols)
|
||||
.map_err(editor_error)?;
|
||||
Ok(draft.page())
|
||||
}
|
||||
|
||||
pub fn save_entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
fields: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut draft = self
|
||||
.status()?
|
||||
.editors
|
||||
.remove(&editor)
|
||||
.ok_or_else(editor_missing)?;
|
||||
if let Err(error) = draft.apply(fields) {
|
||||
self.restore_editor(editor, draft)?;
|
||||
return Err(editor_error(error));
|
||||
}
|
||||
let path = draft.document().path().to_string();
|
||||
let mut committer = match AutomaticEntryCommitter::for_entry(
|
||||
&self.repository,
|
||||
&path,
|
||||
GitIdentity::ironstorage(),
|
||||
) {
|
||||
Ok(committer) => committer,
|
||||
Err(error) => {
|
||||
self.restore_editor(editor, draft)?;
|
||||
return Err(entry_detail("Password Entry Could Not Be Saved", error));
|
||||
}
|
||||
};
|
||||
if let Err(error) = EntryDocumentService::new(&self.repository, &self.keys)
|
||||
.save_recoverable(draft.document(), None, &mut committer)
|
||||
{
|
||||
self.restore_editor(editor, draft)?;
|
||||
return Err(document_error(error));
|
||||
}
|
||||
Ok(MobileEntryPage::from_document(draft.document()))
|
||||
}
|
||||
|
||||
pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationError> {
|
||||
self.status()?.editors.remove(&editor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
|
||||
self.session
|
||||
.manual_lock()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
self.status()?.active = None;
|
||||
let mut status = self.status()?;
|
||||
status.active = None;
|
||||
status.editors.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -403,7 +607,9 @@ impl MobileAuthentication {
|
||||
self.session
|
||||
.cancel()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
self.status()?.active = None;
|
||||
let mut status = self.status()?;
|
||||
status.active = None;
|
||||
status.editors.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -447,6 +653,48 @@ impl MobileAuthentication {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_active(&self) -> Result<(), MobileAuthenticationError> {
|
||||
let handle = self
|
||||
.status()?
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| active.handle.clone())
|
||||
.ok_or_else(locked_error)?;
|
||||
if let Err(error) = handle.ensure_active() {
|
||||
let mut status = self.status()?;
|
||||
status.active = None;
|
||||
status.editors.clear();
|
||||
return Err(MobileAuthenticationError::authentication(error));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_editor(
|
||||
&self,
|
||||
draft: MobileEntryDraft,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
let id = status.next_editor_id;
|
||||
status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| {
|
||||
entry_detail(
|
||||
"Entry Editor Is Unavailable",
|
||||
"entry editor identifiers are exhausted",
|
||||
)
|
||||
})?;
|
||||
let page = draft.page();
|
||||
status.editors.insert(id, draft);
|
||||
Ok(MobileEntryEditorSession::new(id, page))
|
||||
}
|
||||
|
||||
fn restore_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
draft: MobileEntryDraft,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.status()?.editors.insert(editor, draft);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_active_document(&self, path: &str) -> Result<EntryDocument, MobileAuthenticationError> {
|
||||
let (handle, key) = {
|
||||
let status = self.status()?;
|
||||
@@ -519,13 +767,32 @@ fn locked_error() -> MobileAuthenticationError {
|
||||
}
|
||||
|
||||
fn document_error(error: DocumentError) -> MobileAuthenticationError {
|
||||
entry_detail("Password Entry Is Unavailable", error)
|
||||
let kind = if matches!(
|
||||
&error,
|
||||
DocumentError::Write(WriteError::ConcurrentModification { .. })
|
||||
) {
|
||||
MobileAuthenticationErrorKind::Conflict
|
||||
} else {
|
||||
MobileAuthenticationErrorKind::Entry
|
||||
};
|
||||
MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string())
|
||||
}
|
||||
|
||||
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
|
||||
entry_detail("Field Value Is Unavailable", error)
|
||||
}
|
||||
|
||||
fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError {
|
||||
entry_detail("Entry Draft Is Invalid", error)
|
||||
}
|
||||
|
||||
fn editor_missing() -> MobileAuthenticationError {
|
||||
entry_detail(
|
||||
"Entry Draft Is Unavailable",
|
||||
"the entry draft was discarded or locked",
|
||||
)
|
||||
}
|
||||
|
||||
fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Entry,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Storage-owned projection of lossless entry documents for native mobile viewers.
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
|
||||
|
||||
use crate::document::{
|
||||
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind,
|
||||
EntrySensitivity,
|
||||
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldDraft, EntryFieldId,
|
||||
EntryFieldKind, EntrySensitivity,
|
||||
};
|
||||
use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileEntrySectionKind {
|
||||
@@ -104,6 +105,352 @@ pub struct MobileEntryPage {
|
||||
sections: Vec<MobileEntrySection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileEntryEditorFieldKind {
|
||||
Password,
|
||||
Field,
|
||||
Note,
|
||||
OtpUri,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryEditorField {
|
||||
id: u64,
|
||||
kind: MobileEntryEditorFieldKind,
|
||||
name: Option<String>,
|
||||
label: String,
|
||||
system_image: String,
|
||||
value: Option<String>,
|
||||
masked_value: String,
|
||||
diagnostic: Option<String>,
|
||||
sensitive: bool,
|
||||
multiline: bool,
|
||||
name_editable: bool,
|
||||
removable: bool,
|
||||
reorderable: bool,
|
||||
}
|
||||
|
||||
impl MobileEntryEditorField {
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> MobileEntryEditorFieldKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Option<&str> {
|
||||
self.value.as_deref()
|
||||
}
|
||||
|
||||
pub fn masked_value(&self) -> &str {
|
||||
&self.masked_value
|
||||
}
|
||||
|
||||
pub fn diagnostic(&self) -> Option<&str> {
|
||||
self.diagnostic.as_deref()
|
||||
}
|
||||
|
||||
pub fn sensitive(&self) -> bool {
|
||||
self.sensitive
|
||||
}
|
||||
|
||||
pub fn multiline(&self) -> bool {
|
||||
self.multiline
|
||||
}
|
||||
|
||||
pub fn name_editable(&self) -> bool {
|
||||
self.name_editable
|
||||
}
|
||||
|
||||
pub fn removable(&self) -> bool {
|
||||
self.removable
|
||||
}
|
||||
|
||||
pub fn reorderable(&self) -> bool {
|
||||
self.reorderable
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryEditorPage {
|
||||
path: String,
|
||||
title: String,
|
||||
fields: Vec<MobileEntryEditorField>,
|
||||
dirty: bool,
|
||||
creating: bool,
|
||||
default_password_length: u32,
|
||||
maximum_password_length: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryEditorSession {
|
||||
id: u64,
|
||||
page: MobileEntryEditorPage,
|
||||
}
|
||||
|
||||
impl MobileEntryEditorSession {
|
||||
pub fn new(id: u64, page: MobileEntryEditorPage) -> Self {
|
||||
Self { id, page }
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn page(&self) -> &MobileEntryEditorPage {
|
||||
&self.page
|
||||
}
|
||||
}
|
||||
|
||||
impl MobileEntryEditorPage {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn fields(&self) -> &[MobileEntryEditorField] {
|
||||
&self.fields
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
pub fn creating(&self) -> bool {
|
||||
self.creating
|
||||
}
|
||||
|
||||
pub fn default_password_length(&self) -> u32 {
|
||||
self.default_password_length
|
||||
}
|
||||
|
||||
pub fn maximum_password_length(&self) -> u32 {
|
||||
self.maximum_password_length
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryEditorInput {
|
||||
id: u64,
|
||||
name: Option<String>,
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
impl MobileEntryEditorInput {
|
||||
pub fn new(id: u64, name: Option<String>, value: Option<String>) -> Self {
|
||||
Self { id, name, value }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileEntryDraft {
|
||||
document: EntryDocument,
|
||||
creating: bool,
|
||||
}
|
||||
|
||||
impl MobileEntryDraft {
|
||||
pub fn new(document: EntryDocument, creating: bool) -> Self {
|
||||
Self { document, creating }
|
||||
}
|
||||
|
||||
pub fn page(&self) -> MobileEntryEditorPage {
|
||||
let fields = self
|
||||
.document
|
||||
.fields()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| editor_field(index, field))
|
||||
.collect();
|
||||
MobileEntryEditorPage {
|
||||
path: self.document.path().to_string(),
|
||||
title: self
|
||||
.document
|
||||
.path()
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Password")
|
||||
.to_owned(),
|
||||
fields,
|
||||
dirty: self.document.is_modified(),
|
||||
creating: self.creating,
|
||||
default_password_length: GeneratorConfig::pass_defaults().default_length().get() as u32,
|
||||
maximum_password_length: MAX_PASSWORD_LENGTH as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(
|
||||
&mut self,
|
||||
inputs: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<(), MobileEntryEditorError> {
|
||||
if inputs.len() != self.document.fields().len() {
|
||||
return Err(MobileEntryEditorError::FieldSetChanged);
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut replacements = Vec::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
if !seen.insert(input.id) {
|
||||
return Err(MobileEntryEditorError::FieldSetChanged);
|
||||
}
|
||||
let id = EntryFieldId::from_value(input.id);
|
||||
let field = self
|
||||
.document
|
||||
.field(id)
|
||||
.ok_or(MobileEntryEditorError::FieldSetChanged)?;
|
||||
if let Some(value) = input.value {
|
||||
replacements.push((id, editor_draft(field, input.name, value)?));
|
||||
} else if field.metadata().diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value) {
|
||||
return Err(MobileEntryEditorError::NonUtf8);
|
||||
}
|
||||
}
|
||||
for (id, replacement) in replacements {
|
||||
self.document.update(id, replacement)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&mut self,
|
||||
kind: MobileEntryEditorFieldKind,
|
||||
name: Option<String>,
|
||||
value: String,
|
||||
) -> Result<(), MobileEntryEditorError> {
|
||||
let draft = match kind {
|
||||
MobileEntryEditorFieldKind::Password => {
|
||||
return Err(MobileEntryEditorError::ProtectedPasswordField);
|
||||
}
|
||||
MobileEntryEditorFieldKind::Field => EntryFieldDraft::field(
|
||||
name.ok_or(MobileEntryEditorError::MissingFieldName)?,
|
||||
value.into_bytes(),
|
||||
)?,
|
||||
MobileEntryEditorFieldKind::Note => {
|
||||
EntryFieldDraft::field("notes", value.into_bytes())?
|
||||
}
|
||||
MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?,
|
||||
};
|
||||
self.document.add(self.document.fields().len(), draft)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> {
|
||||
let id = EntryFieldId::from_value(id);
|
||||
if self
|
||||
.document
|
||||
.fields()
|
||||
.first()
|
||||
.is_some_and(|field| field.id() == id)
|
||||
{
|
||||
return Err(MobileEntryEditorError::ProtectedPasswordField);
|
||||
}
|
||||
self.document.remove(id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reorder(&mut self, id: u64, index: usize) -> Result<(), MobileEntryEditorError> {
|
||||
let id = EntryFieldId::from_value(id);
|
||||
if index == 0
|
||||
|| self
|
||||
.document
|
||||
.fields()
|
||||
.first()
|
||||
.is_some_and(|field| field.id() == id)
|
||||
{
|
||||
return Err(MobileEntryEditorError::ProtectedPasswordField);
|
||||
}
|
||||
self.document.reorder(id, index)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_password(
|
||||
&mut self,
|
||||
length: Option<u32>,
|
||||
no_symbols: bool,
|
||||
) -> Result<(), MobileEntryEditorError> {
|
||||
let length = length
|
||||
.map(|length| {
|
||||
usize::try_from(length)
|
||||
.ok()
|
||||
.and_then(NonZeroUsize::new)
|
||||
.ok_or(MobileEntryEditorError::Generate(
|
||||
GenerateError::InvalidLength,
|
||||
))
|
||||
})
|
||||
.transpose()?;
|
||||
let generated = GeneratorConfig::pass_defaults().generate_secret(length, no_symbols)?;
|
||||
let value = generated.expose().to_vec();
|
||||
if let Some(password) = self
|
||||
.document
|
||||
.fields()
|
||||
.iter()
|
||||
.find(|field| field.metadata().kind() == EntryFieldKind::Password)
|
||||
{
|
||||
self.document
|
||||
.update(password.id(), EntryFieldDraft::line(value)?)?;
|
||||
} else {
|
||||
self.document.add(0, EntryFieldDraft::line(value)?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn document(&self) -> &EntryDocument {
|
||||
&self.document
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MobileEntryEditorError {
|
||||
Document(DocumentError),
|
||||
Generate(GenerateError),
|
||||
FieldSetChanged,
|
||||
MissingFieldName,
|
||||
ProtectedPasswordField,
|
||||
NonUtf8,
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileEntryEditorError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Document(error) => error.fmt(formatter),
|
||||
Self::Generate(error) => error.fmt(formatter),
|
||||
Self::FieldSetChanged => formatter.write_str("the entry fields changed while editing"),
|
||||
Self::MissingFieldName => formatter.write_str("a named field requires a name"),
|
||||
Self::ProtectedPasswordField => {
|
||||
formatter.write_str("the first password field cannot be removed or moved")
|
||||
}
|
||||
Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobileEntryEditorError {}
|
||||
|
||||
impl From<DocumentError> for MobileEntryEditorError {
|
||||
fn from(error: DocumentError) -> Self {
|
||||
Self::Document(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GenerateError> for MobileEntryEditorError {
|
||||
fn from(error: GenerateError) -> Self {
|
||||
Self::Generate(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl MobileEntryPage {
|
||||
pub fn from_document(document: &EntryDocument) -> Self {
|
||||
let title = document
|
||||
@@ -213,12 +560,71 @@ fn mobile_field(field: &EntryField) -> MobileEntryField {
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
|
||||
let metadata = field.metadata();
|
||||
let kind = if is_named_note(field) {
|
||||
MobileEntryEditorFieldKind::Note
|
||||
} else {
|
||||
match metadata.kind() {
|
||||
EntryFieldKind::Password => MobileEntryEditorFieldKind::Password,
|
||||
EntryFieldKind::OtpUri => MobileEntryEditorFieldKind::OtpUri,
|
||||
EntryFieldKind::Note => MobileEntryEditorFieldKind::Note,
|
||||
EntryFieldKind::Username
|
||||
| EntryFieldKind::Email
|
||||
| EntryFieldKind::Url
|
||||
| EntryFieldKind::Field => MobileEntryEditorFieldKind::Field,
|
||||
}
|
||||
};
|
||||
let sensitive = matches!(
|
||||
kind,
|
||||
MobileEntryEditorFieldKind::Password | MobileEntryEditorFieldKind::OtpUri
|
||||
);
|
||||
MobileEntryEditorField {
|
||||
id: field.id().value(),
|
||||
kind,
|
||||
name: metadata.name().map(str::to_owned),
|
||||
label: label(field),
|
||||
system_image: system_image(metadata.kind()).to_owned(),
|
||||
value: String::from_utf8(field.value().to_vec()).ok(),
|
||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||
"Empty"
|
||||
} else if sensitive {
|
||||
"Hidden"
|
||||
} else {
|
||||
"Unavailable"
|
||||
}
|
||||
.to_owned(),
|
||||
diagnostic: metadata.diagnostic().map(diagnostic),
|
||||
sensitive,
|
||||
multiline: matches!(kind, MobileEntryEditorFieldKind::Note)
|
||||
|| field.value().contains(&b'\n'),
|
||||
name_editable: matches!(kind, MobileEntryEditorFieldKind::Field),
|
||||
removable: index > 0,
|
||||
reorderable: index > 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_draft(
|
||||
field: &EntryField,
|
||||
name: Option<String>,
|
||||
value: String,
|
||||
) -> Result<EntryFieldDraft, MobileEntryEditorError> {
|
||||
Ok(match field.metadata().kind() {
|
||||
EntryFieldKind::Password => EntryFieldDraft::line(value.into_bytes())?,
|
||||
EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?,
|
||||
EntryFieldKind::Note => EntryFieldDraft::multiline(value.into_bytes()),
|
||||
EntryFieldKind::Username
|
||||
| EntryFieldKind::Email
|
||||
| EntryFieldKind::Url
|
||||
| EntryFieldKind::Field => EntryFieldDraft::field(
|
||||
name.ok_or(MobileEntryEditorError::MissingFieldName)?,
|
||||
value.into_bytes(),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn section_kind(field: &EntryField) -> MobileEntrySectionKind {
|
||||
if field.metadata().name().is_some_and(|name| {
|
||||
["comment", "comments", "note", "notes"]
|
||||
.iter()
|
||||
.any(|candidate| name.eq_ignore_ascii_case(candidate))
|
||||
}) {
|
||||
if is_named_note(field) {
|
||||
return MobileEntrySectionKind::Notes;
|
||||
}
|
||||
match field.metadata().kind() {
|
||||
@@ -232,6 +638,14 @@ fn section_kind(field: &EntryField) -> MobileEntrySectionKind {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_named_note(field: &EntryField) -> bool {
|
||||
field.metadata().name().is_some_and(|name| {
|
||||
["comment", "comments", "note", "notes"]
|
||||
.iter()
|
||||
.any(|candidate| name.eq_ignore_ascii_case(candidate))
|
||||
})
|
||||
}
|
||||
|
||||
fn label(field: &EntryField) -> String {
|
||||
if field.metadata().kind() == EntryFieldKind::Password {
|
||||
return "Password".to_owned();
|
||||
|
||||
Reference in New Issue
Block a user