Implement insert and edit sessions (#7)
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user