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

@@ -21,6 +21,7 @@ The current direct dependencies are:
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 | | [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
| [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 | | [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 |
| [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 | | [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 |
| [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 |
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 | | [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 | | [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 |
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 | | [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |

View File

@@ -36,6 +36,8 @@ the rollback/commit contract are documented in
[`docs/recipient-policies.md`](docs/recipient-policies.md). [`docs/recipient-policies.md`](docs/recipient-policies.md).
Typed list/show/find/decrypted-grep models and secret presentation selection are Typed list/show/find/decrypted-grep models and secret presentation selection are
documented in [`docs/read-domains.md`](docs/read-domains.md). documented in [`docs/read-domains.md`](docs/read-domains.md).
Insert modes, concurrency-safe edit sessions, and the secure CLI editor-file
boundary are documented in [`docs/write-domains.md`](docs/write-domains.md).
## Project layout ## Project layout

View File

@@ -12,6 +12,6 @@ path = "src/main.rs"
[dependencies] [dependencies]
ironstorage.workspace = true ironstorage.workspace = true
tempfile = "3"
[dev-dependencies] [dev-dependencies]
tempfile = "3"

308
apps/cli/src/editor.rs Normal file
View File

@@ -0,0 +1,308 @@
use std::{
error::Error,
ffi::OsString,
fmt, fs,
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
path::{Path, PathBuf},
};
use ironstorage::{config::ResolvedEditor, repository::SecretBytes};
const MAX_EDIT_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct EditorInvocation {
program: String,
arguments: Vec<OsString>,
plaintext_path: PathBuf,
}
impl EditorInvocation {
pub(crate) fn program(&self) -> &str {
&self.program
}
pub(crate) fn arguments(&self) -> &[OsString] {
&self.arguments
}
pub(crate) fn plaintext_path(&self) -> &Path {
&self.plaintext_path
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EditorStatus {
Saved,
Cancelled,
Failed(i32),
}
/// Host boundary for the one CLI-only editor exception. Storage and other applications never
/// receive an executable and tests can exercise the complete session without spawning a process.
pub(crate) trait EditorHost {
fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct EditorHostError;
impl fmt::Display for EditorHostError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("editor host failed")
}
}
impl Error for EditorHostError {}
pub(crate) fn edit_replacement(
plaintext: &SecretBytes,
editor: &ResolvedEditor,
host: &mut impl EditorHost,
) -> Result<SecretBytes, CliEditorError> {
let temporary = create_secure_directory()?;
let path = temporary.path().join("entry.txt");
let mut file = create_private_file(&path)?;
let prepared = file
.write_all(plaintext.expose())
.and_then(|()| file.sync_all());
drop(file);
if prepared.is_err() {
let _ = wipe_and_remove(&path);
return Err(CliEditorError::TemporaryFile);
}
let command = editor.command();
let mut arguments = command
.arguments()
.iter()
.map(OsString::from)
.collect::<Vec<_>>();
arguments.push(path.clone().into_os_string());
let invocation = EditorInvocation {
program: command.program().to_owned(),
arguments,
plaintext_path: path.clone(),
};
let result = match host.edit(&invocation) {
Ok(EditorStatus::Saved) => {
restrict_file_permissions(&path).and_then(|()| read_replacement(&path))
}
Ok(EditorStatus::Cancelled) => Err(CliEditorError::Cancelled),
Ok(EditorStatus::Failed(code)) => Err(CliEditorError::Failed(code)),
Err(error) => Err(CliEditorError::Host(error)),
};
wipe_and_remove(&path)?;
result
}
fn create_secure_directory() -> Result<tempfile::TempDir, CliEditorError> {
#[cfg(target_os = "linux")]
if Path::new("/dev/shm").is_dir()
&& let Ok(directory) = tempfile::Builder::new()
.prefix("ironstorage-edit-")
.tempdir_in("/dev/shm")
{
return Ok(directory);
}
tempfile::Builder::new()
.prefix("ironstorage-edit-")
.tempdir()
.map_err(|_| CliEditorError::TemporaryDirectory)
}
#[cfg(unix)]
fn create_private_file(path: &Path) -> Result<fs::File, CliEditorError> {
use std::os::unix::fs::OpenOptionsExt as _;
fs::OpenOptions::new()
.create_new(true)
.read(true)
.write(true)
.mode(0o600)
.open(path)
.map_err(|_| CliEditorError::TemporaryFile)
}
#[cfg(not(unix))]
fn create_private_file(path: &Path) -> Result<fs::File, CliEditorError> {
fs::OpenOptions::new()
.create_new(true)
.read(true)
.write(true)
.open(path)
.map_err(|_| CliEditorError::TemporaryFile)
}
fn read_replacement(path: &Path) -> Result<SecretBytes, CliEditorError> {
let metadata = fs::metadata(path).map_err(|_| CliEditorError::TemporaryFile)?;
if !metadata.is_file() || metadata.len() > MAX_EDIT_BYTES {
return Err(CliEditorError::ReplacementTooLarge);
}
let mut file = fs::File::open(path).map_err(|_| CliEditorError::TemporaryFile)?;
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut bytes)
.map_err(|_| CliEditorError::TemporaryFile)?;
Ok(SecretBytes::new(bytes))
}
#[cfg(unix)]
fn restrict_file_permissions(path: &Path) -> Result<(), CliEditorError> {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|_| CliEditorError::TemporaryFile)
}
#[cfg(not(unix))]
fn restrict_file_permissions(_path: &Path) -> Result<(), CliEditorError> {
Ok(())
}
fn wipe_and_remove(path: &Path) -> Result<(), CliEditorError> {
let mut file = match fs::OpenOptions::new().read(true).write(true).open(path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Err(CliEditorError::Cleanup),
};
let length = file.metadata().map_err(|_| CliEditorError::Cleanup)?.len();
file.seek(SeekFrom::Start(0))
.map_err(|_| CliEditorError::Cleanup)?;
let zeros = [0_u8; 8192];
let mut remaining = length;
while remaining > 0 {
let count = remaining.min(zeros.len() as u64) as usize;
file.write_all(&zeros[..count])
.map_err(|_| CliEditorError::Cleanup)?;
remaining -= count as u64;
}
file.set_len(0).map_err(|_| CliEditorError::Cleanup)?;
file.sync_all().map_err(|_| CliEditorError::Cleanup)?;
drop(file);
fs::remove_file(path).map_err(|_| CliEditorError::Cleanup)
}
#[derive(Debug)]
pub(crate) enum CliEditorError {
TemporaryDirectory,
TemporaryFile,
ReplacementTooLarge,
Host(EditorHostError),
Cancelled,
Failed(i32),
Cleanup,
}
impl fmt::Display for CliEditorError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TemporaryDirectory => formatter.write_str("cannot create secure edit directory"),
Self::TemporaryFile => formatter.write_str("cannot use secure edit file"),
Self::ReplacementTooLarge => formatter.write_str("edited entry is too large"),
Self::Host(error) => error.fmt(formatter),
Self::Cancelled => formatter.write_str("editor cancelled"),
Self::Failed(code) => write!(formatter, "editor exited unsuccessfully ({code})"),
Self::Cleanup => formatter.write_str("cannot securely clean up edit file"),
}
}
}
impl Error for CliEditorError {}
#[cfg(test)]
mod tests {
use super::*;
use ironstorage::{config::ConfigLoader, repository::SecretBytes};
#[derive(Default)]
struct RecordingHost {
replacement: Vec<u8>,
status: Option<EditorStatus>,
invocation: Option<EditorInvocation>,
mode: Option<u32>,
}
impl EditorHost for RecordingHost {
fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError> {
self.invocation = Some(invocation.clone());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
self.mode = Some(
fs::metadata(invocation.plaintext_path())
.unwrap()
.permissions()
.mode()
& 0o777,
);
}
if self.status.unwrap_or(EditorStatus::Saved) == EditorStatus::Saved {
fs::write(invocation.plaintext_path(), &self.replacement).unwrap();
}
Ok(self.status.unwrap_or(EditorStatus::Saved))
}
}
#[test]
fn secure_editor_file_uses_resolved_arguments_permissions_and_cleanup()
-> Result<(), Box<dyn Error>> {
let temporary = tempfile::tempdir()?;
fs::create_dir(temporary.path().join("vault"))?;
fs::create_dir(temporary.path().join("keys"))?;
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
"vault='vault'\ndefault_key='alice'\nkey_material='keys'\neditor=['fixture-editor','--wait','--clean']\n",
)?;
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().to_owned())
.load(Some(&config_path))?;
let editor = config.resolve_editor_from(None, None)?;
let plaintext = SecretBytes::new(b"original\n".to_vec());
let mut host = RecordingHost {
replacement: b"replacement\n".to_vec(),
..RecordingHost::default()
};
let replacement = edit_replacement(&plaintext, &editor, &mut host)?;
assert_eq!(replacement.expose(), b"replacement\n");
let invocation = host.invocation.expect("invocation");
assert_eq!(invocation.program(), "fixture-editor");
assert_eq!(invocation.arguments()[0].to_str(), Some("--wait"));
assert_eq!(invocation.arguments()[1].to_str(), Some("--clean"));
assert_eq!(
invocation.arguments().last().unwrap(),
invocation.plaintext_path().as_os_str()
);
#[cfg(unix)]
assert_eq!(host.mode, Some(0o600));
assert!(!invocation.plaintext_path().exists());
Ok(())
}
#[test]
fn failure_and_cancellation_clean_up_without_returning_plaintext() -> Result<(), Box<dyn Error>>
{
let temporary = tempfile::tempdir()?;
fs::create_dir(temporary.path().join("vault"))?;
fs::create_dir(temporary.path().join("keys"))?;
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
"vault='vault'\ndefault_key='alice'\nkey_material='keys'\n",
)?;
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().to_owned())
.load(Some(&config_path))?;
let editor =
config.resolve_editor_from(Some(std::ffi::OsStr::new("visual --flag")), None)?;
let plaintext = SecretBytes::new(b"secret".to_vec());
for status in [EditorStatus::Failed(42), EditorStatus::Cancelled] {
let mut host = RecordingHost {
status: Some(status),
..RecordingHost::default()
};
assert!(edit_replacement(&plaintext, &editor, &mut host).is_err());
assert!(!host.invocation.unwrap().plaintext_path().exists());
}
Ok(())
}
}

