568 lines
18 KiB
Rust
568 lines
18 KiB
Rust
//! 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(())
|
|
}
|
|
}
|