Implement confirmed Android tree mutation workflows (#108)
This commit is contained in:
1 parent
6a864a8d7f
commit
316a875d63
22 files changed
+2170
-220
No files matched your search
@@ -253,6 +253,12 @@ pub(crate) struct SealedEntryDocument {
|
||||
}
|
||||
|
||||
impl SealedEntryDocument {
|
||||
pub(crate) fn path(&self) -> &EntryPath {
|
||||
&self.path
|
||||
}
|
||||
pub(crate) fn is_modified(&self) -> bool {
|
||||
self.modified
|
||||
}
|
||||
pub(crate) fn probe(&self) -> &EncryptedEntry {
|
||||
&self.ciphertext
|
||||
}
|
||||
|
||||
@@ -334,6 +334,11 @@ struct EditorCheckpoint {
|
||||
inputs: Option<SealedEditorInputs>,
|
||||
}
|
||||
|
||||
pub struct MobileNavigationMutationOutcome {
|
||||
pub result: MobileMutationOutcome,
|
||||
pub navigation: crate::mobile_passwords::navigation::PasswordNavigation,
|
||||
}
|
||||
|
||||
/// Only released after the checkpoint's encryption key has an active authentication lease.
|
||||
pub struct MobileEntryEditorRecovery {
|
||||
pub session: MobileEntryEditorSession,
|
||||
@@ -1240,6 +1245,127 @@ impl MobileAuthentication {
|
||||
result
|
||||
}
|
||||
|
||||
pub fn prepare_navigation_mutation(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: &str,
|
||||
action: MobileMutationAction,
|
||||
) -> Result<MobileMutationPlan, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let object =
|
||||
crate::mobile_passwords::navigation::checked_row(&self.repository, id, revision)
|
||||
.map_err(|error| entry_detail(error.title(), error.detail()))?;
|
||||
let source = match object {
|
||||
crate::read::TreeNodeId::Entry(path) => path.to_string(),
|
||||
crate::read::TreeNodeId::Directory(path) => format!("{path}/"),
|
||||
};
|
||||
let plan = self.prepare_entry_mutation(&source, action)?;
|
||||
crate::mobile_passwords::navigation::checked_row(&self.repository, id, revision)
|
||||
.map_err(|error| entry_detail(error.title(), error.detail()))?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub fn unlock_mutation(
|
||||
&self,
|
||||
source: &str,
|
||||
revision: &str,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.check_mutation_revision(revision)?;
|
||||
let snapshot = self.repository.snapshot().map_err(entry_error)?;
|
||||
let probe = match snapshot.resolve(source).map_err(entry_error)? {
|
||||
crate::repository::ResolvedObject::Entry(record) => self
|
||||
.repository
|
||||
.read_entry(record.path())
|
||||
.map_err(entry_error)?,
|
||||
crate::repository::ResolvedObject::Directory(record) => {
|
||||
let path = EntryPath::parse(
|
||||
record
|
||||
.path()
|
||||
.as_path()
|
||||
.join("IronStorage-mutation-authentication"),
|
||||
)
|
||||
.map_err(entry_error)?;
|
||||
let recipients = RecipientPolicyManager::new(&self.repository, &self.keys)
|
||||
.resolve_for_entry(&path, None)
|
||||
.map_err(|error| entry_detail("Recipients Are Unavailable", error))?;
|
||||
self.keys
|
||||
.encrypt(
|
||||
SecretBytes::new(b"IronStorage mobile mutation authentication".to_vec()),
|
||||
recipients.recipients(),
|
||||
)
|
||||
.map_err(key_error)?
|
||||
}
|
||||
};
|
||||
self.unlock_ciphertext(&probe, passphrase)?;
|
||||
self.check_mutation_revision(revision)?;
|
||||
self.state()
|
||||
}
|
||||
|
||||
fn check_mutation_revision(&self, revision: &str) -> Result<(), MobileAuthenticationError> {
|
||||
if self.repository.mutation_revision().map_err(entry_error)? != revision {
|
||||
self.manual_lock()?;
|
||||
return Err(MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Conflict,
|
||||
"Password Tree Changed",
|
||||
"Refresh and prepare the action again before confirming.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn perform_navigation_mutation(
|
||||
&self,
|
||||
request: MobileMutationRequest,
|
||||
return_path: &str,
|
||||
) -> Result<MobileNavigationMutationOutcome, MobileAuthenticationError> {
|
||||
let result = self.perform_entry_mutation(request)?;
|
||||
let navigation = match result.selection() {
|
||||
Some(crate::read::TreeNodeId::Directory(path)) => {
|
||||
crate::mobile_passwords::navigation::navigate_repository(
|
||||
&self.repository,
|
||||
&path.to_string(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
Some(crate::read::TreeNodeId::Entry(path)) => {
|
||||
let mut page = crate::mobile_passwords::navigation::navigate_repository(
|
||||
&self.repository,
|
||||
&path.parent_directory().to_string(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| entry_detail(error.title(), error.detail()))?;
|
||||
let id = format!("entry:{path}");
|
||||
page.selected = page.rows.iter().find(|row| row.row.id() == id).cloned();
|
||||
Ok(page)
|
||||
}
|
||||
None => crate::mobile_passwords::navigation::navigate_repository(
|
||||
&self.repository,
|
||||
return_path,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
),
|
||||
}
|
||||
.map_err(|error| {
|
||||
entry_detail(
|
||||
"Password Action Completed",
|
||||
format!(
|
||||
"{} {} Refresh the tree: {}",
|
||||
result.title(),
|
||||
result.detail(),
|
||||
error.detail()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(MobileNavigationMutationOutcome { result, navigation })
|
||||
}
|
||||
|
||||
pub fn prepare_presented_entry_mutation(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -1268,6 +1394,7 @@ impl MobileAuthentication {
|
||||
path: &str,
|
||||
action: MobileMutationAction,
|
||||
) -> Result<MobileMutationPlan, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let (has_open_editor, has_dirty_editor) = self.editor_state(path)?;
|
||||
MobileMutationService::new(&self.repository)
|
||||
.prepare(path, action, has_open_editor, has_dirty_editor)
|
||||
@@ -1302,23 +1429,53 @@ impl MobileAuthentication {
|
||||
AutomaticTreeCommitter::for_source(&self.repository, &request.source, identity)
|
||||
.map_err(git_mutation_error)?;
|
||||
let has_open_editor = self.editor_state(&request.source)?.0;
|
||||
let checkpoint_ids = if request.discard_editor {
|
||||
self.status()?
|
||||
.editor_checkpoints
|
||||
.iter()
|
||||
.filter(|(_, checkpoint)| {
|
||||
mutation_contains(&request.source, checkpoint.document.path())
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
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(
|
||||
let result = MobileMutationService::new(&self.repository).perform_guarded(
|
||||
&request,
|
||||
has_open_editor && editors.is_empty(),
|
||||
has_open_editor && !request.discard_editor,
|
||||
&self.keys,
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
&|| {
|
||||
self.ensure_active()
|
||||
.map(|_| ())
|
||||
.map_err(|_| crate::mutation::MutationError::Cancelled)
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Ok(mut outcome) => {
|
||||
let mut discarded = checkpoint_ids;
|
||||
discarded.extend(editors.iter().map(|(id, _)| *id));
|
||||
discarded.sort_unstable();
|
||||
discarded.dedup();
|
||||
let mut status = self.status()?;
|
||||
for id in &discarded {
|
||||
status.editor_checkpoints.remove(id);
|
||||
}
|
||||
outcome.set_discarded_editors(discarded);
|
||||
Ok(outcome)
|
||||
}
|
||||
Err(error) => {
|
||||
self.restore_entry_editors(editors)?;
|
||||
if self.ensure_active().is_ok() {
|
||||
self.restore_entry_editors(editors)?;
|
||||
}
|
||||
Err(mutation_error(error))
|
||||
}
|
||||
}
|
||||
@@ -1605,6 +1762,18 @@ impl MobileAuthentication {
|
||||
document: &mut EntryDocument,
|
||||
field_id: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.reserve_entry_mutation()?;
|
||||
let result = self.save_reserved_entry_field(document, field_id, value);
|
||||
self.release_entry_mutation();
|
||||
result
|
||||
}
|
||||
|
||||
fn save_reserved_entry_field(
|
||||
&self,
|
||||
document: &mut EntryDocument,
|
||||
field_id: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
document
|
||||
.replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes())
|
||||
@@ -1627,6 +1796,7 @@ impl MobileAuthentication {
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let document = self.open_active_document(path)?;
|
||||
self.store_editor(MobileEntryDraft::new(document, false))
|
||||
}
|
||||
@@ -1637,6 +1807,7 @@ impl MobileAuthentication {
|
||||
name: &str,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let directory = DirectoryPath::parse(directory).map_err(entry_error)?;
|
||||
let leaf = EntryPath::parse(name).map_err(entry_error)?;
|
||||
if leaf
|
||||
@@ -1737,6 +1908,7 @@ impl MobileAuthentication {
|
||||
editor: u64,
|
||||
fields: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
@@ -1751,6 +1923,7 @@ impl MobileAuthentication {
|
||||
name: Option<String>,
|
||||
value: String,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
@@ -1763,6 +1936,7 @@ impl MobileAuthentication {
|
||||
editor: u64,
|
||||
image: SecretBytes,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
@@ -1775,6 +1949,7 @@ impl MobileAuthentication {
|
||||
editor: u64,
|
||||
field: u64,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
@@ -1788,6 +1963,7 @@ impl MobileAuthentication {
|
||||
field: u64,
|
||||
index: usize,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
@@ -1801,6 +1977,7 @@ impl MobileAuthentication {
|
||||
length: Option<u32>,
|
||||
no_symbols: bool,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
@@ -1817,6 +1994,17 @@ impl MobileAuthentication {
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
self.ensure_repository_idle()?;
|
||||
self.reserve_entry_mutation()?;
|
||||
let result = self.save_reserved_entry_editor(editor, fields);
|
||||
self.release_entry_mutation();
|
||||
result
|
||||
}
|
||||
|
||||
fn save_reserved_entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
fields: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
let mut draft = self
|
||||
.status()?
|
||||
.editors
|
||||
@@ -2169,10 +2357,11 @@ impl MobileAuthentication {
|
||||
}
|
||||
|
||||
fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> {
|
||||
if self.status()?.repository_operation_active {
|
||||
let status = self.status()?;
|
||||
if status.repository_operation_active || status.mutation_active {
|
||||
Err(entry_detail(
|
||||
"Repository Action In Progress",
|
||||
"wait for the current commit, fetch, pull, or push action to finish",
|
||||
"wait for the current mutation, commit, fetch, pull, or push action to finish",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -2187,17 +2376,25 @@ impl MobileAuthentication {
|
||||
|
||||
fn editor_state(&self, path: &str) -> Result<(bool, bool), MobileAuthenticationError> {
|
||||
let status = self.status()?;
|
||||
let mut matching = status
|
||||
let active = 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()),
|
||||
))
|
||||
.filter(|draft| mutation_contains(path, draft.document().path()));
|
||||
let sealed = status
|
||||
.editor_checkpoints
|
||||
.values()
|
||||
.filter(|checkpoint| mutation_contains(path, checkpoint.document.path()));
|
||||
let mut open = false;
|
||||
let mut dirty = false;
|
||||
for document in active.map(|draft| draft.document()) {
|
||||
open = true;
|
||||
dirty |= document.is_modified();
|
||||
}
|
||||
for checkpoint in sealed {
|
||||
open = true;
|
||||
dirty |= checkpoint.document.is_modified() || checkpoint.inputs.is_some();
|
||||
}
|
||||
Ok((open, dirty))
|
||||
}
|
||||
|
||||
fn take_entry_editors(
|
||||
@@ -2208,7 +2405,7 @@ impl MobileAuthentication {
|
||||
let ids = status
|
||||
.editors
|
||||
.iter()
|
||||
.filter(|(_, draft)| draft.document().path().to_string() == path)
|
||||
.filter(|(_, draft)| mutation_contains(path, draft.document().path()))
|
||||
.map(|(id, _)| *id)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(ids
|
||||
@@ -2230,7 +2427,9 @@ impl MobileAuthentication {
|
||||
editor: u64,
|
||||
draft: MobileEntryDraft,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.status()?.editors.insert(editor, draft);
|
||||
if self.ensure_active().is_ok() {
|
||||
self.status()?.editors.insert(editor, draft);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2269,6 +2468,16 @@ impl MobileAuthentication {
|
||||
}
|
||||
}
|
||||
|
||||
fn mutation_contains(source: &str, entry: &EntryPath) -> bool {
|
||||
if source.ends_with('/') {
|
||||
entry
|
||||
.as_path()
|
||||
.starts_with(Path::new(source.trim_end_matches('/')))
|
||||
} else {
|
||||
entry.to_string() == source
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_operation_conflict(
|
||||
operation: MobileRepositoryOperation,
|
||||
busy: bool,
|
||||
@@ -2895,6 +3104,161 @@ mod tests {
|
||||
assert!(!authentication.state().unwrap().unlocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_mutations_keep_dirty_checkpoints_on_failure_and_discard_them_only_after_success()
|
||||
{
|
||||
use crate::{
|
||||
mobile_mutation::{MobileMutationAction, MobileMutationRequest},
|
||||
repository::{EntryPath, SecretBytes},
|
||||
};
|
||||
let (_root, auth, _) = otp_fixture("otpauth://totp/Fixture:alice?secret=JBSWY3DPEHPK3PXP");
|
||||
let navigation = crate::mobile_passwords::navigation::navigate_repository(
|
||||
&auth.repository,
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let row = navigation
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.row.path() == "otp")
|
||||
.unwrap();
|
||||
let plan = auth
|
||||
.prepare_navigation_mutation(row.row.id(), &row.revision, MobileMutationAction::Delete)
|
||||
.unwrap();
|
||||
assert!(!auth.state().unwrap().unlocked());
|
||||
auth.unlock_mutation(
|
||||
plan.source(),
|
||||
plan.revision(),
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
)
|
||||
.unwrap();
|
||||
let editor = auth.begin_entry_editor("otp").unwrap();
|
||||
let first = editor.page().fields()[0].id();
|
||||
auth.update_entry_editor(
|
||||
editor.id(),
|
||||
editor
|
||||
.page()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| {
|
||||
crate::mobile_entry::MobileEntryEditorInput::new(
|
||||
field.id(),
|
||||
field.name().map(str::to_owned),
|
||||
if field.id() == first {
|
||||
Some("unsaved value".to_owned())
|
||||
} else {
|
||||
field.value().map(str::to_owned)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap();
|
||||
auth.checkpoint_entry_editor(editor.id(), 1).unwrap();
|
||||
let plan = auth
|
||||
.prepare_navigation_mutation(row.row.id(), &row.revision, MobileMutationAction::Delete)
|
||||
.unwrap();
|
||||
assert!(plan.has_open_editor());
|
||||
assert!(plan.has_dirty_editor());
|
||||
let mut request = MobileMutationRequest {
|
||||
action: MobileMutationAction::Delete,
|
||||
source: plan.source().to_owned(),
|
||||
revision: plan.revision().to_owned(),
|
||||
destination: None,
|
||||
confirmed: true,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
};
|
||||
assert!(
|
||||
auth.perform_navigation_mutation(request.clone(), "")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
auth.entry_editor(editor.id()).unwrap().fields()[0].value(),
|
||||
Some("unsaved value")
|
||||
);
|
||||
let path = EntryPath::parse("otp").unwrap();
|
||||
let original = auth.repository.read_entry(&path).unwrap();
|
||||
let lock = auth.repository.root_path().join(".git/index.lock");
|
||||
std::fs::write(&lock, b"locked").unwrap();
|
||||
request.discard_editor = true;
|
||||
assert!(
|
||||
auth.perform_navigation_mutation(request.clone(), "")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(auth.repository.read_entry(&path).unwrap(), original);
|
||||
assert_eq!(
|
||||
auth.entry_editor(editor.id()).unwrap().fields()[0].value(),
|
||||
Some("unsaved value")
|
||||
);
|
||||
assert!(
|
||||
auth.status()
|
||||
.unwrap()
|
||||
.editor_checkpoints
|
||||
.contains_key(&editor.id())
|
||||
);
|
||||
std::fs::remove_file(lock).unwrap();
|
||||
assert!(
|
||||
auth.prepare_navigation_mutation(
|
||||
row.row.id(),
|
||||
&row.revision,
|
||||
MobileMutationAction::Delete
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let refreshed = crate::mobile_passwords::navigation::navigate_repository(
|
||||
&auth.repository,
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let row = refreshed
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.row.path() == "otp")
|
||||
.unwrap();
|
||||
auth.manual_lock().unwrap();
|
||||
assert_eq!(auth.editor_state("otp").unwrap(), (true, true));
|
||||
let plan = auth
|
||||
.prepare_navigation_mutation(row.row.id(), &row.revision, MobileMutationAction::Delete)
|
||||
.unwrap();
|
||||
request.revision = plan.revision().to_owned();
|
||||
assert!(
|
||||
auth.perform_navigation_mutation(request.clone(), "")
|
||||
.is_err()
|
||||
);
|
||||
auth.unlock_mutation(
|
||||
plan.source(),
|
||||
plan.revision(),
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = auth.perform_navigation_mutation(request, "").unwrap();
|
||||
assert_eq!(outcome.result.discarded_editors(), &[editor.id()]);
|
||||
assert!(auth.repository.read_entry(&path).is_err());
|
||||
assert!(
|
||||
outcome
|
||||
.navigation
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.row.path() != "otp")
|
||||
);
|
||||
assert!(auth.resume_entry_editor(editor.id(), None).is_err());
|
||||
assert!(
|
||||
auth.prepare_navigation_mutation(
|
||||
row.row.id(),
|
||||
&row.revision,
|
||||
MobileMutationAction::Delete
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() {
|
||||
use crate::authentication::SystemAuthenticationClock;
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
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},
|
||||
repository::{
|
||||
DirectoryPath, EntryPath, Repository, RepositoryError, RepositorySnapshot, ResolvedObject,
|
||||
},
|
||||
write::OverwriteDecision,
|
||||
};
|
||||
|
||||
@@ -27,9 +26,22 @@ pub struct MobileMutationDestination {
|
||||
title: String,
|
||||
detail: String,
|
||||
requires_overwrite: bool,
|
||||
depth: u32,
|
||||
unavailable: Option<String>,
|
||||
collision_targets: Vec<String>,
|
||||
}
|
||||
|
||||
impl MobileMutationDestination {
|
||||
pub fn collision_targets(&self) -> &[String] {
|
||||
&self.collision_targets
|
||||
}
|
||||
pub fn depth(&self) -> u32 {
|
||||
self.depth
|
||||
}
|
||||
pub fn unavailable(&self) -> Option<&str> {
|
||||
self.unavailable.as_deref()
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
@@ -56,9 +68,18 @@ pub struct MobileMutationPlan {
|
||||
destinations: Vec<MobileMutationDestination>,
|
||||
has_open_editor: bool,
|
||||
has_dirty_editor: bool,
|
||||
directory: bool,
|
||||
entries: u32,
|
||||
}
|
||||
|
||||
impl MobileMutationPlan {
|
||||
pub fn is_directory(&self) -> bool {
|
||||
self.directory
|
||||
}
|
||||
pub fn entries(&self) -> u32 {
|
||||
self.entries
|
||||
}
|
||||
|
||||
pub fn action(&self) -> MobileMutationAction {
|
||||
self.action
|
||||
}
|
||||
@@ -106,9 +127,20 @@ pub struct MobileMutationOutcome {
|
||||
destination: Option<String>,
|
||||
title: String,
|
||||
detail: String,
|
||||
selection: Option<crate::read::TreeNodeId>,
|
||||
discarded_editors: Vec<u64>,
|
||||
}
|
||||
|
||||
impl MobileMutationOutcome {
|
||||
pub fn selection(&self) -> Option<&crate::read::TreeNodeId> {
|
||||
self.selection.as_ref()
|
||||
}
|
||||
pub fn discarded_editors(&self) -> &[u64] {
|
||||
&self.discarded_editors
|
||||
}
|
||||
pub(crate) fn set_discarded_editors(&mut self, editors: Vec<u64>) {
|
||||
self.discarded_editors = editors;
|
||||
}
|
||||
pub fn action(&self) -> MobileMutationAction {
|
||||
self.action
|
||||
}
|
||||
@@ -146,53 +178,69 @@ impl<'a> MobileMutationService<'a> {
|
||||
has_open_editor: bool,
|
||||
has_dirty_editor: bool,
|
||||
) -> Result<MobileMutationPlan, MobileMutationError> {
|
||||
let source = EntryPath::parse(source)?;
|
||||
if hidden_path(source.as_path()) {
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let (path, directory) = source_path(&snapshot, source)?;
|
||||
if hidden_path(path.as_path()) || path.as_path().as_os_str().is_empty() {
|
||||
return Err(MobileMutationError::InvalidSource {
|
||||
path: source.to_string(),
|
||||
path: source.to_owned(),
|
||||
});
|
||||
}
|
||||
let ciphertext = self.repository.read_entry(&source)?;
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let source_title = source
|
||||
let source_title = path
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("a string entry path has a UTF-8 file name")
|
||||
.ok_or_else(|| MobileMutationError::InvalidSource {
|
||||
path: source.to_owned(),
|
||||
})?
|
||||
.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),
|
||||
.filter(|record| !hidden_path(record.path().as_path()))
|
||||
.filter_map(|record| {
|
||||
let target = record.path().as_path().join(path.as_path().file_name()?);
|
||||
if target == path.as_path() || directory && target.starts_with(path.as_path()) {
|
||||
return None;
|
||||
}
|
||||
let (collision_targets, unavailable) =
|
||||
collision(&snapshot, &path, directory, &target);
|
||||
Some(MobileMutationDestination {
|
||||
path: directory_path(record.path()),
|
||||
title: directory_title(record.path()),
|
||||
detail: directory_detail(record.path()),
|
||||
requires_overwrite: !collision_targets.is_empty(),
|
||||
collision_targets,
|
||||
depth: u32::try_from(record.path().as_path().components().count())
|
||||
.unwrap_or(u32::MAX),
|
||||
unavailable,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let entries = if directory {
|
||||
snapshot
|
||||
.entries()
|
||||
.filter(|entry| entry.path().as_path().starts_with(path.as_path()))
|
||||
.count()
|
||||
} else {
|
||||
1
|
||||
};
|
||||
Ok(MobileMutationPlan {
|
||||
action,
|
||||
source: source.to_string(),
|
||||
source: if directory {
|
||||
format!("{path}/")
|
||||
} else {
|
||||
path.to_string()
|
||||
},
|
||||
source_title,
|
||||
revision: revision(ciphertext.as_bytes()),
|
||||
revision: self.repository.mutation_revision()?,
|
||||
destinations,
|
||||
has_open_editor,
|
||||
has_dirty_editor,
|
||||
directory,
|
||||
entries: u32::try_from(entries).unwrap_or(u32::MAX),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,9 +251,25 @@ impl<'a> MobileMutationService<'a> {
|
||||
keys: &KeyStore,
|
||||
provider: &mut impl SecretProvider,
|
||||
committer: &mut impl TreeCommitter,
|
||||
) -> Result<MobileMutationOutcome, MobileMutationError> {
|
||||
self.perform_guarded(request, has_open_editor, keys, provider, committer, &|| {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn perform_guarded(
|
||||
&self,
|
||||
request: &MobileMutationRequest,
|
||||
has_open_editor: bool,
|
||||
keys: &KeyStore,
|
||||
provider: &mut impl SecretProvider,
|
||||
committer: &mut impl TreeCommitter,
|
||||
validation: &(dyn Fn() -> Result<(), MutationError> + Sync),
|
||||
) -> Result<MobileMutationOutcome, MobileMutationError> {
|
||||
let destination = self.preflight(request, has_open_editor)?;
|
||||
let mutator = TreeMutator::new(self.repository, keys);
|
||||
let mutator = TreeMutator::new(self.repository, keys)
|
||||
.with_revision(&request.revision)
|
||||
.with_validation(validation);
|
||||
let overwrite = if request.overwrite {
|
||||
OverwriteDecision::Allow
|
||||
} else {
|
||||
@@ -215,7 +279,10 @@ impl<'a> MobileMutationService<'a> {
|
||||
MobileMutationAction::Delete => mutator.remove(
|
||||
&RemoveRequest {
|
||||
entry: request.source.clone(),
|
||||
recursive: false,
|
||||
recursive: matches!(
|
||||
self.repository.snapshot()?.resolve(&request.source)?,
|
||||
ResolvedObject::Directory(_)
|
||||
),
|
||||
force: false,
|
||||
},
|
||||
OverwriteDecision::Allow,
|
||||
@@ -244,6 +311,14 @@ impl<'a> MobileMutationService<'a> {
|
||||
committer,
|
||||
)?,
|
||||
};
|
||||
let selection = outcome.selection().map(|selection| match selection {
|
||||
crate::mutation::MutationSelection::Entry(path) => {
|
||||
crate::read::TreeNodeId::Entry(path.clone())
|
||||
}
|
||||
crate::mutation::MutationSelection::Directory(path) => {
|
||||
crate::read::TreeNodeId::Directory(path.clone())
|
||||
}
|
||||
});
|
||||
let destination = outcome
|
||||
.selection()
|
||||
.map(|selection| selection.display_path());
|
||||
@@ -274,6 +349,8 @@ impl<'a> MobileMutationService<'a> {
|
||||
destination,
|
||||
title: title.to_owned(),
|
||||
detail,
|
||||
selection,
|
||||
discarded_editors: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,14 +359,14 @@ impl<'a> MobileMutationService<'a> {
|
||||
request: &MobileMutationRequest,
|
||||
has_open_editor: bool,
|
||||
) -> Result<Option<String>, MobileMutationError> {
|
||||
let source = EntryPath::parse(&request.source)?;
|
||||
if hidden_path(source.as_path()) {
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let (source, directory) = source_path(&snapshot, &request.source)?;
|
||||
if hidden_path(source.as_path()) || source.as_path().as_os_str().is_empty() {
|
||||
return Err(MobileMutationError::InvalidSource {
|
||||
path: source.to_string(),
|
||||
});
|
||||
}
|
||||
let current = self.repository.read_entry(&source)?;
|
||||
if revision(current.as_bytes()) != request.revision {
|
||||
if self.repository.mutation_revision()? != request.revision {
|
||||
return Err(MobileMutationError::StaleEntry {
|
||||
path: source.to_string(),
|
||||
});
|
||||
@@ -328,19 +405,24 @@ impl<'a> MobileMutationService<'a> {
|
||||
.as_path()
|
||||
.join(source.as_path().file_name().expect("validated source name")),
|
||||
)?;
|
||||
if destination_entry == source {
|
||||
if destination_entry.as_path() == source.as_path() {
|
||||
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 {
|
||||
let (destination_exists, unavailable) =
|
||||
collision(&snapshot, &source, directory, destination_entry.as_path());
|
||||
if let Some(detail) = unavailable {
|
||||
return Err(MobileMutationError::DestinationUnavailable { detail });
|
||||
}
|
||||
if !destination_exists.is_empty() && !request.overwrite {
|
||||
return Err(MobileMutationError::OverwriteRequired {
|
||||
path: destination_entry.to_string(),
|
||||
});
|
||||
}
|
||||
if !request.confirmed {
|
||||
return Err(MobileMutationError::ConfirmationRequired {
|
||||
path: source.to_string(),
|
||||
});
|
||||
}
|
||||
let destination = directory_path(&destination);
|
||||
Ok(Some(if destination.is_empty() {
|
||||
destination
|
||||
@@ -350,8 +432,126 @@ impl<'a> MobileMutationService<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(ciphertext: &[u8]) -> String {
|
||||
HEXLOWER.encode(&Sha256::digest(ciphertext))
|
||||
fn source_path(
|
||||
snapshot: &RepositorySnapshot,
|
||||
source: &str,
|
||||
) -> Result<(DirectoryPath, bool), MobileMutationError> {
|
||||
Ok(match snapshot.resolve(source)? {
|
||||
ResolvedObject::Entry(record) => (DirectoryPath::parse(record.path().as_path())?, false),
|
||||
ResolvedObject::Directory(record) => (record.path().clone(), true),
|
||||
})
|
||||
}
|
||||
|
||||
fn collision(
|
||||
snapshot: &RepositorySnapshot,
|
||||
source: &DirectoryPath,
|
||||
directory: bool,
|
||||
target: &std::path::Path,
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
if directory && target.starts_with(source.as_path()) {
|
||||
return (
|
||||
Vec::new(),
|
||||
Some("A folder cannot be moved or copied into itself.".to_owned()),
|
||||
);
|
||||
}
|
||||
if snapshot
|
||||
.entries()
|
||||
.any(|entry| entry.path().as_path() == target)
|
||||
&& directory
|
||||
|| snapshot
|
||||
.directories()
|
||||
.any(|record| record.path().as_path() == target)
|
||||
&& !directory
|
||||
{
|
||||
return (
|
||||
Vec::new(),
|
||||
Some("An entry and a folder cannot replace each other.".to_owned()),
|
||||
);
|
||||
}
|
||||
if !directory {
|
||||
return (
|
||||
snapshot
|
||||
.entries()
|
||||
.filter(|entry| entry.path().as_path() == target)
|
||||
.map(|entry| entry.path().to_string())
|
||||
.collect(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
for policy in snapshot
|
||||
.recipient_policies()
|
||||
.filter(|policy| policy.directory().as_path().starts_with(source.as_path()))
|
||||
{
|
||||
let destination = target.join(
|
||||
policy
|
||||
.directory()
|
||||
.as_path()
|
||||
.strip_prefix(source.as_path())
|
||||
.expect("source prefix"),
|
||||
);
|
||||
if snapshot
|
||||
.recipient_policies()
|
||||
.any(|existing| existing.directory().as_path() == destination)
|
||||
{
|
||||
return (
|
||||
Vec::new(),
|
||||
Some(
|
||||
"The destination has a recipient policy that cannot be overwritten safely."
|
||||
.to_owned(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut overwrite = Vec::new();
|
||||
for entry in snapshot
|
||||
.entries()
|
||||
.filter(|entry| entry.path().as_path().starts_with(source.as_path()))
|
||||
{
|
||||
let destination = target.join(
|
||||
entry
|
||||
.path()
|
||||
.as_path()
|
||||
.strip_prefix(source.as_path())
|
||||
.expect("source prefix"),
|
||||
);
|
||||
if snapshot
|
||||
.directories()
|
||||
.any(|record| record.path().as_path() == destination)
|
||||
{
|
||||
return (
|
||||
Vec::new(),
|
||||
Some("A destination folder conflicts with an entry in the source.".to_owned()),
|
||||
);
|
||||
}
|
||||
if snapshot
|
||||
.entries()
|
||||
.any(|existing| existing.path().as_path() == destination)
|
||||
{
|
||||
overwrite.push(destination.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
for record in snapshot
|
||||
.directories()
|
||||
.filter(|record| record.path().as_path().starts_with(source.as_path()))
|
||||
{
|
||||
let destination = target.join(
|
||||
record
|
||||
.path()
|
||||
.as_path()
|
||||
.strip_prefix(source.as_path())
|
||||
.expect("source prefix"),
|
||||
);
|
||||
if snapshot
|
||||
.entries()
|
||||
.any(|entry| entry.path().as_path() == destination)
|
||||
{
|
||||
return (
|
||||
Vec::new(),
|
||||
Some("A destination entry conflicts with a folder in the source.".to_owned()),
|
||||
);
|
||||
}
|
||||
}
|
||||
(overwrite, None)
|
||||
}
|
||||
|
||||
fn directory_path(path: &DirectoryPath) -> String {
|
||||
@@ -384,6 +584,7 @@ pub enum MobileMutationError {
|
||||
ConfirmationRequired { path: String },
|
||||
DestinationRequired,
|
||||
InvalidDestination { path: String },
|
||||
DestinationUnavailable { detail: String },
|
||||
SameDestination,
|
||||
OverwriteRequired { path: String },
|
||||
}
|
||||
@@ -405,7 +606,9 @@ impl MobileMutationError {
|
||||
Self::EditorOpen { .. } => "Password Editor Is Open",
|
||||
Self::ConfirmationRequired { .. } => "Delete Confirmation Required",
|
||||
Self::DestinationRequired => "Destination Required",
|
||||
Self::InvalidDestination { .. } | Self::SameDestination => "Destination Is Invalid",
|
||||
Self::InvalidDestination { .. }
|
||||
| Self::DestinationUnavailable { .. }
|
||||
| Self::SameDestination => "Destination Is Invalid",
|
||||
Self::OverwriteRequired { .. } => "Password Already Exists",
|
||||
Self::InvalidSource { .. } => "Password Entry Is Unavailable",
|
||||
Self::Repository(_) | Self::Mutation(_) => "Password Action Failed",
|
||||
@@ -439,6 +642,7 @@ impl fmt::Display for MobileMutationError {
|
||||
"the selected password folder is unavailable: {path}"
|
||||
)
|
||||
}
|
||||
Self::DestinationUnavailable { detail } => formatter.write_str(detail),
|
||||
Self::SameDestination => {
|
||||
formatter.write_str("the source is already in the selected folder")
|
||||
}
|
||||
@@ -477,6 +681,70 @@ mod tests {
|
||||
};
|
||||
use crate::repository::Repository;
|
||||
|
||||
#[test]
|
||||
fn lease_revocation_before_apply_does_not_delete_a_confirmed_entry()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
use crate::{
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
mutation::{MutationError, TreeCommit, TreeCommitError, TreeCommitter},
|
||||
repository::{EntryPath, SecretBytes},
|
||||
};
|
||||
struct NoSecrets;
|
||||
impl SecretProvider for NoSecrets {
|
||||
fn secret_for(&mut self, _: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
Err(SecretProviderError::Missing)
|
||||
}
|
||||
}
|
||||
struct NoCommits;
|
||||
impl TreeCommitter for NoCommits {
|
||||
fn commit(&mut self, _: &TreeCommit) -> Result<(), TreeCommitError> {
|
||||
panic!("revoked mutation must not commit")
|
||||
}
|
||||
}
|
||||
let root = tempdir()?;
|
||||
fs::write(root.path().join("entry.gpg"), b"encrypted-entry")?;
|
||||
let repository = Repository::open(root.path())?;
|
||||
let keys = KeyStore::new();
|
||||
let service = MobileMutationService::new(&repository);
|
||||
let plan = service.prepare("entry", MobileMutationAction::Delete, false, false)?;
|
||||
let validations = std::sync::atomic::AtomicUsize::new(0);
|
||||
let result = service.perform_guarded(
|
||||
&MobileMutationRequest {
|
||||
action: MobileMutationAction::Delete,
|
||||
source: "entry".into(),
|
||||
revision: plan.revision().into(),
|
||||
destination: None,
|
||||
confirmed: true,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
},
|
||||
false,
|
||||
&keys,
|
||||
&mut NoSecrets,
|
||||
&mut NoCommits,
|
||||
&|| {
|
||||
let count = validations.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
if count == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MutationError::Cancelled)
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(MobileMutationError::Mutation(MutationError::Cancelled))
|
||||
));
|
||||
assert_eq!(validations.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||
assert_eq!(
|
||||
repository
|
||||
.read_entry(&EntryPath::parse("entry")?)?
|
||||
.as_bytes(),
|
||||
b"encrypted-entry"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_destinations_collisions_hidden_paths_and_stale_revisions()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -136,6 +136,37 @@ fn open_search_repository(
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
pub(crate) fn checked_row(
|
||||
repository: &Repository,
|
||||
id: &str,
|
||||
revision: &str,
|
||||
) -> Result<crate::read::TreeNodeId, MobilePasswordError> {
|
||||
let object = if let Some(path) = id.strip_prefix("entry:") {
|
||||
crate::read::TreeNodeId::Entry(
|
||||
EntryPath::parse(path).map_err(MobilePasswordError::repository)?,
|
||||
)
|
||||
} else if let Some(path) = id.strip_prefix("directory:") {
|
||||
let path = DirectoryPath::parse(path).map_err(MobilePasswordError::repository)?;
|
||||
if path.as_path().as_os_str().is_empty() {
|
||||
return Err(MobilePasswordError::invalid_path());
|
||||
}
|
||||
crate::read::TreeNodeId::Directory(path)
|
||||
} else {
|
||||
return Err(MobilePasswordError::invalid_path());
|
||||
};
|
||||
let current = repository
|
||||
.navigation_revision(object.path(), object.entry().is_some())
|
||||
.map_err(MobilePasswordError::repository)?;
|
||||
if HEXLOWER.encode(&Sha256::digest(current.as_bytes())) != revision {
|
||||
return Err(MobilePasswordError {
|
||||
kind: MobilePasswordErrorKind::InvalidPath,
|
||||
title: "Password Object Changed".to_owned(),
|
||||
detail: "Refresh the password tree before selecting an action.".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn unavailable() -> MobilePasswordError {
|
||||
MobilePasswordError {
|
||||
kind: MobilePasswordErrorKind::Configuration,
|
||||
|
||||
@@ -129,11 +129,45 @@ impl MutationSelection {
|
||||
pub struct TreeMutator<'a> {
|
||||
repository: &'a Repository,
|
||||
keys: &'a KeyStore,
|
||||
expected_revision: Option<&'a str>,
|
||||
validation: Option<&'a (dyn Fn() -> Result<(), MutationError> + Sync)>,
|
||||
}
|
||||
|
||||
impl<'a> TreeMutator<'a> {
|
||||
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
|
||||
Self { repository, keys }
|
||||
Self {
|
||||
repository,
|
||||
keys,
|
||||
expected_revision: None,
|
||||
validation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_revision(mut self, revision: &'a str) -> Self {
|
||||
self.expected_revision = Some(revision);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_validation(
|
||||
mut self,
|
||||
validation: &'a (dyn Fn() -> Result<(), MutationError> + Sync),
|
||||
) -> Self {
|
||||
self.validation = Some(validation);
|
||||
self
|
||||
}
|
||||
|
||||
fn check_revision(&self) -> Result<(), MutationError> {
|
||||
if let Some(expected) = self.expected_revision
|
||||
&& self.repository.mutation_revision()? != expected
|
||||
{
|
||||
return Err(MutationError::TreeChanged {
|
||||
path: std::path::PathBuf::new(),
|
||||
});
|
||||
}
|
||||
if let Some(validation) = self.validation {
|
||||
validation()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(
|
||||
@@ -142,6 +176,7 @@ impl<'a> TreeMutator<'a> {
|
||||
decision: OverwriteDecision,
|
||||
committer: &mut impl TreeCommitter,
|
||||
) -> Result<MutationOutcome, MutationError> {
|
||||
self.check_revision()?;
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let resolved = snapshot.resolve(&request.entry)?;
|
||||
if !request.force && decision == OverwriteDecision::Decline {
|
||||
@@ -175,6 +210,7 @@ impl<'a> TreeMutator<'a> {
|
||||
)
|
||||
}
|
||||
};
|
||||
self.check_revision()?;
|
||||
if let Err(operation) = self.remove_sources(&entries, &policies, &directories) {
|
||||
return Err(with_rollback(
|
||||
operation,
|
||||
@@ -265,6 +301,7 @@ impl<'a> TreeMutator<'a> {
|
||||
provider: &mut impl SecretProvider,
|
||||
committer: &mut impl TreeCommitter,
|
||||
) -> Result<MutationOutcome, MutationError> {
|
||||
self.check_revision()?;
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
let resolved = snapshot.resolve(source)?;
|
||||
let requested_destination = DirectoryPath::parse(destination)?;
|
||||
@@ -460,6 +497,7 @@ impl<'a> TreeMutator<'a> {
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
self.check_revision()?;
|
||||
let mut created_directories = Vec::new();
|
||||
let applied = (|| -> Result<(), MutationError> {
|
||||
for directory in &destination_directories {
|
||||
|
||||
@@ -391,6 +391,54 @@ impl Repository {
|
||||
Ok(EncryptedEntry(bytes))
|
||||
}
|
||||
|
||||
/// Bind confirmed mutations to ciphertext, policy, and filesystem object identities.
|
||||
pub(crate) fn mutation_revision(&self) -> Result<String, RepositoryError> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let snapshot = self.snapshot()?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut field = |bytes: &[u8]| {
|
||||
digest.update((bytes.len() as u64).to_le_bytes());
|
||||
digest.update(bytes);
|
||||
};
|
||||
for directory in snapshot.directories() {
|
||||
field(b"directory");
|
||||
field(directory.path().as_path().as_os_str().as_encoded_bytes());
|
||||
field(
|
||||
self.navigation_revision(directory.path().as_path(), false)?
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
for entry in snapshot.entries() {
|
||||
field(b"entry");
|
||||
field(entry.path().as_path().as_os_str().as_encoded_bytes());
|
||||
field(
|
||||
self.navigation_revision(entry.path().as_path(), true)?
|
||||
.as_bytes(),
|
||||
);
|
||||
field(self.read_entry(entry.path())?.as_bytes());
|
||||
}
|
||||
for policy in snapshot.recipient_policies() {
|
||||
field(b"policy");
|
||||
field(policy.directory().as_path().as_os_str().as_encoded_bytes());
|
||||
field(
|
||||
&self
|
||||
.read_policy_file(policy.directory(), false)?
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
field(
|
||||
&self
|
||||
.read_policy_file(policy.directory(), true)?
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
for auxiliary in snapshot.auxiliary_files() {
|
||||
field(b"auxiliary");
|
||||
field(auxiliary.path().as_os_str().as_encoded_bytes());
|
||||
field(&auxiliary.length().to_le_bytes());
|
||||
}
|
||||
Ok(data_encoding::HEXLOWER.encode(&digest.finalize()))
|
||||
}
|
||||
|
||||
/// A non-secret object revision; navigation never opens encrypted contents.
|
||||
pub(crate) fn navigation_revision(
|
||||
&self,
|
||||
|
||||
@@ -16,6 +16,249 @@ use ironstorage::{
|
||||
};
|
||||
use support::compatibility::{FixtureSet, TestResult};
|
||||
|
||||
#[test]
|
||||
fn mobile_tree_plans_confirm_folders_and_preserve_pass_recipient_semantics() -> TestResult {
|
||||
use ironstorage::mobile_mutation::{
|
||||
MobileMutationAction as Action, MobileMutationRequest, MobileMutationService,
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
fs::create_dir(store.path().join("archive"))?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let service = MobileMutationService::new(&repository);
|
||||
let mut secrets = Secrets::all(&fixture);
|
||||
let mut committer = Committer::default();
|
||||
let original = repository.read_entry(&EntryPath::parse("email/personal")?)?;
|
||||
let plan = service.prepare("email/", Action::Copy, false, false)?;
|
||||
assert!(plan.is_directory());
|
||||
assert_eq!(plan.entries(), 1);
|
||||
assert!(
|
||||
plan.destinations()
|
||||
.iter()
|
||||
.all(|destination| destination.path() != "email")
|
||||
);
|
||||
let mut request = MobileMutationRequest {
|
||||
action: Action::Copy,
|
||||
source: plan.source().into(),
|
||||
revision: plan.revision().into(),
|
||||
destination: Some("team".into()),
|
||||
confirmed: false,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
};
|
||||
assert!(
|
||||
service
|
||||
.perform(&request, false, &keys, &mut secrets, &mut committer)
|
||||
.is_err()
|
||||
);
|
||||
assert!(committer.changes.is_empty());
|
||||
assert!(!store.path().join("team/email").exists());
|
||||
request.confirmed = true;
|
||||
service.perform(&request, false, &keys, &mut secrets, &mut committer)?;
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("email/personal")?)?,
|
||||
original
|
||||
);
|
||||
let copied = repository.read_entry(&EntryPath::parse("team/email/personal")?)?;
|
||||
assert_ne!(copied, original);
|
||||
assert!(keys.is_encrypted_for(
|
||||
&copied,
|
||||
&[keys.resolve(&fixture.key("bob")?.primary_fingerprint)?]
|
||||
)?);
|
||||
assert_eq!(
|
||||
keys.decrypt(&copied, &mut secrets)?.expose(),
|
||||
fixture.read("expected/basic/email/personal.txt")?
|
||||
);
|
||||
let plan = service.prepare("team/", Action::Move, false, false)?;
|
||||
let policy = fs::read(store.path().join("team/.gpg-id"))?;
|
||||
service.perform(
|
||||
&MobileMutationRequest {
|
||||
action: Action::Move,
|
||||
source: plan.source().into(),
|
||||
revision: plan.revision().into(),
|
||||
destination: Some("archive".into()),
|
||||
confirmed: true,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
},
|
||||
false,
|
||||
&keys,
|
||||
&mut secrets,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert!(!store.path().join("team").exists());
|
||||
assert_eq!(fs::read(store.path().join("archive/team/.gpg-id"))?, policy);
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("archive/team/email/personal")?)?,
|
||||
copied
|
||||
);
|
||||
let plan = service.prepare("archive/team/", Action::Delete, false, false)?;
|
||||
let mut request = MobileMutationRequest {
|
||||
action: Action::Delete,
|
||||
source: plan.source().into(),
|
||||
revision: plan.revision().into(),
|
||||
destination: None,
|
||||
confirmed: false,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
};
|
||||
assert!(
|
||||
service
|
||||
.perform(&request, false, &keys, &mut secrets, &mut committer)
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.path().join("archive/team").exists());
|
||||
request.confirmed = true;
|
||||
service.perform(&request, false, &keys, &mut secrets, &mut committer)?;
|
||||
assert!(!store.path().join("archive/team").exists());
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("email/personal")?)?,
|
||||
original
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_overwrite_is_explicit_and_destination_replacement_invalidates_the_plan() -> TestResult {
|
||||
use ironstorage::mobile_mutation::{
|
||||
MobileMutationAction as Action, MobileMutationError, MobileMutationRequest,
|
||||
MobileMutationService,
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let path = EntryPath::parse("team/personal")?;
|
||||
let original = repository.read_entry(&EntryPath::parse("email/personal")?)?;
|
||||
repository.write_entry(&path, &original)?;
|
||||
let service = MobileMutationService::new(&repository);
|
||||
let plan = service.prepare("email/personal", Action::Copy, false, false)?;
|
||||
let target = plan
|
||||
.destinations()
|
||||
.iter()
|
||||
.find(|target| target.path() == "team")
|
||||
.unwrap();
|
||||
assert!(target.requires_overwrite());
|
||||
assert_eq!(target.collision_targets(), &["team/personal"]);
|
||||
let mut request = MobileMutationRequest {
|
||||
action: Action::Copy,
|
||||
source: plan.source().into(),
|
||||
revision: plan.revision().into(),
|
||||
destination: Some("team".into()),
|
||||
confirmed: true,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
};
|
||||
let mut secrets = Secrets::all(&fixture);
|
||||
let mut committer = Committer::default();
|
||||
assert!(matches!(
|
||||
service.perform(&request, false, &keys, &mut secrets, &mut committer),
|
||||
Err(MobileMutationError::OverwriteRequired { .. })
|
||||
));
|
||||
repository.write_entry(&path, &original)?;
|
||||
request.overwrite = true;
|
||||
assert!(matches!(
|
||||
service.perform(&request, false, &keys, &mut secrets, &mut committer),
|
||||
Err(MobileMutationError::StaleEntry { .. })
|
||||
));
|
||||
assert_eq!(repository.read_entry(&path)?, original);
|
||||
assert!(committer.changes.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_rechecks_revisions_after_reencryption_and_rolls_back_failed_commits() -> TestResult {
|
||||
use ironstorage::mobile_mutation::{
|
||||
MobileMutationAction as Action, MobileMutationRequest, MobileMutationService,
|
||||
};
|
||||
struct ReplacingSecrets<'a> {
|
||||
inner: Secrets,
|
||||
repository: &'a Repository,
|
||||
path: EntryPath,
|
||||
}
|
||||
impl SecretProvider for ReplacingSecrets<'_> {
|
||||
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
self.repository
|
||||
.write_entry(
|
||||
&self.path,
|
||||
&ironstorage::repository::EncryptedEntry::new(b"replacement".to_vec()),
|
||||
)
|
||||
.map_err(|_| SecretProviderError::Unavailable)?;
|
||||
self.inner.secret_for(key)
|
||||
}
|
||||
}
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
fs::create_dir(store.path().join("archive"))?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let service = MobileMutationService::new(&repository);
|
||||
let original = repository.read_entry(&EntryPath::parse("email/personal")?)?;
|
||||
let plan = service.prepare("email/personal", Action::Move, false, false)?;
|
||||
let mut provider = ReplacingSecrets {
|
||||
inner: Secrets::all(&fixture),
|
||||
repository: &repository,
|
||||
path: EntryPath::parse("email/personal")?,
|
||||
};
|
||||
let mut committer = Committer::default();
|
||||
assert!(
|
||||
service
|
||||
.perform(
|
||||
&MobileMutationRequest {
|
||||
action: Action::Move,
|
||||
source: plan.source().into(),
|
||||
revision: plan.revision().into(),
|
||||
destination: Some("team".into()),
|
||||
confirmed: true,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
},
|
||||
false,
|
||||
&keys,
|
||||
&mut provider,
|
||||
&mut committer
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
repository
|
||||
.read_entry(&EntryPath::parse("email/personal")?)?
|
||||
.as_bytes(),
|
||||
b"replacement"
|
||||
);
|
||||
assert!(!store.path().join("team/personal.gpg").exists());
|
||||
assert!(committer.changes.is_empty());
|
||||
repository.write_entry(&EntryPath::parse("email/personal")?, &original)?;
|
||||
let plan = service.prepare("email/", Action::Move, false, false)?;
|
||||
committer.fail = true;
|
||||
assert!(
|
||||
service
|
||||
.perform(
|
||||
&MobileMutationRequest {
|
||||
action: Action::Move,
|
||||
source: plan.source().into(),
|
||||
revision: plan.revision().into(),
|
||||
destination: Some("archive".into()),
|
||||
confirmed: true,
|
||||
overwrite: false,
|
||||
discard_editor: false,
|
||||
},
|
||||
false,
|
||||
&keys,
|
||||
&mut Secrets::all(&fixture),
|
||||
&mut committer
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("email/personal")?)?,
|
||||
original
|
||||
);
|
||||
assert!(!store.path().join("archive/email").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Secrets(BTreeMap<String, Vec<u8>>);
|
||||
impl Secrets {
|
||||
fn all(fixture: &FixtureSet) -> Self {
|
||||
|
||||
Reference in new issue
Block a user