Implement insert and edit sessions (#7)
This commit is contained in:
@@ -11,6 +11,7 @@ pub mod crypto;
|
||||
pub mod read;
|
||||
pub mod recipient;
|
||||
pub mod repository;
|
||||
pub mod write;
|
||||
|
||||
/// Product name shared by the presentation adapters.
|
||||
pub const PRODUCT_NAME: &str = "IronStorage";
|
||||
|
||||
@@ -37,6 +37,10 @@ impl EntryPath {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn parent_directory(&self) -> DirectoryPath {
|
||||
self.parent()
|
||||
}
|
||||
|
||||
fn parent(&self) -> DirectoryPath {
|
||||
DirectoryPath(self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf))
|
||||
}
|
||||
@@ -511,6 +515,20 @@ impl Repository {
|
||||
self.write_entry_with_checkpoint(path, ciphertext, |_| Ok(()))
|
||||
}
|
||||
|
||||
pub fn remove_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
|
||||
let original = self.read_entry(path)?;
|
||||
let (parent, file_name) = self.open_entry_parent(path)?;
|
||||
parent
|
||||
.remove_file(&file_name)
|
||||
.map_err(|error| io_error("remove encrypted entry", &path.0, error))?;
|
||||
sync_directory(&parent, &path.parent().0).map_err(|_| {
|
||||
RepositoryError::DurabilityUncertain {
|
||||
path: path.0.clone(),
|
||||
}
|
||||
})?;
|
||||
Ok(original)
|
||||
}
|
||||
|
||||
fn write_entry_with_checkpoint<F>(
|
||||
&self,
|
||||
path: &EntryPath,
|
||||
|
||||
398
crates/storage/src/write.rs
Normal file
398
crates/storage/src/write.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
//! Insert and concurrency-safe in-process edit sessions.
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::{
|
||||
command::{EditRequest, InsertInput, InsertRequest},
|
||||
crypto::{CryptoError, KeyStore, SecretProvider},
|
||||
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
|
||||
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OverwriteDecision {
|
||||
Allow,
|
||||
Decline,
|
||||
}
|
||||
|
||||
pub struct InsertContent {
|
||||
mode: InsertInput,
|
||||
secret: SecretBytes,
|
||||
}
|
||||
|
||||
impl InsertContent {
|
||||
pub fn hidden(first: Vec<u8>, confirmation: Vec<u8>) -> Result<Self, WriteError> {
|
||||
validate_single_line(&first)?;
|
||||
validate_single_line(&confirmation)?;
|
||||
if first != confirmation {
|
||||
return Err(WriteError::ConfirmationMismatch);
|
||||
}
|
||||
if first.is_empty() {
|
||||
return Err(WriteError::EmptySingleLine);
|
||||
}
|
||||
Ok(Self {
|
||||
mode: InsertInput::HiddenConfirmed,
|
||||
secret: SecretBytes::new(first),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn echoed(line: Vec<u8>) -> Result<Self, WriteError> {
|
||||
validate_single_line(&line)?;
|
||||
if line.is_empty() {
|
||||
return Err(WriteError::EmptySingleLine);
|
||||
}
|
||||
Ok(Self {
|
||||
mode: InsertInput::EchoedLine,
|
||||
secret: SecretBytes::new(line),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn multiline(contents: Vec<u8>) -> Self {
|
||||
Self {
|
||||
mode: InsertInput::Multiline,
|
||||
secret: SecretBytes::new(contents),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expose(&self) -> &[u8] {
|
||||
self.secret.expose()
|
||||
}
|
||||
|
||||
fn into_secret(self) -> SecretBytes {
|
||||
self.secret
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for InsertContent {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("InsertContent([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EntryAction {
|
||||
Insert,
|
||||
Edit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EntryCommit {
|
||||
path: EntryPath,
|
||||
action: EntryAction,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl EntryCommit {
|
||||
pub fn path(&self) -> &EntryPath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn action(&self) -> EntryAction {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EntryCommitError(String);
|
||||
|
||||
impl EntryCommitError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EntryCommitError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for EntryCommitError {}
|
||||
|
||||
pub trait EntryCommitter {
|
||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NoGitEntryCommitter;
|
||||
|
||||
impl EntryCommitter for NoGitEntryCommitter {
|
||||
fn commit(&mut self, _change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WriteOutcome {
|
||||
path: EntryPath,
|
||||
action: EntryAction,
|
||||
}
|
||||
|
||||
impl WriteOutcome {
|
||||
pub fn path(&self) -> &EntryPath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn action(&self) -> EntryAction {
|
||||
self.action
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EditSession {
|
||||
path: EntryPath,
|
||||
original_ciphertext: Option<EncryptedEntry>,
|
||||
plaintext: SecretBytes,
|
||||
}
|
||||
|
||||
impl EditSession {
|
||||
pub fn path(&self) -> &EntryPath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn plaintext(&self) -> &SecretBytes {
|
||||
&self.plaintext
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EditSession {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("EditSession")
|
||||
.field("path", &self.path)
|
||||
.field("plaintext", &self.plaintext)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VaultWriter<'a> {
|
||||
repository: &'a Repository,
|
||||
keys: &'a KeyStore,
|
||||
}
|
||||
|
||||
impl<'a> VaultWriter<'a> {
|
||||
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
|
||||
Self { repository, keys }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn insert(
|
||||
&self,
|
||||
request: &InsertRequest,
|
||||
contents: InsertContent,
|
||||
overwrite: OverwriteDecision,
|
||||
signing: Option<&SigningPolicy>,
|
||||
committer: &mut impl EntryCommitter,
|
||||
) -> Result<WriteOutcome, WriteError> {
|
||||
let path = EntryPath::parse(&request.entry)?;
|
||||
if contents.mode != request.input {
|
||||
return Err(WriteError::InputModeMismatch);
|
||||
}
|
||||
let original = match self.repository.read_entry(&path) {
|
||||
Ok(original) => Some(original),
|
||||
Err(RepositoryError::NotFound { .. }) => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if original.is_some() && !request.force && overwrite == OverwriteDecision::Decline {
|
||||
return Err(WriteError::Cancelled);
|
||||
}
|
||||
let recipients = RecipientPolicyManager::new(self.repository, self.keys)
|
||||
.resolve_for_entry(&path, signing)?;
|
||||
let ciphertext = self
|
||||
.keys
|
||||
.encrypt(contents.into_secret(), recipients.recipients())?;
|
||||
self.repository.write_entry(&path, &ciphertext)?;
|
||||
let change = EntryCommit {
|
||||
path: path.clone(),
|
||||
action: EntryAction::Insert,
|
||||
message: format!("Add given password for {path} to store."),
|
||||
};
|
||||
if let Err(error) = committer.commit(&change) {
|
||||
if let Err(rollback) = self.restore(&path, original.as_ref()) {
|
||||
return Err(WriteError::RollbackFailed {
|
||||
operation: error,
|
||||
rollback,
|
||||
});
|
||||
}
|
||||
return Err(WriteError::Commit(error));
|
||||
}
|
||||
Ok(WriteOutcome {
|
||||
path,
|
||||
action: EntryAction::Insert,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn begin_edit(
|
||||
&self,
|
||||
request: &EditRequest,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<EditSession, WriteError> {
|
||||
let path = EntryPath::parse(&request.entry)?;
|
||||
let original_ciphertext = match self.repository.read_entry(&path) {
|
||||
Ok(ciphertext) => Some(ciphertext),
|
||||
Err(RepositoryError::NotFound { .. }) => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let plaintext = if let Some(ciphertext) = &original_ciphertext {
|
||||
self.keys.decrypt(ciphertext, provider)?
|
||||
} else {
|
||||
SecretBytes::new(Vec::new())
|
||||
};
|
||||
Ok(EditSession {
|
||||
path,
|
||||
original_ciphertext,
|
||||
plaintext,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn finish_edit(
|
||||
&self,
|
||||
session: EditSession,
|
||||
replacement: SecretBytes,
|
||||
editor_name: &str,
|
||||
signing: Option<&SigningPolicy>,
|
||||
committer: &mut impl EntryCommitter,
|
||||
) -> Result<WriteOutcome, WriteError> {
|
||||
if replacement.expose() == session.plaintext.expose() {
|
||||
return Err(WriteError::Unchanged);
|
||||
}
|
||||
let concurrent = match (
|
||||
session.original_ciphertext.as_ref(),
|
||||
self.repository.read_entry(&session.path),
|
||||
) {
|
||||
(Some(original), Ok(current)) => ¤t != original,
|
||||
(None, Err(RepositoryError::NotFound { .. })) => false,
|
||||
(Some(_), Err(RepositoryError::NotFound { .. })) | (None, Ok(_)) => true,
|
||||
(_, Err(error)) => return Err(error.into()),
|
||||
};
|
||||
if concurrent {
|
||||
return Err(WriteError::ConcurrentModification {
|
||||
path: session.path.clone(),
|
||||
});
|
||||
}
|
||||
let recipients = RecipientPolicyManager::new(self.repository, self.keys)
|
||||
.resolve_for_entry(&session.path, signing)?;
|
||||
let ciphertext = self.keys.encrypt(replacement, recipients.recipients())?;
|
||||
self.repository.write_entry(&session.path, &ciphertext)?;
|
||||
let change = EntryCommit {
|
||||
path: session.path.clone(),
|
||||
action: EntryAction::Edit,
|
||||
message: format!("Edit password for {} using {}.", session.path, editor_name),
|
||||
};
|
||||
if let Err(error) = committer.commit(&change) {
|
||||
if let Err(rollback) = self.restore(&session.path, session.original_ciphertext.as_ref())
|
||||
{
|
||||
return Err(WriteError::RollbackFailed {
|
||||
operation: error,
|
||||
rollback,
|
||||
});
|
||||
}
|
||||
return Err(WriteError::Commit(error));
|
||||
}
|
||||
Ok(WriteOutcome {
|
||||
path: session.path,
|
||||
action: EntryAction::Edit,
|
||||
})
|
||||
}
|
||||
|
||||
fn restore(
|
||||
&self,
|
||||
path: &EntryPath,
|
||||
original: Option<&EncryptedEntry>,
|
||||
) -> Result<(), RepositoryError> {
|
||||
if let Some(original) = original {
|
||||
self.repository.write_entry(path, original)?;
|
||||
} else {
|
||||
self.repository.remove_entry(path)?;
|
||||
self.repository
|
||||
.cleanup_empty_directories(&path.parent_directory())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_single_line(line: &[u8]) -> Result<(), WriteError> {
|
||||
if line.iter().any(|byte| matches!(byte, b'\n' | b'\r')) {
|
||||
Err(WriteError::InvalidSingleLine)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WriteError {
|
||||
Repository(RepositoryError),
|
||||
Crypto(CryptoError),
|
||||
RecipientPolicy(RecipientPolicyError),
|
||||
ConfirmationMismatch,
|
||||
EmptySingleLine,
|
||||
InvalidSingleLine,
|
||||
InputModeMismatch,
|
||||
Cancelled,
|
||||
Unchanged,
|
||||
ConcurrentModification {
|
||||
path: EntryPath,
|
||||
},
|
||||
Commit(EntryCommitError),
|
||||
RollbackFailed {
|
||||
operation: EntryCommitError,
|
||||
rollback: RepositoryError,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for WriteError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Repository(error) => error.fmt(formatter),
|
||||
Self::Crypto(error) => error.fmt(formatter),
|
||||
Self::RecipientPolicy(error) => error.fmt(formatter),
|
||||
Self::ConfirmationMismatch => {
|
||||
formatter.write_str("password confirmation does not match")
|
||||
}
|
||||
Self::EmptySingleLine => formatter.write_str("password may not be empty"),
|
||||
Self::InvalidSingleLine => {
|
||||
formatter.write_str("single-line input contains a line break")
|
||||
}
|
||||
Self::InputModeMismatch => {
|
||||
formatter.write_str("insert content does not match the requested input mode")
|
||||
}
|
||||
Self::Cancelled => formatter.write_str("entry overwrite was declined"),
|
||||
Self::Unchanged => formatter.write_str("entry was not changed"),
|
||||
Self::ConcurrentModification { path } => {
|
||||
write!(formatter, "entry changed during editing: {path}")
|
||||
}
|
||||
Self::Commit(error) => write!(formatter, "cannot commit entry mutation: {error}"),
|
||||
Self::RollbackFailed {
|
||||
operation,
|
||||
rollback,
|
||||
} => write!(
|
||||
formatter,
|
||||
"entry commit failed ({operation}) and repository rollback failed ({rollback})"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for WriteError {}
|
||||
|
||||
impl From<RepositoryError> for WriteError {
|
||||
fn from(error: RepositoryError) -> Self {
|
||||
Self::Repository(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CryptoError> for WriteError {
|
||||
fn from(error: CryptoError) -> Self {
|
||||
Self::Crypto(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecipientPolicyError> for WriteError {
|
||||
fn from(error: RecipientPolicyError) -> Self {
|
||||
Self::RecipientPolicy(error)
|
||||
}
|
||||
}
|
||||
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