Files
IronStorage/crates/storage/src/write.rs
2026-08-10 07:22:55 +00:00

494 lines
15 KiB
Rust

//! 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<u8>, mut confirmation: Vec<u8>) -> Result<Self, WriteError> {
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<u8>) -> Result<Self, WriteError> {
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<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(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<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
}
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<bool, WriteError> {
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<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::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<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 store_generated(
&self,
path: &EntryPath,
contents: SecretBytes,
force: bool,
overwrite: OverwriteDecision,
signing: Option<&SigningPolicy>,
committer: &mut impl EntryCommitter,
) -> Result<WriteOutcome, WriteError> {
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<WriteOutcome, WriteError> {
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<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::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<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)
}
}