//! 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}, }; use zeroize::Zeroize; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OverwriteDecision { Allow, Decline, } pub struct InsertContent { mode: InsertInput, secret: SecretBytes, } impl InsertContent { pub fn hidden(mut first: Vec, mut confirmation: Vec) -> Result { if let Err(error) = validate_single_line(&first) { first.zeroize(); confirmation.zeroize(); return Err(error); } if let Err(error) = validate_single_line(&confirmation) { first.zeroize(); confirmation.zeroize(); return Err(error); } if first != confirmation { first.zeroize(); confirmation.zeroize(); return Err(WriteError::ConfirmationMismatch); } if first.is_empty() { confirmation.zeroize(); return Err(WriteError::EmptySingleLine); } confirmation.zeroize(); Ok(Self { mode: InsertInput::HiddenConfirmed, secret: SecretBytes::new(first), }) } pub fn echoed(mut line: Vec) -> Result { if let Err(error) = validate_single_line(&line) { line.zeroize(); return Err(error); } if line.is_empty() { return Err(WriteError::EmptySingleLine); } Ok(Self { mode: InsertInput::EchoedLine, secret: SecretBytes::new(line), }) } pub fn multiline(contents: Vec) -> 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(crate) fn new(path: EntryPath, action: EntryAction, message: String) -> Self { Self { path, action, message, } } 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) -> 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, plaintext: SecretBytes, } impl EditSession { pub fn path(&self) -> &EntryPath { &self.path } pub fn plaintext(&self) -> &SecretBytes { &self.plaintext } pub(crate) fn original_ciphertext(&self) -> Option<&EncryptedEntry> { self.original_ciphertext.as_ref() } } 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 } } /// Report whether a logical entry already exists so a frontend can ask for /// confirmation without inspecting the password-store filesystem itself. pub fn entry_exists(&self, entry: &str) -> Result { let path = EntryPath::parse(entry)?; match self.repository.read_entry(&path) { Ok(_) => Ok(true), Err(RepositoryError::NotFound { .. }) => Ok(false), Err(error) => Err(error.into()), } } #[allow(clippy::too_many_arguments)] pub fn insert( &self, request: &InsertRequest, contents: InsertContent, overwrite: OverwriteDecision, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, ) -> Result { 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::new( path.clone(), EntryAction::Insert, 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 { 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 store_generated( &self, path: &EntryPath, contents: SecretBytes, force: bool, overwrite: OverwriteDecision, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, ) -> Result { 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() && !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, recipients.recipients())?; self.repository.write_entry(path, &ciphertext)?; let change = EntryCommit::new( path.clone(), EntryAction::Insert, format!("Add generated password for {path}."), ); 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: path.clone(), action: EntryAction::Insert, }) } #[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 { self.finish_edit_recoverable(&session, replacement, editor_name, signing, committer) } /// Finish an edit while retaining the session when validation, encryption, /// conflict detection, or commit orchestration fails. #[allow(clippy::too_many_arguments)] pub fn finish_edit_recoverable( &self, session: &EditSession, replacement: SecretBytes, editor_name: &str, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, ) -> Result { 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::new( session.path.clone(), EntryAction::Edit, 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.clone(), 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 for WriteError { fn from(error: RepositoryError) -> Self { Self::Repository(error) } } impl From for WriteError { fn from(error: CryptoError) -> Self { Self::Crypto(error) } } impl From for WriteError { fn from(error: RecipientPolicyError) -> Self { Self::RecipientPolicy(error) } }