Implement iPhone password swipe actions
This commit is contained in:
@@ -37,6 +37,11 @@ use ironstorage::{
|
||||
MobileKeyTransferKind as StorageKeyTransferKind,
|
||||
MobileKeyTransferProgress as StorageKeyTransferProgress,
|
||||
},
|
||||
mobile_mutation::{
|
||||
MobileMutationAction as StorageMutationAction,
|
||||
MobileMutationOutcome as StorageMutationOutcome, MobileMutationPlan as StorageMutationPlan,
|
||||
MobileMutationRequest as StorageMutationRequest,
|
||||
},
|
||||
mobile_onboarding::{
|
||||
self, MobileOnboardingError as StorageOnboardingError,
|
||||
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
||||
@@ -560,6 +565,121 @@ pub struct MobileAuthenticationState {
|
||||
pub remaining_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileMutationAction {
|
||||
Move,
|
||||
Copy,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl From<StorageMutationAction> for MobileMutationAction {
|
||||
fn from(action: StorageMutationAction) -> Self {
|
||||
match action {
|
||||
StorageMutationAction::Move => Self::Move,
|
||||
StorageMutationAction::Copy => Self::Copy,
|
||||
StorageMutationAction::Delete => Self::Delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MobileMutationAction> for StorageMutationAction {
|
||||
fn from(action: MobileMutationAction) -> Self {
|
||||
match action {
|
||||
MobileMutationAction::Move => Self::Move,
|
||||
MobileMutationAction::Copy => Self::Copy,
|
||||
MobileMutationAction::Delete => Self::Delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileMutationDestination {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub requires_overwrite: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileMutationPlan {
|
||||
pub action: MobileMutationAction,
|
||||
pub source: String,
|
||||
pub source_title: String,
|
||||
pub revision: String,
|
||||
pub destinations: Vec<MobileMutationDestination>,
|
||||
pub has_open_editor: bool,
|
||||
pub has_dirty_editor: bool,
|
||||
}
|
||||
|
||||
impl From<StorageMutationPlan> for MobileMutationPlan {
|
||||
fn from(plan: StorageMutationPlan) -> Self {
|
||||
Self {
|
||||
action: plan.action().into(),
|
||||
source: plan.source().to_owned(),
|
||||
source_title: plan.source_title().to_owned(),
|
||||
revision: plan.revision().to_owned(),
|
||||
destinations: plan
|
||||
.destinations()
|
||||
.iter()
|
||||
.map(|destination| MobileMutationDestination {
|
||||
path: destination.path().to_owned(),
|
||||
title: destination.title().to_owned(),
|
||||
detail: destination.detail().to_owned(),
|
||||
requires_overwrite: destination.requires_overwrite(),
|
||||
})
|
||||
.collect(),
|
||||
has_open_editor: plan.has_open_editor(),
|
||||
has_dirty_editor: plan.has_dirty_editor(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileMutationRequest {
|
||||
pub action: MobileMutationAction,
|
||||
pub source: String,
|
||||
pub revision: String,
|
||||
pub destination: Option<String>,
|
||||
pub confirmed: bool,
|
||||
pub overwrite: bool,
|
||||
pub discard_editor: bool,
|
||||
}
|
||||
|
||||
impl From<MobileMutationRequest> for StorageMutationRequest {
|
||||
fn from(request: MobileMutationRequest) -> Self {
|
||||
Self {
|
||||
action: request.action.into(),
|
||||
source: request.source,
|
||||
revision: request.revision,
|
||||
destination: request.destination,
|
||||
confirmed: request.confirmed,
|
||||
overwrite: request.overwrite,
|
||||
discard_editor: request.discard_editor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileMutationOutcome {
|
||||
pub action: MobileMutationAction,
|
||||
pub source: String,
|
||||
pub destination: Option<String>,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl From<StorageMutationOutcome> for MobileMutationOutcome {
|
||||
fn from(outcome: StorageMutationOutcome) -> Self {
|
||||
Self {
|
||||
action: outcome.action().into(),
|
||||
source: outcome.source().to_owned(),
|
||||
destination: outcome.destination().map(str::to_owned),
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileEntrySectionKind {
|
||||
Password,
|
||||
@@ -1193,6 +1313,27 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prepare_entry_mutation(
|
||||
&self,
|
||||
path: String,
|
||||
action: MobileMutationAction,
|
||||
) -> Result<MobileMutationPlan, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.prepare_entry_mutation(&path, action.into())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn perform_entry_mutation(
|
||||
&self,
|
||||
request: MobileMutationRequest,
|
||||
) -> Result<MobileMutationOutcome, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.perform_entry_mutation(request.into())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn replace_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod mobile_authentication;
|
||||
pub mod mobile_entry;
|
||||
pub mod mobile_home;
|
||||
pub mod mobile_key_transfer;
|
||||
pub mod mobile_mutation;
|
||||
pub mod mobile_onboarding;
|
||||
pub mod mobile_passwords;
|
||||
pub mod mobile_totp;
|
||||
|
||||
@@ -9,12 +9,16 @@ use crate::{
|
||||
config::{Config, ConfigError},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
|
||||
git::{AutomaticEntryCommitter, GitIdentity},
|
||||
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
|
||||
mobile_entry::{
|
||||
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
||||
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
||||
MobileEntryValueError, field_value,
|
||||
},
|
||||
mobile_mutation::{
|
||||
MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan,
|
||||
MobileMutationRequest, MobileMutationService,
|
||||
},
|
||||
mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{
|
||||
@@ -162,6 +166,7 @@ struct MobileAuthenticationStatus {
|
||||
active: Option<ActiveMobileLease>,
|
||||
next_editor_id: u64,
|
||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||
mutation_active: bool,
|
||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||
}
|
||||
|
||||
@@ -196,6 +201,7 @@ impl MobileAuthentication {
|
||||
active: None,
|
||||
next_editor_id: 0,
|
||||
editors: BTreeMap::new(),
|
||||
mutation_active: false,
|
||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||
}),
|
||||
config,
|
||||
@@ -399,6 +405,66 @@ impl MobileAuthentication {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_entry_mutation(
|
||||
&self,
|
||||
path: &str,
|
||||
action: MobileMutationAction,
|
||||
) -> Result<MobileMutationPlan, MobileAuthenticationError> {
|
||||
let (has_open_editor, has_dirty_editor) = self.editor_state(path)?;
|
||||
MobileMutationService::new(&self.repository)
|
||||
.prepare(path, action, has_open_editor, has_dirty_editor)
|
||||
.map_err(mutation_error)
|
||||
}
|
||||
|
||||
pub fn perform_entry_mutation(
|
||||
&self,
|
||||
request: MobileMutationRequest,
|
||||
) -> Result<MobileMutationOutcome, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
self.reserve_entry_mutation()?;
|
||||
let result = self.perform_reserved_entry_mutation(request);
|
||||
self.release_entry_mutation();
|
||||
result
|
||||
}
|
||||
|
||||
fn perform_reserved_entry_mutation(
|
||||
&self,
|
||||
request: MobileMutationRequest,
|
||||
) -> Result<MobileMutationOutcome, 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())
|
||||
};
|
||||
let mut committer = AutomaticTreeCommitter::for_source(
|
||||
&self.repository,
|
||||
&request.source,
|
||||
GitIdentity::ironstorage(),
|
||||
)
|
||||
.map_err(git_mutation_error)?;
|
||||
let has_open_editor = self.editor_state(&request.source)?.0;
|
||||
let editors = if has_open_editor && request.discard_editor {
|
||||
self.take_entry_editors(&request.source)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
let result = MobileMutationService::new(&self.repository).perform(
|
||||
&request,
|
||||
has_open_editor && editors.is_empty(),
|
||||
&self.keys,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
);
|
||||
match result {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(error) => {
|
||||
self.restore_entry_editors(editors)?;
|
||||
Err(mutation_error(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unlock_totp(
|
||||
&self,
|
||||
passphrase: Option<SecretBytes>,
|
||||
@@ -766,6 +832,12 @@ impl MobileAuthentication {
|
||||
draft: MobileEntryDraft,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
if status.mutation_active {
|
||||
return Err(entry_detail(
|
||||
"Password Action In Progress",
|
||||
"wait for the current move, copy, or delete action to finish",
|
||||
));
|
||||
}
|
||||
let id = status.next_editor_id;
|
||||
status.next_editor_id = status.next_editor_id.checked_add(1).ok_or_else(|| {
|
||||
entry_detail(
|
||||
@@ -778,6 +850,66 @@ impl MobileAuthentication {
|
||||
Ok(MobileEntryEditorSession::new(id, page))
|
||||
}
|
||||
|
||||
fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
// ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it.
|
||||
if status.mutation_active {
|
||||
return Err(MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Conflict,
|
||||
"Password Action In Progress",
|
||||
"Wait for the current move, copy, or delete action to finish.",
|
||||
));
|
||||
}
|
||||
status.mutation_active = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_entry_mutation(&self) {
|
||||
if let Ok(mut status) = self.status.lock() {
|
||||
status.mutation_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> {
|
||||
let status = self.status()?;
|
||||
let mut matching = status
|
||||
.editors
|
||||
.values()
|
||||
.filter(|draft| draft.document().path().to_string() == path);
|
||||
let Some(first) = matching.next() else {
|
||||
return Ok((false, false));
|
||||
};
|
||||
Ok((
|
||||
true,
|
||||
first.document().is_modified() || matching.any(|draft| draft.document().is_modified()),
|
||||
))
|
||||
}
|
||||
|
||||
fn take_entry_editors(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Vec<(u64, MobileEntryDraft)>, MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
let ids = status
|
||||
.editors
|
||||
.iter()
|
||||
.filter(|(_, draft)| draft.document().path().to_string() == path)
|
||||
.map(|(id, _)| *id)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(ids
|
||||
.into_iter()
|
||||
.filter_map(|id| status.editors.remove(&id).map(|draft| (id, draft)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn restore_entry_editors(
|
||||
&self,
|
||||
editors: Vec<(u64, MobileEntryDraft)>,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.status()?.editors.extend(editors);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
@@ -870,6 +1002,38 @@ fn document_error(error: DocumentError) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(kind, "Password Entry Could Not Be Saved", error.to_string())
|
||||
}
|
||||
|
||||
fn mutation_error(error: MobileMutationError) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
if error.is_conflict() {
|
||||
MobileAuthenticationErrorKind::Conflict
|
||||
} else {
|
||||
MobileAuthenticationErrorKind::Entry
|
||||
},
|
||||
error.title(),
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn git_mutation_error(error: GitError) -> MobileAuthenticationError {
|
||||
let conflict = matches!(
|
||||
&error,
|
||||
GitError::DirtyWorktree | GitError::MergeConflicts { .. }
|
||||
);
|
||||
MobileAuthenticationError::new(
|
||||
if conflict {
|
||||
MobileAuthenticationErrorKind::Conflict
|
||||
} else {
|
||||
MobileAuthenticationErrorKind::Entry
|
||||
},
|
||||
if conflict {
|
||||
"Password Action Blocked by Git"
|
||||
} else {
|
||||
"Password Action Failed"
|
||||
},
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
|
||||
entry_detail("Field Value Is Unavailable", error)
|
||||
}
|
||||
|
||||
567
crates/storage/src/mobile_mutation.rs
Normal file
567
crates/storage/src/mobile_mutation.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
//! Storage-owned preparation and execution of native mobile entry mutations.
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::{
|
||||
command::{CopyRequest, MoveRequest, RemoveRequest},
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
mutation::{MutationError, TreeCommitter, TreeMutator},
|
||||
read::hidden_path,
|
||||
repository::{DirectoryPath, EntryPath, Repository, RepositoryError},
|
||||
write::OverwriteDecision,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileMutationAction {
|
||||
Move,
|
||||
Copy,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileMutationDestination {
|
||||
path: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
requires_overwrite: bool,
|
||||
}
|
||||
|
||||
impl MobileMutationDestination {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn requires_overwrite(&self) -> bool {
|
||||
self.requires_overwrite
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileMutationPlan {
|
||||
action: MobileMutationAction,
|
||||
source: String,
|
||||
source_title: String,
|
||||
revision: String,
|
||||
destinations: Vec<MobileMutationDestination>,
|
||||
has_open_editor: bool,
|
||||
has_dirty_editor: bool,
|
||||
}
|
||||
|
||||
impl MobileMutationPlan {
|
||||
pub fn action(&self) -> MobileMutationAction {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn source(&self) -> &str {
|
||||
&self.source
|
||||
}
|
||||
|
||||
pub fn source_title(&self) -> &str {
|
||||
&self.source_title
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> &str {
|
||||
&self.revision
|
||||
}
|
||||
|
||||
pub fn destinations(&self) -> &[MobileMutationDestination] {
|
||||
&self.destinations
|
||||
}
|
||||
|
||||
pub fn has_open_editor(&self) -> bool {
|
||||
self.has_open_editor
|
||||
}
|
||||
|
||||
pub fn has_dirty_editor(&self) -> bool {
|
||||
self.has_dirty_editor
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileMutationRequest {
|
||||
pub action: MobileMutationAction,
|
||||
pub source: String,
|
||||
pub revision: String,
|
||||
pub destination: Option<String>,
|
||||
pub confirmed: bool,
|
||||
pub overwrite: bool,
|
||||
pub discard_editor: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileMutationOutcome {
|
||||
action: MobileMutationAction,
|
||||
source: String,
|
||||
destination: Option<String>,
|
||||
title: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl MobileMutationOutcome {
|
||||
pub fn action(&self) -> MobileMutationAction {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn source(&self) -> &str {
|
||||
&self.source
|
||||
}
|
||||
|
||||
pub fn destination(&self) -> Option<&str> {
|
||||
self.destination.as_deref()
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileMutationService<'a> {
|
||||
repository: &'a Repository,
|
||||
}
|
||||
|
||||
impl<'a> MobileMutationService<'a> {
|
||||
pub fn new(repository: &'a Repository) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&self,
|
||||
source: &str,
|
||||
action: MobileMutationAction,
|
||||
has_open_editor: bool,
|
||||
has_dirty_editor: bool,
|
||||
) -> Result<MobileMutationPlan, MobileMutationError> {
|
||||
let source = EntryPath::parse(source)?;
|
||||
if hidden_path(source.as_path()) {
|
||||
return Err(MobileMutationError::InvalidSource {
|
||||
path: source.to_string(),
|
||||
});
|
||||
}
|
||||
let ciphertext = self.repository.read_entry(&source)?;
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let source_title = source
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("a string entry path has a UTF-8 file name")
|
||||
.to_owned();
|
||||
let destinations = if action == MobileMutationAction::Delete {
|
||||
Vec::new()
|
||||
} else {
|
||||
snapshot
|
||||
.directories()
|
||||
.filter(|directory| !hidden_path(directory.path().as_path()))
|
||||
.filter_map(|directory| {
|
||||
let destination = EntryPath::parse(
|
||||
directory
|
||||
.path()
|
||||
.as_path()
|
||||
.join(source.as_path().file_name()?),
|
||||
)
|
||||
.ok()?;
|
||||
(destination != source).then(|| MobileMutationDestination {
|
||||
path: directory_path(directory.path()),
|
||||
title: directory_title(directory.path()),
|
||||
detail: directory_detail(directory.path()),
|
||||
requires_overwrite: snapshot
|
||||
.entries()
|
||||
.any(|entry| entry.path() == &destination),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Ok(MobileMutationPlan {
|
||||
action,
|
||||
source: source.to_string(),
|
||||
source_title,
|
||||
revision: revision(ciphertext.as_bytes()),
|
||||
destinations,
|
||||
has_open_editor,
|
||||
has_dirty_editor,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn perform(
|
||||
&self,
|
||||
request: &MobileMutationRequest,
|
||||
has_open_editor: bool,
|
||||
keys: &KeyStore,
|
||||
provider: &mut impl SecretProvider,
|
||||
committer: &mut impl TreeCommitter,
|
||||
) -> Result<MobileMutationOutcome, MobileMutationError> {
|
||||
let destination = self.preflight(request, has_open_editor)?;
|
||||
let mutator = TreeMutator::new(self.repository, keys);
|
||||
let overwrite = if request.overwrite {
|
||||
OverwriteDecision::Allow
|
||||
} else {
|
||||
OverwriteDecision::Decline
|
||||
};
|
||||
let outcome = match request.action {
|
||||
MobileMutationAction::Delete => mutator.remove(
|
||||
&RemoveRequest {
|
||||
entry: request.source.clone(),
|
||||
recursive: false,
|
||||
force: false,
|
||||
},
|
||||
OverwriteDecision::Allow,
|
||||
committer,
|
||||
)?,
|
||||
MobileMutationAction::Move => mutator.move_tree(
|
||||
&MoveRequest {
|
||||
source: request.source.clone(),
|
||||
destination: destination.expect("move destination was validated"),
|
||||
force: request.overwrite,
|
||||
},
|
||||
overwrite,
|
||||
None,
|
||||
provider,
|
||||
committer,
|
||||
)?,
|
||||
MobileMutationAction::Copy => mutator.copy(
|
||||
&CopyRequest {
|
||||
source: request.source.clone(),
|
||||
destination: destination.expect("copy destination was validated"),
|
||||
force: request.overwrite,
|
||||
},
|
||||
overwrite,
|
||||
None,
|
||||
provider,
|
||||
committer,
|
||||
)?,
|
||||
};
|
||||
let destination = outcome
|
||||
.selection()
|
||||
.map(|selection| selection.display_path());
|
||||
let (title, detail) = match request.action {
|
||||
MobileMutationAction::Move => (
|
||||
"Password Moved",
|
||||
format!(
|
||||
"Moved {} to {}.",
|
||||
request.source,
|
||||
destination.as_deref().unwrap_or("the selected folder")
|
||||
),
|
||||
),
|
||||
MobileMutationAction::Copy => (
|
||||
"Password Copied",
|
||||
format!(
|
||||
"Copied {} to {}.",
|
||||
request.source,
|
||||
destination.as_deref().unwrap_or("the selected folder")
|
||||
),
|
||||
),
|
||||
MobileMutationAction::Delete => {
|
||||
("Password Deleted", format!("Deleted {}.", request.source))
|
||||
}
|
||||
};
|
||||
Ok(MobileMutationOutcome {
|
||||
action: request.action,
|
||||
source: request.source.clone(),
|
||||
destination,
|
||||
title: title.to_owned(),
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
fn preflight(
|
||||
&self,
|
||||
request: &MobileMutationRequest,
|
||||
has_open_editor: bool,
|
||||
) -> Result<Option<String>, MobileMutationError> {
|
||||
let source = EntryPath::parse(&request.source)?;
|
||||
if hidden_path(source.as_path()) {
|
||||
return Err(MobileMutationError::InvalidSource {
|
||||
path: source.to_string(),
|
||||
});
|
||||
}
|
||||
let current = self.repository.read_entry(&source)?;
|
||||
if revision(current.as_bytes()) != request.revision {
|
||||
return Err(MobileMutationError::StaleEntry {
|
||||
path: source.to_string(),
|
||||
});
|
||||
}
|
||||
if has_open_editor && !request.discard_editor {
|
||||
return Err(MobileMutationError::EditorOpen {
|
||||
path: source.to_string(),
|
||||
});
|
||||
}
|
||||
if request.action == MobileMutationAction::Delete {
|
||||
if !request.confirmed {
|
||||
return Err(MobileMutationError::ConfirmationRequired {
|
||||
path: source.to_string(),
|
||||
});
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let destination = request
|
||||
.destination
|
||||
.as_deref()
|
||||
.ok_or(MobileMutationError::DestinationRequired)?;
|
||||
let destination = DirectoryPath::parse(destination)?;
|
||||
if hidden_path(destination.as_path())
|
||||
|| !self
|
||||
.repository
|
||||
.snapshot()?
|
||||
.directories()
|
||||
.any(|directory| directory.path() == &destination)
|
||||
{
|
||||
return Err(MobileMutationError::InvalidDestination {
|
||||
path: directory_path(&destination),
|
||||
});
|
||||
}
|
||||
let destination_entry = EntryPath::parse(
|
||||
destination
|
||||
.as_path()
|
||||
.join(source.as_path().file_name().expect("validated source name")),
|
||||
)?;
|
||||
if destination_entry == source {
|
||||
return Err(MobileMutationError::SameDestination);
|
||||
}
|
||||
let destination_exists = match self.repository.read_entry(&destination_entry) {
|
||||
Ok(_) => true,
|
||||
Err(RepositoryError::NotFound { .. }) => false,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if destination_exists && !request.overwrite {
|
||||
return Err(MobileMutationError::OverwriteRequired {
|
||||
path: destination_entry.to_string(),
|
||||
});
|
||||
}
|
||||
let destination = directory_path(&destination);
|
||||
Ok(Some(if destination.is_empty() {
|
||||
destination
|
||||
} else {
|
||||
format!("{destination}/")
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(ciphertext: &[u8]) -> String {
|
||||
HEXLOWER.encode(&Sha256::digest(ciphertext))
|
||||
}
|
||||
|
||||
fn directory_path(path: &DirectoryPath) -> String {
|
||||
path.as_path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn directory_title(path: &DirectoryPath) -> String {
|
||||
path.as_path()
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Password Store")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn directory_detail(path: &DirectoryPath) -> String {
|
||||
if path.as_path().as_os_str().is_empty() {
|
||||
"Store root".to_owned()
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MobileMutationError {
|
||||
Repository(RepositoryError),
|
||||
Mutation(MutationError),
|
||||
InvalidSource { path: String },
|
||||
StaleEntry { path: String },
|
||||
EditorOpen { path: String },
|
||||
ConfirmationRequired { path: String },
|
||||
DestinationRequired,
|
||||
InvalidDestination { path: String },
|
||||
SameDestination,
|
||||
OverwriteRequired { path: String },
|
||||
}
|
||||
|
||||
impl MobileMutationError {
|
||||
pub fn is_conflict(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::StaleEntry { .. }
|
||||
| Self::EditorOpen { .. }
|
||||
| Self::OverwriteRequired { .. }
|
||||
| Self::Mutation(MutationError::TreeChanged { .. } | MutationError::Commit(_))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::StaleEntry { .. } => "Password Entry Changed",
|
||||
Self::EditorOpen { .. } => "Password Editor Is Open",
|
||||
Self::ConfirmationRequired { .. } => "Delete Confirmation Required",
|
||||
Self::DestinationRequired => "Destination Required",
|
||||
Self::InvalidDestination { .. } | Self::SameDestination => "Destination Is Invalid",
|
||||
Self::OverwriteRequired { .. } => "Password Already Exists",
|
||||
Self::InvalidSource { .. } => "Password Entry Is Unavailable",
|
||||
Self::Repository(_) | Self::Mutation(_) => "Password Action Failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileMutationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Repository(error) => error.fmt(formatter),
|
||||
Self::Mutation(error) => error.fmt(formatter),
|
||||
Self::InvalidSource { path } => {
|
||||
write!(formatter, "the selected entry is not mutable: {path}")
|
||||
}
|
||||
Self::StaleEntry { path } => write!(
|
||||
formatter,
|
||||
"{path} changed after the action began; refresh and try again"
|
||||
),
|
||||
Self::EditorOpen { path } => write!(
|
||||
formatter,
|
||||
"close or explicitly discard the open editor for {path}"
|
||||
),
|
||||
Self::ConfirmationRequired { path } => {
|
||||
write!(formatter, "confirm deletion of {path}")
|
||||
}
|
||||
Self::DestinationRequired => formatter.write_str("select a password folder"),
|
||||
Self::InvalidDestination { path } => {
|
||||
write!(
|
||||
formatter,
|
||||
"the selected password folder is unavailable: {path}"
|
||||
)
|
||||
}
|
||||
Self::SameDestination => {
|
||||
formatter.write_str("the source is already in the selected folder")
|
||||
}
|
||||
Self::OverwriteRequired { path } => {
|
||||
write!(
|
||||
formatter,
|
||||
"confirm replacement of the existing entry at {path}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobileMutationError {}
|
||||
|
||||
impl From<RepositoryError> for MobileMutationError {
|
||||
fn from(error: RepositoryError) -> Self {
|
||||
Self::Repository(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MutationError> for MobileMutationError {
|
||||
fn from(error: MutationError) -> Self {
|
||||
Self::Mutation(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::{
|
||||
MobileMutationAction, MobileMutationError, MobileMutationRequest, MobileMutationService,
|
||||
};
|
||||
use crate::repository::Repository;
|
||||
|
||||
#[test]
|
||||
fn plans_destinations_collisions_hidden_paths_and_stale_revisions()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let temporary = tempdir()?;
|
||||
fs::create_dir_all(temporary.path().join("Personal"))?;
|
||||
fs::create_dir_all(temporary.path().join("Work"))?;
|
||||
fs::create_dir_all(temporary.path().join(".extensions"))?;
|
||||
fs::write(temporary.path().join("Personal/bank.gpg"), b"source")?;
|
||||
fs::write(temporary.path().join("Work/bank.gpg"), b"collision")?;
|
||||
let repository = Repository::open(temporary.path())?;
|
||||
let service = MobileMutationService::new(&repository);
|
||||
|
||||
let plan = service.prepare("Personal/bank", MobileMutationAction::Move, true, true)?;
|
||||
assert!(plan.has_open_editor());
|
||||
assert!(plan.has_dirty_editor());
|
||||
assert!(!plan.revision().is_empty());
|
||||
assert!(plan.destinations().iter().all(|destination| {
|
||||
destination.path() != "Personal" && !destination.path().starts_with(".extensions")
|
||||
}));
|
||||
assert!(
|
||||
plan.destinations()
|
||||
.iter()
|
||||
.any(|destination| destination.path() == "" && !destination.requires_overwrite())
|
||||
);
|
||||
assert!(
|
||||
plan.destinations()
|
||||
.iter()
|
||||
.any(|destination| destination.path() == "Work" && destination.requires_overwrite())
|
||||
);
|
||||
|
||||
fs::write(temporary.path().join("Personal/bank.gpg"), b"changed")?;
|
||||
let changed = service.prepare("Personal/bank", MobileMutationAction::Move, false, false)?;
|
||||
assert_ne!(plan.revision(), changed.revision());
|
||||
|
||||
let stale = MobileMutationRequest {
|
||||
action: MobileMutationAction::Move,
|
||||
source: plan.source().to_owned(),
|
||||
revision: plan.revision().to_owned(),
|
||||
destination: Some("Work".to_owned()),
|
||||
confirmed: false,
|
||||
overwrite: true,
|
||||
discard_editor: true,
|
||||
};
|
||||
assert!(matches!(
|
||||
service.preflight(&stale, false),
|
||||
Err(MobileMutationError::StaleEntry { .. })
|
||||
));
|
||||
|
||||
let delete = MobileMutationRequest {
|
||||
action: MobileMutationAction::Delete,
|
||||
source: changed.source().to_owned(),
|
||||
revision: changed.revision().to_owned(),
|
||||
destination: None,
|
||||
confirmed: false,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
};
|
||||
assert!(matches!(
|
||||
service.preflight(&delete, false),
|
||||
Err(MobileMutationError::ConfirmationRequired { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
service.preflight(
|
||||
&MobileMutationRequest {
|
||||
confirmed: true,
|
||||
..delete.clone()
|
||||
},
|
||||
true
|
||||
),
|
||||
Err(MobileMutationError::EditorOpen { .. })
|
||||
));
|
||||
|
||||
let collision = MobileMutationRequest {
|
||||
action: MobileMutationAction::Copy,
|
||||
source: changed.source().to_owned(),
|
||||
revision: changed.revision().to_owned(),
|
||||
destination: Some("Work".to_owned()),
|
||||
confirmed: false,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
};
|
||||
assert!(matches!(
|
||||
service.preflight(&collision, false),
|
||||
Err(MobileMutationError::OverwriteRequired { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user