Implement native iPhone entry editor

This commit is contained in:
2026-08-11 20:27:14 +02:00
parent 900e62e523
commit ae64ce47a3
8 changed files with 2516 additions and 76 deletions

View File

@@ -320,6 +320,10 @@ impl EntryDocument {
self.conflict_token
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn add(
&mut self,
index: usize,

View File

@@ -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,

View File

@@ -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();

View File

@@ -9,7 +9,10 @@ use ironstorage::{
document::{
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
},
mobile_entry::{MobileEntryPage, MobileEntrySectionKind, field_value},
mobile_entry::{
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
MobileEntryEditorInput, MobileEntryPage, MobileEntrySectionKind, field_value,
},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
@@ -624,6 +627,133 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult {
Ok(())
}
#[test]
fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
write_plaintext(
&repository,
&keys,
"documents/mobile-editor",
b"old password\nlogin: alice\ncomments: first\nsecond\nnote: repeated one\nnote: repeated two\nbinary: \xff\n",
)?;
let service = EntryDocumentService::new(&repository, &keys);
let document = service.open("documents/mobile-editor", &mut secrets)?;
let mut draft = MobileEntryDraft::new(document, false);
let page = draft.page();
assert!(!page.dirty());
assert!(!page.creating());
assert_eq!(page.default_password_length(), 25);
assert_eq!(page.fields().len(), 6);
assert!(page.fields()[2].multiline());
assert_eq!(page.fields()[2].value(), Some("first\nsecond"));
assert!(page.fields()[5].value().is_none());
let password = page.fields()[0].id();
assert!(matches!(
draft.remove(password),
Err(MobileEntryEditorError::ProtectedPasswordField)
));
assert!(matches!(
draft.reorder(password, 1),
Err(MobileEntryEditorError::ProtectedPasswordField)
));
assert!(draft.generate_password(Some(0), false).is_err());
let repeated_one = page.fields()[3].id();
let inputs = page
.fields()
.iter()
.map(|field| {
let (name, value) = if field.label() == "Login" {
(Some("username".to_owned()), Some("bob".to_owned()))
} else if field.label() == "Comments" {
(
field.name().map(str::to_owned),
Some("heading\nline: preserved\n\nend".to_owned()),
)
} else {
(
field.name().map(str::to_owned),
field.value().map(str::to_owned),
)
};
MobileEntryEditorInput::new(field.id(), name, value)
})
.collect();
draft.apply(inputs)?;
draft.add(
MobileEntryEditorFieldKind::Field,
Some("tags".to_owned()),
"work, mobile".to_owned(),
)?;
draft.add(
MobileEntryEditorFieldKind::Note,
None,
"open note\nsecond line".to_owned(),
)?;
draft.remove(repeated_one)?;
let open_note = draft.page().fields().last().expect("open note").id();
draft.reorder(open_note, 2)?;
draft.generate_password(Some(32), true)?;
let edited = draft.page();
assert!(edited.dirty());
let password = edited.fields().first().expect("password").value().unwrap();
assert_eq!(password.len(), 32);
assert!(
password
.chars()
.all(|character| character.is_ascii_alphanumeric())
);
assert_eq!(edited.fields()[2].value(), Some("open note\nsecond line"));
let mut committer = Committer::default();
service.save_recoverable(draft.document(), None, &mut committer)?;
let reopened = service.open("documents/mobile-editor", &mut secrets)?;
assert_eq!(
reopened
.fields()
.iter()
.filter(|field| field.metadata().name() == Some("note"))
.count(),
1
);
assert!(
reopened.fields().iter().any(|field| {
field.metadata().name() == Some("username") && field.value() == b"bob"
})
);
assert!(reopened.fields().iter().any(|field| {
field.metadata().name() == Some("comments")
&& field.value() == b"heading\nline: preserved\n\nend"
}));
assert!(reopened.fields().iter().any(|field| {
field.metadata().name() == Some("tags") && field.value() == b"work, mobile"
}));
assert!(
reopened
.fields()
.iter()
.any(|field| { field.metadata().name() == Some("binary") && field.value() == b"\xff" })
);
let mut created = service.open("documents/cancelled-mobile", &mut secrets)?;
created.add(0, EntryFieldDraft::line(Vec::new())?)?;
let created = MobileEntryDraft::new(created, true);
assert!(created.page().creating());
assert!(created.page().dirty());
drop(created);
assert!(matches!(
repository.read_entry(&EntryPath::parse("documents/cancelled-mobile")?),
Err(ironstorage::repository::RepositoryError::NotFound { .. })
));
Ok(())
}
#[test]
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
let fixture = FixtureSet::load()?;