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

+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 {