Implement confirmed Android tree mutation workflows (#108)
Android ARM verification / rust (push) Failing after 49m2s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 2m40s

This commit is contained in:
hugo committed 2026-10-12 13:06:22 +02:00
1 parent 6a864a8d7f
commit 316a875d63
22 files changed
+2170 -220

No files matched your search

+6
View File
@@ -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
}
+381 -17
View File
@@ -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;
+313 -45
View File
@@ -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,
+39 -1
View File
@@ -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 {
+48
View File
@@ -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,
+243
View File
@@ -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 {