375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
#![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,
|
|
}
|
|
}
|