View File

@@ -11,6 +11,9 @@ use ironstorage::{
config::Config, config::Config,
}; };
#[allow(dead_code)]
mod editor;
fn main() -> ExitCode { fn main() -> ExitCode {
match run() { match run() {
Ok(code) => ExitCode::from(code), Ok(code) => ExitCode::from(code),

View File

@@ -11,6 +11,7 @@ pub mod crypto;
pub mod read; pub mod read;
pub mod recipient; pub mod recipient;
pub mod repository; pub mod repository;
pub mod write;
/// Product name shared by the presentation adapters. /// Product name shared by the presentation adapters.
pub const PRODUCT_NAME: &str = "IronStorage"; pub const PRODUCT_NAME: &str = "IronStorage";

View File

@@ -37,6 +37,10 @@ impl EntryPath {
&self.0 &self.0
} }
pub fn parent_directory(&self) -> DirectoryPath {
self.parent()
}
fn parent(&self) -> DirectoryPath { fn parent(&self) -> DirectoryPath {
DirectoryPath(self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf)) 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(())) 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>( fn write_entry_with_checkpoint<F>(
&self, &self,
path: &EntryPath, 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,
}
}

52
docs/write-domains.md Normal file
View File

@@ -0,0 +1,52 @@
# Insert and edit domains
`VaultWriter` in `crates/storage` owns insertion, edit concurrency, recipient
selection, encryption, repository mutation, rollback, and commit intent.
Frontends collect input and overwrite decisions but cannot write an encrypted
entry directly.
## Insert
`InsertContent` has distinct constructors for hidden confirmed input, echoed
single-line input, and exact multiline bytes. Hidden values must match their
confirmation; both single-line modes reject empty values and embedded line
breaks. Multiline input preserves every byte and may be empty. The content mode
must equal the parsed `InsertRequest`, preventing an adapter from silently
changing stdin semantics.
An existing entry requires either `--force` or an explicit allow decision. A
decline returns before recipient resolution or encryption. The nearest signed
or unsigned recipient policy is resolved for the destination, plaintext is
consumed by embedded OpenPGP encryption, and repository replacement is atomic.
The storage commit boundary is invoked only afterward with the compatible
`Add given password ...` intent. Commit failure restores the exact old
ciphertext or removes a newly created entry and its empty parent directories.
## Edit sessions
Beginning a session snapshots the original encrypted bytes and decrypts an
existing entry into redacted `SecretBytes`; a missing entry starts with empty
contents so `pass edit` can create it. Finishing consumes replacement
`SecretBytes`. Unchanged content returns without encryption or commit. Before
writing, storage compares current encrypted bytes with the snapshot (or checks
that a new path is still absent), so concurrent replacement, removal, or
creation is rejected without overwriting the other writer.
Successful replacement uses the destination's current recipient policy and
records compatible editor commit intent. A commit error restores the prior
state. Rollback failure retains both the commit and repository errors.
## CLI editor boundary
Editor precedence remains TOML configuration, `VISUAL`, `EDITOR`, then `vim`,
with the program and arguments parsed without a shell. The CLI adapter converts
that value to one `EditorInvocation` for a narrowly injected `EditorHost`;
storage, TUI, GUI, iOS, and watchOS never receive executable information and
continue to use the in-process replacement API.
Before invoking the host, the CLI creates a mode-0600 plaintext file inside a
private temporary directory. Linux prefers `/dev/shm` when available; native
temporary storage is the fallback. The file is never inside the vault. A saved
file is bounded before reading. Saved, cancelled, failed, and host-error paths
all overwrite the file with zeros, truncate and sync it, remove it, and finally
drop the private directory. Returned replacement bytes remain zeroizing.