Implement insert and edit sessions (#7)
This commit is contained in:
@@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
@@ -36,6 +36,8 @@ the rollback/commit contract are documented in
|
||||
[`docs/recipient-policies.md`](docs/recipient-policies.md).
|
||||
Typed list/show/find/decrypted-grep models and secret presentation selection are
|
||||
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
|
||||
|
||||
|
||||
@@ -12,6 +12,6 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ironstorage.workspace = true
|
||||
tempfile = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
308
apps/cli/src/editor.rs
Normal file
308
apps/cli/src/editor.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ use ironstorage::{
|
||||
config::Config,
|
||||
};
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod editor;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
52
docs/write-domains.md
Normal file
52
docs/write-domains.md
Normal 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.
|
||||
Reference in New Issue
Block a user