#![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, 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; } pub(crate) struct NativeEditorHost; impl EditorHost for NativeEditorHost { fn edit(&mut self, invocation: &EditorInvocation) -> Result { 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 { 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::>(); 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 { #[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 { 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::OpenOptions::new() .create_new(true) .read(true) .write(true) .open(path) .map_err(|_| CliEditorError::TemporaryFile) } fn read_replacement(path: &Path) -> Result { 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, status: Option, invocation: Option, mode: Option, } impl EditorHost for RecordingHost { fn edit(&mut self, invocation: &EditorInvocation) -> Result { 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> { 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> { 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" ); } }