Files
IronStorage/apps/cli/src/editor.rs
2026-08-10 01:51:47 +00:00

351 lines
12 KiB
Rust

#![allow(
clippy::disallowed_types,
reason = "this module is the documented CLI-only configured-editor process boundary"
)]
use std::{
error::Error,
ffi::OsString,
fmt, fs,
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
path::{Path, PathBuf},
process::Command,
};
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>;
}
pub(crate) struct NativeEditorHost;
impl EditorHost for NativeEditorHost {
fn edit(&mut self, invocation: &EditorInvocation) -> Result<EditorStatus, EditorHostError> {
let status = Command::new(invocation.program())
.args(invocation.arguments())
.status()
.map_err(|_| EditorHostError(invocation.program().to_owned()))?;
if status.success() {
Ok(EditorStatus::Saved)
} else {
Ok(EditorStatus::Failed(status.code().unwrap_or(1)))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct EditorHostError(String);
impl fmt::Display for EditorHostError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"editor executable could not be launched: {}",
self.0
)
}
}
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(())
}
#[test]
fn missing_editor_executable_has_a_clear_error() {
let invocation = EditorInvocation {
program: "ironstorage-editor-does-not-exist".to_owned(),
arguments: Vec::new(),
plaintext_path: PathBuf::from("unused"),
};
let error = NativeEditorHost
.edit(&invocation)
.expect_err("the intentionally absent editor must fail");
assert_eq!(
error.to_string(),
"editor executable could not be launched: ironstorage-editor-does-not-exist"
);
}
}