Implement insert and edit sessions (#7)
This commit is contained in:
374
crates/storage/tests/write_domains.rs
Normal file
374
crates/storage/tests/write_domains.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::{collections::BTreeMap, fs};
|
||||
|
||||
use ironstorage::{
|
||||
command::{EditRequest, InsertInput, InsertRequest},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
write::{
|
||||
EntryCommit, EntryCommitError, EntryCommitter, InsertContent, OverwriteDecision,
|
||||
VaultWriter, 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 RecordingCommitter {
|
||||
changes: Vec<EntryCommit>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl EntryCommitter for RecordingCommitter {
|
||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||
self.changes.push(change.clone());
|
||||
if self.fail {
|
||||
Err(EntryCommitError::new("simulated Git failure"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_content_modes_validate_and_redact_input() -> TestResult {
|
||||
let hidden = InsertContent::hidden(b"secret".to_vec(), b"secret".to_vec())?;
|
||||
assert_eq!(hidden.expose(), b"secret");
|
||||
assert!(!format!("{hidden:?}").contains("secret"));
|
||||
assert!(matches!(
|
||||
InsertContent::hidden(b"first".to_vec(), b"second".to_vec()),
|
||||
Err(WriteError::ConfirmationMismatch)
|
||||
));
|
||||
assert!(matches!(
|
||||
InsertContent::echoed(Vec::new()),
|
||||
Err(WriteError::EmptySingleLine)
|
||||
));
|
||||
assert!(matches!(
|
||||
InsertContent::echoed(b"two\nlines".to_vec()),
|
||||
Err(WriteError::InvalidSingleLine)
|
||||
));
|
||||
assert!(InsertContent::multiline(Vec::new()).expose().is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_hidden_echoed_and_multiline_use_inherited_recipients() -> 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 writer = VaultWriter::new(&repository, &keys);
|
||||
let mut committer = RecordingCommitter::default();
|
||||
|
||||
for (path, input, contents) in [
|
||||
(
|
||||
"new/hidden",
|
||||
InsertInput::HiddenConfirmed,
|
||||
InsertContent::hidden(b"hidden".to_vec(), b"hidden".to_vec())?,
|
||||
),
|
||||
(
|
||||
"new/echoed",
|
||||
InsertInput::EchoedLine,
|
||||
InsertContent::echoed(b"echoed".to_vec())?,
|
||||
),
|
||||
(
|
||||
"new/multiline",
|
||||
InsertInput::Multiline,
|
||||
InsertContent::multiline(b"first\nsecond\n".to_vec()),
|
||||
),
|
||||
] {
|
||||
writer.insert(
|
||||
&insert(path, false, input),
|
||||
contents,
|
||||
OverwriteDecision::Decline,
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
}
|
||||
assert_eq!(committer.changes.len(), 3);
|
||||
assert_eq!(
|
||||
committer.changes[2].message(),
|
||||
"Add given password for new/multiline to store."
|
||||
);
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let plaintext = keys.decrypt(
|
||||
&repository.read_entry(&EntryPath::parse("new/multiline")?)?,
|
||||
&mut provider,
|
||||
)?;
|
||||
assert_eq!(plaintext.expose(), b"first\nsecond\n");
|
||||
let alice = fixture.key("alice")?;
|
||||
assert!(keys.is_encrypted_for(
|
||||
&repository.read_entry(&EntryPath::parse("new/hidden")?)?,
|
||||
&[keys.resolve(&alice.primary_fingerprint)?]
|
||||
)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_decision_force_and_commit_failure_are_transactional() -> 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 writer = VaultWriter::new(&repository, &keys);
|
||||
let path = EntryPath::parse("email/personal")?;
|
||||
let original = repository.read_entry(&path)?;
|
||||
let mut committer = RecordingCommitter::default();
|
||||
|
||||
assert!(matches!(
|
||||
writer.insert(
|
||||
&insert("email/personal", false, InsertInput::EchoedLine),
|
||||
InsertContent::echoed(b"declined".to_vec())?,
|
||||
OverwriteDecision::Decline,
|
||||
None,
|
||||
&mut committer,
|
||||
),
|
||||
Err(WriteError::Cancelled)
|
||||
));
|
||||
assert_eq!(repository.read_entry(&path)?, original);
|
||||
|
||||
writer.insert(
|
||||
&insert("email/personal", true, InsertInput::EchoedLine),
|
||||
InsertContent::echoed(b"forced".to_vec())?,
|
||||
OverwriteDecision::Decline,
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
let forced = repository.read_entry(&path)?;
|
||||
assert_ne!(forced, original);
|
||||
|
||||
committer.fail = true;
|
||||
assert!(matches!(
|
||||
writer.insert(
|
||||
&insert("email/personal", true, InsertInput::EchoedLine),
|
||||
InsertContent::echoed(b"rollback".to_vec())?,
|
||||
OverwriteDecision::Allow,
|
||||
None,
|
||||
&mut committer,
|
||||
),
|
||||
Err(WriteError::Commit(_))
|
||||
));
|
||||
assert_eq!(repository.read_entry(&path)?, forced);
|
||||
|
||||
assert!(matches!(
|
||||
writer.insert(
|
||||
&insert("temporary/deep/entry", false, InsertInput::Multiline),
|
||||
InsertContent::multiline(b"new".to_vec()),
|
||||
OverwriteDecision::Allow,
|
||||
None,
|
||||
&mut committer,
|
||||
),
|
||||
Err(WriteError::Commit(_))
|
||||
));
|
||||
assert!(!store.path().join("temporary").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_session_handles_replacement_unchanged_and_commit_failure() -> 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 writer = VaultWriter::new(&repository, &keys);
|
||||
let request = EditRequest {
|
||||
entry: "email/personal".to_owned(),
|
||||
};
|
||||
let path = EntryPath::parse("email/personal")?;
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let mut committer = RecordingCommitter::default();
|
||||
|
||||
let unchanged = writer.begin_edit(&request, &mut provider)?;
|
||||
let same = SecretBytes::new(unchanged.plaintext().expose().to_vec());
|
||||
assert!(matches!(
|
||||
writer.finish_edit(unchanged, same, "fixture-editor", None, &mut committer),
|
||||
Err(WriteError::Unchanged)
|
||||
));
|
||||
assert!(committer.changes.is_empty());
|
||||
|
||||
let session = writer.begin_edit(&request, &mut provider)?;
|
||||
writer.finish_edit(
|
||||
session,
|
||||
SecretBytes::new(b"edited\nlogin: replacement\n".to_vec()),
|
||||
"fixture-editor",
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert_eq!(
|
||||
committer.changes[0].message(),
|
||||
"Edit password for email/personal using fixture-editor."
|
||||
);
|
||||
let edited = repository.read_entry(&path)?;
|
||||
let plaintext = keys.decrypt(&edited, &mut provider)?;
|
||||
assert_eq!(plaintext.expose(), b"edited\nlogin: replacement\n");
|
||||
|
||||
let session = writer.begin_edit(&request, &mut provider)?;
|
||||
committer.fail = true;
|
||||
assert!(matches!(
|
||||
writer.finish_edit(
|
||||
session,
|
||||
SecretBytes::new(b"must roll back".to_vec()),
|
||||
"fixture-editor",
|
||||
None,
|
||||
&mut committer,
|
||||
),
|
||||
Err(WriteError::Commit(_))
|
||||
));
|
||||
assert_eq!(repository.read_entry(&path)?, edited);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_detects_concurrent_ciphertext_change_before_writing() -> 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 writer = VaultWriter::new(&repository, &keys);
|
||||
let request = EditRequest {
|
||||
entry: "email/personal".to_owned(),
|
||||
};
|
||||
let path = EntryPath::parse("email/personal")?;
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let session = writer.begin_edit(&request, &mut provider)?;
|
||||
let mut committer = RecordingCommitter::default();
|
||||
writer.insert(
|
||||
&insert("email/personal", true, InsertInput::EchoedLine),
|
||||
InsertContent::echoed(b"concurrent".to_vec())?,
|
||||
OverwriteDecision::Allow,
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
let concurrent = repository.read_entry(&path)?;
|
||||
|
||||
assert!(matches!(
|
||||
writer.finish_edit(
|
||||
session,
|
||||
SecretBytes::new(b"stale editor".to_vec()),
|
||||
"fixture-editor",
|
||||
None,
|
||||
&mut committer,
|
||||
),
|
||||
Err(WriteError::ConcurrentModification { .. })
|
||||
));
|
||||
assert_eq!(repository.read_entry(&path)?, concurrent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_session_can_create_a_new_entry_and_detect_one_appearing_concurrently() -> 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 writer = VaultWriter::new(&repository, &keys);
|
||||
let request = EditRequest {
|
||||
entry: "new/from-editor".to_owned(),
|
||||
};
|
||||
let mut provider = FixtureSecrets::all(&fixture);
|
||||
let session = writer.begin_edit(&request, &mut provider)?;
|
||||
assert!(session.plaintext().expose().is_empty());
|
||||
let mut committer = RecordingCommitter::default();
|
||||
writer.finish_edit(
|
||||
session,
|
||||
SecretBytes::new(b"created by editor\n".to_vec()),
|
||||
"fixture-editor",
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert!(store.path().join("new/from-editor.gpg").is_file());
|
||||
|
||||
let concurrent_request = EditRequest {
|
||||
entry: "new/concurrent-editor".to_owned(),
|
||||
};
|
||||
let session = writer.begin_edit(&concurrent_request, &mut provider)?;
|
||||
writer.insert(
|
||||
&insert("new/concurrent-editor", false, InsertInput::EchoedLine),
|
||||
InsertContent::echoed(b"appeared".to_vec())?,
|
||||
OverwriteDecision::Allow,
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert!(matches!(
|
||||
writer.finish_edit(
|
||||
session,
|
||||
SecretBytes::new(b"stale".to_vec()),
|
||||
"fixture-editor",
|
||||
None,
|
||||
&mut committer,
|
||||
),
|
||||
Err(WriteError::ConcurrentModification { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_recipient_policy_fails_before_entry_creation() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
fs::create_dir(store.path().join("invalid"))?;
|
||||
fs::write(
|
||||
store.path().join("invalid/.gpg-id"),
|
||||
b"missing@ironstorage.invalid\n",
|
||||
)?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let writer = VaultWriter::new(&repository, &keys);
|
||||
|
||||
assert!(matches!(
|
||||
writer.insert(
|
||||
&insert("invalid/entry", false, InsertInput::Multiline),
|
||||
InsertContent::multiline(b"secret".to_vec()),
|
||||
OverwriteDecision::Allow,
|
||||
None,
|
||||
&mut RecordingCommitter::default(),
|
||||
),
|
||||
Err(WriteError::RecipientPolicy(
|
||||
ironstorage::recipient::RecipientPolicyError::Crypto(
|
||||
CryptoError::MissingIdentity { .. }
|
||||
)
|
||||
))
|
||||
));
|
||||
assert!(!store.path().join("invalid/entry.gpg").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert(entry: &str, force: bool, input: InsertInput) -> InsertRequest {
|
||||
InsertRequest {
|
||||
entry: entry.to_owned(),
|
||||
input,
|
||||
force,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user