Files
IronStorage/crates/storage/tests/entry_documents.rs

369 lines
13 KiB
Rust

#![forbid(unsafe_code)]
mod support;
use std::collections::{BTreeMap, BTreeSet};
use ironstorage::{
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::{
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
};
use support::compatibility::{FixtureSet, TestResult};
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
impl FixtureSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self(
fixture
.generated
.keys
.iter()
.map(|key| {
(
key.primary_fingerprint.clone(),
key.passphrase.as_bytes().to_vec(),
)
})
.collect(),
)
}
}
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.0
.get(key.fingerprint().as_str())
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
#[derive(Default)]
struct Committer {
changes: Vec<EntryCommit>,
fail: bool,
}
impl EntryCommitter for Committer {
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
self.changes.push(change.clone());
if self.fail {
Err(EntryCommitError::new("simulated commit failure"))
} else {
Ok(())
}
}
}
#[test]
fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
let plaintext = b"p\xc3\xa4ssw\xc3\xb6rd\r\nusername: alice\r\nautoType_enabled: true\r\nicon: Internet\r\ncustom: one\r\ncustom: \r\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\r\n\r\nfirst note\r\nsecond note\r\n\xe9\x8d\xb5: \xe5\x80\xbc\r\nunrecognized line";
write_plaintext(&repository, &keys, "documents/complex", plaintext)?;
let service = EntryDocumentService::new(&repository, &keys);
let document = service.open("documents/complex", &mut secrets)?;
assert_eq!(document.serialize().expose(), plaintext);
assert_eq!(
document.password().expect("password").value(),
"pässwörd".as_bytes()
);
assert_eq!(document.fields().len(), 12);
let kinds = document
.fields()
.iter()
.map(|field| field.metadata().kind())
.collect::<Vec<_>>();
assert_eq!(
kinds,
[
EntryFieldKind::Password,
EntryFieldKind::Username,
EntryFieldKind::Field,
EntryFieldKind::Field,
EntryFieldKind::Field,
EntryFieldKind::Field,
EntryFieldKind::OtpUri,
EntryFieldKind::Blank,
EntryFieldKind::Note,
EntryFieldKind::Note,
EntryFieldKind::Field,
EntryFieldKind::Note,
]
);
assert_eq!(document.fields()[1].metadata().name(), Some("username"));
assert_eq!(
document.fields()[1].metadata().sensitivity(),
EntrySensitivity::Ordinary
);
for (index, name, value) in [
(2, "autoType_enabled", b"true".as_slice()),
(3, "icon", b"Internet".as_slice()),
] {
assert_eq!(document.fields()[index].metadata().name(), Some(name));
assert_eq!(document.fields()[index].value(), value);
assert_eq!(
document.fields()[index].metadata().sensitivity(),
EntrySensitivity::Ordinary
);
}
assert_eq!(document.fields()[4].metadata().name(), Some("custom"));
assert_eq!(document.fields()[4].value(), b"one");
assert_eq!(document.fields()[5].metadata().name(), Some("custom"));
assert!(document.fields()[5].value().is_empty());
assert_ne!(document.fields()[4].id(), document.fields()[5].id());
assert_eq!(document.fields()[10].metadata().name(), Some(""));
assert_eq!(document.fields()[10].value(), "".as_bytes());
assert_eq!(
document.fields()[6].metadata().sensitivity(),
EntrySensitivity::Sensitive
);
let otp = document.fields()[6]
.metadata()
.otp()
.expect("validated OTP metadata");
assert_eq!(otp.kind(), ironstorage::otp::OtpKind::Totp);
assert_eq!(otp.issuer(), Some("Example"));
assert_eq!(otp.account(), "alice");
assert_eq!(otp.algorithm(), ironstorage::otp::OtpAlgorithm::Sha1);
assert_eq!(otp.digits(), 6);
assert_eq!(otp.period(), Some(30));
assert_eq!(otp.counter(), None);
let copied = document.copy_field_value(document.fields()[4].id())?;
assert_eq!(copied.expose(), b"one");
assert!(!format!("{document:?}").contains("pässwörd"));
assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
Ok(())
}
#[test]
fn field_ids_survive_updates_removal_and_reordering() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
write_plaintext(
&repository,
&keys,
"documents/editable",
b"password\nusername: alice\nnote one\nnote two\n",
)?;
let service = EntryDocumentService::new(&repository, &keys);
let mut document = service.open("documents/editable", &mut secrets)?;
let password_id = document.fields()[0].id();
let username_id = document.fields()[1].id();
let note_one_id = document.fields()[2].id();
let note_two_id = document.fields()[3].id();
let token = document.conflict_token();
document.update(
username_id,
EntryFieldDraft::field("username", b"bob".to_vec())?,
)?;
let email_id = document.add(
2,
EntryFieldDraft::field("email", "bob@例.test".as_bytes().to_vec())?,
)?;
document.reorder(note_two_id, 2)?;
let removed = document.remove(note_one_id)?;
assert_eq!(removed.id(), note_one_id);
assert_eq!(document.conflict_token(), token);
assert_eq!(document.fields()[0].id(), password_id);
assert_eq!(
document.field(username_id).expect("username").value(),
b"bob"
);
assert_eq!(
document.field(email_id).expect("email").metadata().kind(),
EntryFieldKind::Email
);
assert_eq!(document.fields()[2].id(), note_two_id);
assert_eq!(
document.serialize().expose(),
"password\nusername: bob\nnote two\nemail: bob@例.test\n".as_bytes()
);
let mut empty = service.open("documents/missing", &mut secrets)?;
assert!(empty.fields().is_empty());
assert!(empty.password().is_none());
empty.add(0, EntryFieldDraft::line(b"password".to_vec())?)?;
empty.add(1, EntryFieldDraft::blank())?;
assert_eq!(empty.fields().len(), 2);
assert_eq!(empty.fields()[1].metadata().kind(), EntryFieldKind::Blank);
assert_eq!(empty.serialize().expose(), b"password\n");
assert!(matches!(
EntryFieldDraft::line(b"two\nlines".to_vec()),
Err(DocumentError::LineBreak)
));
assert!(matches!(
EntryFieldDraft::field(" invalid ", Vec::new()),
Err(DocumentError::InvalidFieldName)
));
assert!(matches!(
EntryFieldDraft::otp_uri(b"otpauth://totp/missing-secret".to_vec()),
Err(DocumentError::InvalidOtpUri)
));
Ok(())
}
#[test]
fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
let service = EntryDocumentService::new(&repository, &keys);
let mut committer = Committer::default();
let mut created = service.open("documents/new", &mut secrets)?;
created.add(0, EntryFieldDraft::line(b"new password".to_vec())?)?;
created.add(1, EntryFieldDraft::field("username", b"alice".to_vec())?)?;
service.save(created, None, &mut committer)?;
assert_eq!(committer.changes.len(), 1);
assert_eq!(
decrypt(&repository, &keys, "documents/new", &mut secrets)?.expose(),
b"new password\nusername: alice"
);
let mut first = service.open("documents/new", &mut secrets)?;
let mut stale = service.open("documents/new", &mut secrets)?;
assert_eq!(first.conflict_token(), stale.conflict_token());
let first_password = first.password().expect("password").id();
first.update(first_password, EntryFieldDraft::line(b"winner".to_vec())?)?;
service.save(first, None, &mut committer)?;
let winner = repository.read_entry(&EntryPath::parse("documents/new")?)?;
let stale_password = stale.password().expect("password").id();
stale.update(stale_password, EntryFieldDraft::line(b"stale".to_vec())?)?;
assert!(matches!(
service.save_recoverable(&stale, None, &mut committer),
Err(DocumentError::Write(
WriteError::ConcurrentModification { .. }
))
));
assert_eq!(stale.serialize().expose(), b"stale\nusername: alice");
assert_eq!(
repository.read_entry(&EntryPath::parse("documents/new")?)?,
winner
);
let mut rollback = service.open("documents/new", &mut secrets)?;
let password = rollback.password().expect("password").id();
rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?;
committer.fail = true;
assert!(matches!(
service.save_recoverable(&rollback, None, &mut committer),
Err(DocumentError::Write(WriteError::Commit(_)))
));
assert!(
rollback
.serialize()
.expose()
.starts_with(b"must roll back\n")
);
assert_eq!(
repository.read_entry(&EntryPath::parse("documents/new")?)?,
winner
);
committer.fail = false;
std::fs::write(
store.path().join(".gpg-id"),
b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n",
)?;
let retained = rollback.serialize();
assert!(
service
.save_recoverable(&rollback, None, &mut committer)
.is_err()
);
assert_eq!(rollback.serialize().expose(), retained.expose());
assert_eq!(
repository.read_entry(&EntryPath::parse("documents/new")?)?,
winner
);
Ok(())
}
#[test]
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
let fixture = FixtureSet::load()?;
let stores = fixture
.generated
.entries
.iter()
.map(|entry| entry.store.as_str())
.collect::<BTreeSet<_>>();
for store_id in stores {
let store = fixture.materialize_store(store_id)?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
let service = EntryDocumentService::new(&repository, &keys);
for entry in fixture
.generated
.entries
.iter()
.filter(|entry| entry.store == store_id)
{
let logical = entry
.path
.strip_suffix(".gpg")
.expect("fixture entry suffix");
let document = service.open(logical, &mut secrets)?;
assert_eq!(
document.serialize().expose(),
fixture.read(
std::path::Path::new("expected")
.join(store_id)
.join(&entry.plaintext),
)?,
"document round trip for {}/{}",
store_id,
entry.path
);
}
}
Ok(())
}
fn write_plaintext(
repository: &Repository,
keys: &KeyStore,
path: &str,
plaintext: &[u8],
) -> TestResult {
let path = EntryPath::parse(path)?;
let recipients =
RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?;
let encrypted = keys.encrypt(
SecretBytes::new(plaintext.to_vec()),
recipients.recipients(),
)?;
repository.write_entry(&path, &encrypted)?;
Ok(())
}
fn decrypt(
repository: &Repository,
keys: &KeyStore,
path: &str,
secrets: &mut impl SecretProvider,
) -> Result<SecretBytes, Box<dyn std::error::Error>> {
Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, secrets)?)
}