Files
IronStorage/crates/storage/tests/write_domains.rs
2026-08-09 22:35:46 +00:00

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,
}
}