//! 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, 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, pub confirmed: bool, pub overwrite: bool, pub discard_editor: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileMutationOutcome { action: MobileMutationAction, source: String, destination: Option, 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 { 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 { 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, 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 for MobileMutationError { fn from(error: RepositoryError) -> Self { Self::Repository(error) } } impl From 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> { 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(()) } }