Implement insert and edit sessions (#7)

This commit is contained in:
Hermes Agent
2026-08-09 22:35:46 +00:00
parent dca07b7fbd
commit 0e4cbef944
10 changed files with 1158 additions and 1 deletions

View File

@@ -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";

View File

@@ -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
View 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)) => &current != 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)
}
}

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