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 |
|
| [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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
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,
|
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),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
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