Implement structured iPhone entry viewer
This commit is contained in:
@@ -12,6 +12,10 @@ use ironstorage::{
|
||||
MobileAuthenticationError as StorageAuthenticationError,
|
||||
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
||||
MobileAuthenticationState as StorageAuthenticationState,
|
||||
MobileEntryCopy as StorageEntryCopy,
|
||||
},
|
||||
mobile_entry::{
|
||||
MobileEntryPage as StorageEntryPage, MobileEntrySectionKind as StorageEntrySectionKind,
|
||||
},
|
||||
mobile_home::{
|
||||
self, MobileHomeChangeKind as StorageHomeChangeKind,
|
||||
@@ -532,6 +536,103 @@ pub struct MobileAuthenticationState {
|
||||
pub remaining_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileEntrySectionKind {
|
||||
Password,
|
||||
Details,
|
||||
OneTimePassword,
|
||||
Notes,
|
||||
}
|
||||
|
||||
impl From<StorageEntrySectionKind> for MobileEntrySectionKind {
|
||||
fn from(kind: StorageEntrySectionKind) -> Self {
|
||||
match kind {
|
||||
StorageEntrySectionKind::Password => Self::Password,
|
||||
StorageEntrySectionKind::Details => Self::Details,
|
||||
StorageEntrySectionKind::OneTimePassword => Self::OneTimePassword,
|
||||
StorageEntrySectionKind::Notes => Self::Notes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileEntryField {
|
||||
pub id: u64,
|
||||
pub label: String,
|
||||
pub system_image: String,
|
||||
pub value: Option<String>,
|
||||
pub masked_value: String,
|
||||
pub detail: Option<String>,
|
||||
pub diagnostic: Option<String>,
|
||||
pub sensitive: bool,
|
||||
pub multiline: bool,
|
||||
pub selectable: bool,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileEntrySection {
|
||||
pub kind: MobileEntrySectionKind,
|
||||
pub title: String,
|
||||
pub fields: Vec<MobileEntryField>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileEntryPage {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub sections: Vec<MobileEntrySection>,
|
||||
}
|
||||
|
||||
impl From<StorageEntryPage> for MobileEntryPage {
|
||||
fn from(page: StorageEntryPage) -> Self {
|
||||
Self {
|
||||
id: page.id().to_owned(),
|
||||
title: page.title().to_owned(),
|
||||
sections: page
|
||||
.sections()
|
||||
.iter()
|
||||
.map(|section| MobileEntrySection {
|
||||
kind: section.kind().into(),
|
||||
title: section.title().to_owned(),
|
||||
fields: section
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| MobileEntryField {
|
||||
id: field.id(),
|
||||
label: field.label().to_owned(),
|
||||
system_image: field.system_image().to_owned(),
|
||||
value: field.value().map(str::to_owned),
|
||||
masked_value: field.masked_value().to_owned(),
|
||||
detail: field.detail().map(str::to_owned),
|
||||
diagnostic: field.diagnostic().map(str::to_owned),
|
||||
sensitive: field.sensitive(),
|
||||
multiline: field.multiline(),
|
||||
selectable: field.selectable(),
|
||||
editable: field.editable(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileEntryCopy {
|
||||
pub value: String,
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl From<StorageEntryCopy> for MobileEntryCopy {
|
||||
fn from(copy: StorageEntryCopy) -> Self {
|
||||
Self {
|
||||
value: copy.value().to_owned(),
|
||||
timeout_seconds: copy.timeout_seconds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StorageAuthenticationState> for MobileAuthenticationState {
|
||||
fn from(state: StorageAuthenticationState) -> Self {
|
||||
Self {
|
||||
@@ -616,6 +717,49 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn entry_page(
|
||||
&self,
|
||||
path: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.entry_page(&path)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn reveal_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
field: u64,
|
||||
) -> Result<String, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.reveal_entry_field(&path, field)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn copy_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
field: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.copy_entry_field(&path, field)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn replace_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
field: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.replace_entry_field(&path, field, value)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationFfiError> {
|
||||
self.authentication.manual_lock().map_err(Into::into)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ use crate::{
|
||||
pub struct EntryFieldId(u64);
|
||||
|
||||
impl EntryFieldId {
|
||||
pub fn from_value(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
@@ -629,7 +633,11 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
if index == 0 {
|
||||
return EntryFieldMetadata {
|
||||
kind: EntryFieldKind::Password,
|
||||
sensitivity: EntrySensitivity::Sensitive,
|
||||
sensitivity: if line.is_empty() {
|
||||
EntrySensitivity::Empty
|
||||
} else {
|
||||
EntrySensitivity::Sensitive
|
||||
},
|
||||
name: Some("password".to_owned()),
|
||||
otp: None,
|
||||
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
|
||||
@@ -663,7 +671,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
let kind = semantic_field_kind(name);
|
||||
return EntryFieldMetadata {
|
||||
kind,
|
||||
sensitivity: field_sensitivity(name, kind),
|
||||
sensitivity: EntrySensitivity::Ordinary,
|
||||
name: Some(name.to_owned()),
|
||||
otp: None,
|
||||
diagnostic: std::str::from_utf8(&line[value_start..])
|
||||
@@ -674,7 +682,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
}
|
||||
EntryFieldMetadata {
|
||||
kind: EntryFieldKind::Note,
|
||||
sensitivity: EntrySensitivity::Sensitive,
|
||||
sensitivity: EntrySensitivity::Ordinary,
|
||||
name: None,
|
||||
otp: None,
|
||||
diagnostic: std::str::from_utf8(line)
|
||||
@@ -693,19 +701,6 @@ fn semantic_field_kind(name: &str) -> EntryFieldKind {
|
||||
}
|
||||
}
|
||||
|
||||
fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity {
|
||||
if matches!(
|
||||
kind,
|
||||
EntryFieldKind::Username | EntryFieldKind::Email | EntryFieldKind::Url
|
||||
) {
|
||||
return EntrySensitivity::Ordinary;
|
||||
}
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"title" | "site" | "host" | "autotype_enabled" | "icon" => EntrySensitivity::Ordinary,
|
||||
_ => EntrySensitivity::Sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_key(field: &EntryField) -> (u8, String) {
|
||||
let metadata = field.metadata();
|
||||
let name = metadata.name().unwrap_or_default().to_lowercase();
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod git;
|
||||
pub mod kdbx;
|
||||
pub mod mobile;
|
||||
pub mod mobile_authentication;
|
||||
pub mod mobile_entry;
|
||||
pub mod mobile_home;
|
||||
pub mod mobile_onboarding;
|
||||
pub mod mobile_passwords;
|
||||
|
||||
@@ -8,6 +8,9 @@ use crate::{
|
||||
},
|
||||
config::{Config, ConfigError},
|
||||
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},
|
||||
secret_store::{SecretProtectionPolicy, SecretStoreError},
|
||||
};
|
||||
@@ -109,6 +112,22 @@ pub struct MobileAuthenticationState {
|
||||
remaining_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryCopy {
|
||||
value: String,
|
||||
timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl MobileEntryCopy {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn timeout_seconds(&self) -> u64 {
|
||||
self.timeout_seconds
|
||||
}
|
||||
}
|
||||
|
||||
impl MobileAuthenticationState {
|
||||
pub fn unlocked(self) -> bool {
|
||||
self.unlocked
|
||||
@@ -328,6 +347,50 @@ impl MobileAuthentication {
|
||||
.map_err(MobileAuthenticationError::authentication)
|
||||
}
|
||||
|
||||
pub fn entry_page(&self, path: &str) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
let document = self.open_active_document(path)?;
|
||||
Ok(MobileEntryPage::from_document(&document))
|
||||
}
|
||||
|
||||
pub fn reveal_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field: u64,
|
||||
) -> Result<String, MobileAuthenticationError> {
|
||||
let document = self.open_active_document(path)?;
|
||||
field_value(&document, field).map_err(value_error)
|
||||
}
|
||||
|
||||
pub fn copy_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||
Ok(MobileEntryCopy {
|
||||
value: self.reveal_entry_field(path, field)?,
|
||||
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn replace_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
let mut document = self.open_active_document(path)?;
|
||||
document
|
||||
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
|
||||
.map_err(document_error)?;
|
||||
let mut committer =
|
||||
AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage())
|
||||
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
|
||||
EntryDocumentService::new(&self.repository, &self.keys)
|
||||
.save_recoverable(&document, None, &mut committer)
|
||||
.map_err(document_error)?;
|
||||
Ok(MobileEntryPage::from_document(&document))
|
||||
}
|
||||
|
||||
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
|
||||
self.session
|
||||
.manual_lock()
|
||||
@@ -383,6 +446,21 @@ impl MobileAuthentication {
|
||||
self.status()?.active = Some(ActiveMobileLease { handle, key });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_active_document(&self, path: &str) -> Result<EntryDocument, MobileAuthenticationError> {
|
||||
let (handle, key) = {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
(active.handle.clone(), active.key.clone())
|
||||
};
|
||||
handle
|
||||
.ensure_active()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
EntryDocumentService::new(&self.repository, &self.keys)
|
||||
.open(path, &mut provider)
|
||||
.map_err(document_error)
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyOnlyProvider<'a> {
|
||||
@@ -431,3 +509,27 @@ fn key_error(error: CryptoError) -> MobileAuthenticationError {
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn locked_error() -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Expired,
|
||||
"IronStorage Locked",
|
||||
"Authenticate before using protected content.",
|
||||
)
|
||||
}
|
||||
|
||||
fn document_error(error: DocumentError) -> MobileAuthenticationError {
|
||||
entry_detail("Password Entry Is Unavailable", error)
|
||||
}
|
||||
|
||||
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
|
||||
entry_detail("Field Value Is Unavailable", error)
|
||||
}
|
||||
|
||||
fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Entry,
|
||||
title,
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
305
crates/storage/src/mobile_entry.rs
Normal file
305
crates/storage/src/mobile_entry.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
//! Storage-owned projection of lossless entry documents for native mobile viewers.
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::document::{
|
||||
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind,
|
||||
EntrySensitivity,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileEntrySectionKind {
|
||||
Password,
|
||||
Details,
|
||||
OneTimePassword,
|
||||
Notes,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryField {
|
||||
id: u64,
|
||||
label: String,
|
||||
system_image: String,
|
||||
value: Option<String>,
|
||||
masked_value: String,
|
||||
detail: Option<String>,
|
||||
diagnostic: Option<String>,
|
||||
sensitive: bool,
|
||||
multiline: bool,
|
||||
selectable: bool,
|
||||
editable: bool,
|
||||
}
|
||||
|
||||
impl MobileEntryField {
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
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 detail(&self) -> Option<&str> {
|
||||
self.detail.as_deref()
|
||||
}
|
||||
|
||||
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 selectable(&self) -> bool {
|
||||
self.selectable
|
||||
}
|
||||
|
||||
pub fn editable(&self) -> bool {
|
||||
self.editable
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntrySection {
|
||||
kind: MobileEntrySectionKind,
|
||||
title: String,
|
||||
fields: Vec<MobileEntryField>,
|
||||
}
|
||||
|
||||
impl MobileEntrySection {
|
||||
pub fn kind(&self) -> MobileEntrySectionKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn fields(&self) -> &[MobileEntryField] {
|
||||
&self.fields
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryPage {
|
||||
id: String,
|
||||
title: String,
|
||||
sections: Vec<MobileEntrySection>,
|
||||
}
|
||||
|
||||
impl MobileEntryPage {
|
||||
pub fn from_document(document: &EntryDocument) -> Self {
|
||||
let title = document
|
||||
.path()
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Password")
|
||||
.to_owned();
|
||||
let mut sections = Vec::new();
|
||||
for (kind, section_title) in [
|
||||
(MobileEntrySectionKind::Password, "Password"),
|
||||
(MobileEntrySectionKind::Details, "Details"),
|
||||
(MobileEntrySectionKind::OneTimePassword, "One-Time Password"),
|
||||
(MobileEntrySectionKind::Notes, "Notes"),
|
||||
] {
|
||||
let fields = document
|
||||
.display_fields()
|
||||
.into_iter()
|
||||
.filter(|field| section_kind(field) == kind)
|
||||
.map(mobile_field)
|
||||
.collect::<Vec<_>>();
|
||||
if !fields.is_empty() {
|
||||
sections.push(MobileEntrySection {
|
||||
kind,
|
||||
title: section_title.to_owned(),
|
||||
fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
Self {
|
||||
id: format!("entry:{}", document.path()),
|
||||
title,
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn sections(&self) -> &[MobileEntrySection] {
|
||||
&self.sections
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MobileEntryValueError {
|
||||
Document(DocumentError),
|
||||
NonUtf8,
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileEntryValueError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Document(error) => error.fmt(formatter),
|
||||
Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobileEntryValueError {}
|
||||
|
||||
impl From<DocumentError> for MobileEntryValueError {
|
||||
fn from(error: DocumentError) -> Self {
|
||||
Self::Document(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn field_value(document: &EntryDocument, id: u64) -> Result<String, MobileEntryValueError> {
|
||||
let value = document.copy_field_value(EntryFieldId::from_value(id))?;
|
||||
String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8)
|
||||
}
|
||||
|
||||
fn mobile_field(field: &EntryField) -> MobileEntryField {
|
||||
let metadata = field.metadata();
|
||||
let sensitive = metadata.sensitivity() == EntrySensitivity::Sensitive;
|
||||
let value = if sensitive || metadata.sensitivity() == EntrySensitivity::Empty {
|
||||
None
|
||||
} else {
|
||||
String::from_utf8(field.value().to_vec()).ok()
|
||||
};
|
||||
MobileEntryField {
|
||||
id: field.id().value(),
|
||||
label: label(field),
|
||||
system_image: system_image(metadata.kind()).to_owned(),
|
||||
value,
|
||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||
"Empty"
|
||||
} else if sensitive {
|
||||
"Hidden"
|
||||
} else {
|
||||
"Unavailable"
|
||||
}
|
||||
.to_owned(),
|
||||
detail: otp_detail(field),
|
||||
diagnostic: metadata.diagnostic().map(diagnostic),
|
||||
sensitive,
|
||||
multiline: matches!(metadata.kind(), EntryFieldKind::Note)
|
||||
|| field.value().contains(&b'\n'),
|
||||
selectable: !sensitive && metadata.diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value),
|
||||
editable: true,
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}) {
|
||||
return MobileEntrySectionKind::Notes;
|
||||
}
|
||||
match field.metadata().kind() {
|
||||
EntryFieldKind::Password => MobileEntrySectionKind::Password,
|
||||
EntryFieldKind::OtpUri => MobileEntrySectionKind::OneTimePassword,
|
||||
EntryFieldKind::Note => MobileEntrySectionKind::Notes,
|
||||
EntryFieldKind::Username
|
||||
| EntryFieldKind::Email
|
||||
| EntryFieldKind::Url
|
||||
| EntryFieldKind::Field => MobileEntrySectionKind::Details,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(field: &EntryField) -> String {
|
||||
if field.metadata().kind() == EntryFieldKind::Password {
|
||||
return "Password".to_owned();
|
||||
}
|
||||
if field.metadata().kind() == EntryFieldKind::OtpUri {
|
||||
return "OTP URI".to_owned();
|
||||
}
|
||||
field
|
||||
.metadata()
|
||||
.name()
|
||||
.map(display_name)
|
||||
.unwrap_or_else(|| match field.metadata().kind() {
|
||||
EntryFieldKind::Username => "Username".to_owned(),
|
||||
EntryFieldKind::Email => "Email".to_owned(),
|
||||
EntryFieldKind::Url => "Website".to_owned(),
|
||||
EntryFieldKind::Field => "Field".to_owned(),
|
||||
EntryFieldKind::Note => "Notes".to_owned(),
|
||||
EntryFieldKind::Password | EntryFieldKind::OtpUri => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
fn display_name(name: &str) -> String {
|
||||
let mut characters = name.chars();
|
||||
match characters.next() {
|
||||
Some(first) => first.to_uppercase().chain(characters).collect(),
|
||||
None => "Field".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn system_image(kind: EntryFieldKind) -> &'static str {
|
||||
match kind {
|
||||
EntryFieldKind::Password => "key.fill",
|
||||
EntryFieldKind::Username => "person.fill",
|
||||
EntryFieldKind::Email => "envelope.fill",
|
||||
EntryFieldKind::Url => "globe",
|
||||
EntryFieldKind::OtpUri => "timer",
|
||||
EntryFieldKind::Field => "text.alignleft",
|
||||
EntryFieldKind::Note => "note.text",
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic(diagnostic: EntryFieldDiagnostic) -> String {
|
||||
match diagnostic {
|
||||
EntryFieldDiagnostic::MalformedOtpUri => {
|
||||
"This OTP URI is malformed. Its original value is preserved.".to_owned()
|
||||
}
|
||||
EntryFieldDiagnostic::NonUtf8Value => {
|
||||
"This field is not UTF-8 text. Its original bytes are preserved.".to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn otp_detail(field: &EntryField) -> Option<String> {
|
||||
let otp = field.metadata().otp()?;
|
||||
let mut parts = vec![format!("{:?}", otp.kind()).to_uppercase()];
|
||||
if let Some(issuer) = otp.issuer() {
|
||||
parts.push(issuer.to_owned());
|
||||
}
|
||||
if !otp.account().is_empty() {
|
||||
parts.push(otp.account().to_owned());
|
||||
}
|
||||
parts.push(format!("{:?}", otp.algorithm()).to_uppercase());
|
||||
parts.push(format!("{} digits", otp.digits()));
|
||||
if let Some(period) = otp.period() {
|
||||
parts.push(format!("{period} seconds"));
|
||||
}
|
||||
if let Some(counter) = otp.counter() {
|
||||
parts.push(format!("counter {counter}"));
|
||||
}
|
||||
Some(parts.join(" · "))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use ironstorage::{
|
||||
document::{
|
||||
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
|
||||
},
|
||||
mobile_entry::{MobileEntryPage, MobileEntrySectionKind, field_value},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
|
||||
@@ -134,6 +135,15 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
||||
document.fields()[6].metadata().sensitivity(),
|
||||
EntrySensitivity::Sensitive
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.fields()
|
||||
.iter()
|
||||
.filter(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive)
|
||||
.map(|field| field.metadata().kind())
|
||||
.collect::<Vec<_>>(),
|
||||
[EntryFieldKind::Password, EntryFieldKind::OtpUri]
|
||||
);
|
||||
let otp = document.fields()[6]
|
||||
.metadata()
|
||||
.otp()
|
||||
@@ -147,11 +157,123 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
||||
assert_eq!(otp.counter(), None);
|
||||
let copied = document.copy_field_value(document.fields()[4].id())?;
|
||||
assert_eq!(copied.expose(), b"one");
|
||||
let mobile = MobileEntryPage::from_document(&document);
|
||||
let mobile_fields = mobile
|
||||
.sections()
|
||||
.iter()
|
||||
.flat_map(|section| section.fields())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(mobile.title(), "complex");
|
||||
assert_eq!(mobile_fields.len(), document.fields().len());
|
||||
assert_eq!(mobile_fields[0].label(), "Password");
|
||||
assert!(mobile_fields[0].sensitive());
|
||||
assert_eq!(mobile_fields[0].value(), None);
|
||||
assert_eq!(mobile_fields[1].value(), Some("alice"));
|
||||
let mobile_custom = mobile_fields
|
||||
.iter()
|
||||
.filter(|field| field.label() == "Custom")
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(mobile_custom.len(), 2);
|
||||
assert_ne!(mobile_custom[0].id(), mobile_custom[1].id());
|
||||
assert_eq!(mobile_custom[0].value(), Some("one"));
|
||||
assert_eq!(mobile_custom[1].value(), Some(""));
|
||||
let mobile_otp = mobile_fields
|
||||
.iter()
|
||||
.find(|field| field.system_image() == "timer")
|
||||
.expect("OTP field");
|
||||
assert!(mobile_otp.sensitive());
|
||||
assert_eq!(mobile_otp.value(), None);
|
||||
assert!(mobile_otp.detail().is_some_and(|detail| {
|
||||
detail.contains("TOTP") && detail.contains("Example") && detail.contains("alice")
|
||||
}));
|
||||
let mobile_note = mobile_fields
|
||||
.iter()
|
||||
.find(|field| field.system_image() == "note.text")
|
||||
.expect("multiline note");
|
||||
assert!(!mobile_note.sensitive());
|
||||
assert!(mobile_note.multiline());
|
||||
assert!(mobile_note.selectable());
|
||||
assert_eq!(mobile_note.value(), Some("\r\nfirst note\r\nsecond note"));
|
||||
assert_eq!(
|
||||
field_value(&document, document.fields()[4].id().value())?,
|
||||
"one"
|
||||
);
|
||||
assert!(!format!("{document:?}").contains("pässwörd"));
|
||||
assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_projection_preserves_partially_understood_fields_without_exposing_them() -> 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/diagnostics",
|
||||
b"password\notpauth://totp/broken\ncustom: \xff\n",
|
||||
)?;
|
||||
|
||||
let document = EntryDocumentService::new(&repository, &keys)
|
||||
.open("documents/diagnostics", &mut secrets)?;
|
||||
let page = MobileEntryPage::from_document(&document);
|
||||
let fields = page
|
||||
.sections()
|
||||
.iter()
|
||||
.flat_map(|section| section.fields())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(fields.len(), 3);
|
||||
assert_eq!(
|
||||
fields
|
||||
.iter()
|
||||
.filter(|field| field.diagnostic().is_some())
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(fields.iter().all(|field| field.value().is_none()));
|
||||
assert!(field_value(&document, document.fields()[2].id().value()).is_err());
|
||||
assert_eq!(
|
||||
document.serialize().expose(),
|
||||
b"password\notpauth://totp/broken\ncustom: \xff\n"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> 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/empty-password",
|
||||
b"\ncomments: ordinary note\n",
|
||||
)?;
|
||||
|
||||
let document = EntryDocumentService::new(&repository, &keys)
|
||||
.open("documents/empty-password", &mut secrets)?;
|
||||
let password = document.password().expect("empty password field");
|
||||
assert_eq!(password.metadata().sensitivity(), EntrySensitivity::Empty);
|
||||
let page = MobileEntryPage::from_document(&document);
|
||||
let password = page.sections()[0].fields().first().expect("password row");
|
||||
assert_eq!(password.label(), "Password");
|
||||
assert_eq!(password.value(), None);
|
||||
assert_eq!(password.masked_value(), "Empty");
|
||||
assert!(!password.sensitive());
|
||||
assert_eq!(
|
||||
document.serialize().expose(),
|
||||
b"\ncomments: ordinary note\n"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
@@ -189,7 +311,7 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let mut secrets = FixtureSecrets::all(&fixture);
|
||||
let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\n";
|
||||
let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\ntags: travel, train\n";
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
@@ -200,8 +322,12 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
||||
let document =
|
||||
EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?;
|
||||
assert_eq!(document.serialize().expose(), plaintext.as_bytes());
|
||||
assert_eq!(document.fields().len(), 4);
|
||||
assert_eq!(document.fields().len(), 5);
|
||||
assert_eq!(document.fields()[1].metadata().name(), Some("comments"));
|
||||
assert_eq!(
|
||||
document.fields()[1].metadata().sensitivity(),
|
||||
EntrySensitivity::Ordinary
|
||||
);
|
||||
assert_eq!(
|
||||
document.fields()[1].value(),
|
||||
"PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n"
|
||||
@@ -212,6 +338,25 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
||||
Some("autotype_enabled")
|
||||
);
|
||||
assert_eq!(document.fields()[3].metadata().name(), Some("icon"));
|
||||
assert_eq!(document.fields()[4].metadata().name(), Some("tags"));
|
||||
assert_eq!(
|
||||
document.fields()[4].metadata().sensitivity(),
|
||||
EntrySensitivity::Ordinary
|
||||
);
|
||||
let mobile = MobileEntryPage::from_document(&document);
|
||||
let comments = mobile
|
||||
.sections()
|
||||
.iter()
|
||||
.find(|section| section.kind() == MobileEntrySectionKind::Notes)
|
||||
.and_then(|section| section.fields().first())
|
||||
.expect("multiline comments section");
|
||||
assert_eq!(comments.label(), "Comments");
|
||||
assert!(!comments.sensitive());
|
||||
assert!(comments.multiline());
|
||||
assert_eq!(
|
||||
comments.value(),
|
||||
std::str::from_utf8(document.fields()[1].value()).ok()
|
||||
);
|
||||
assert!(matches!(
|
||||
EntryFieldDraft::field("www.bahn.de Login", Vec::new()),
|
||||
Err(DocumentError::InvalidFieldName)
|
||||
@@ -228,6 +373,10 @@ fn only_whitespace_free_names_end_multiline_fields() -> TestResult {
|
||||
assert_eq!(note.password().expect("password").value(), b"password");
|
||||
assert_eq!(note.fields().len(), 2);
|
||||
assert_eq!(note.fields()[1].value(), b"\nordinary note\ncontinued");
|
||||
assert_eq!(
|
||||
note.fields()[1].metadata().sensitivity(),
|
||||
EntrySensitivity::Ordinary
|
||||
);
|
||||
|
||||
let unordered = "password\nzeta: last\nnote: named\ncomments: heading\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\nordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n";
|
||||
write_plaintext(
|
||||
|
||||
Reference in New Issue
Block a user