351 lines
12 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|