use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use crate::config::BLOCKED_DESTINATION_PREFIXES;
use crate::rules::{ActionKind, CommandInvocation, RuleConfig};
pub(crate) fn exit_code_from_status(status: ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
return 128 + sig;
}
}
1
}
#[derive(Debug, Clone)]
pub enum ActionOutcome {
PassedThrough { exit_code: i32 },
Trashed { exit_code: i32, message: String },
ExecutedAfterStash { exit_code: i32, message: String },
LoggedOnly { exit_code: i32, message: String },
MovedTo { exit_code: i32, message: String },
Blocked { message: String },
Failed { message: String },
}
impl ActionOutcome {
pub fn exit_code(&self) -> i32 {
match self {
Self::PassedThrough { exit_code }
| Self::Trashed { exit_code, .. }
| Self::ExecutedAfterStash { exit_code, .. }
| Self::LoggedOnly { exit_code, .. }
| Self::MovedTo { exit_code, .. } => *exit_code,
Self::Blocked { .. } | Self::Failed { .. } => 1,
}
}
pub fn message(&self) -> &str {
match self {
Self::PassedThrough { .. } => "omamori allowed the command to run unchanged",
Self::Trashed { message, .. }
| Self::ExecutedAfterStash { message, .. }
| Self::LoggedOnly { message, .. }
| Self::MovedTo { message, .. }
| Self::Blocked { message }
| Self::Failed { message } => message,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::PassedThrough { .. } => "passthrough",
Self::Trashed { .. } => "trash",
Self::ExecutedAfterStash { .. } => "stash-then-exec",
Self::LoggedOnly { .. } => "log-only",
Self::MovedTo { .. } => "move-to",
Self::Blocked { .. } => "block",
Self::Failed { .. } => "failed",
}
}
}
pub trait ExecOps {
fn passthrough(&mut self, invocation: &CommandInvocation) -> io::Result<i32>;
fn move_to_trash(&mut self, targets: &[String]) -> Result<(), String>;
fn move_to_dir(&mut self, targets: &[String], destination: &Path) -> Result<usize, String>;
fn git_stash(&mut self) -> Result<(), String>;
}
pub struct SystemOps {
real_program: PathBuf,
detector_env_keys: Vec<String>,
}
impl SystemOps {
pub fn new(real_program: PathBuf, detector_env_keys: Vec<String>) -> Self {
Self {
real_program,
detector_env_keys,
}
}
}
impl ExecOps for SystemOps {
fn passthrough(&mut self, invocation: &CommandInvocation) -> io::Result<i32> {
let status = Command::new(&self.real_program)
.args(&invocation.args)
.status()?;
Ok(exit_code_from_status(status))
}
fn move_to_trash(&mut self, targets: &[String]) -> Result<(), String> {
let paths = targets.iter().map(PathBuf::from).collect::<Vec<_>>();
trash::delete_all(paths).map_err(|error| error.to_string())
}
fn move_to_dir(&mut self, targets: &[String], destination: &Path) -> Result<usize, String> {
if !destination.exists() {
return Err(format!(
"move-to directory `{}` does not exist; refusing to run the original command",
destination.display()
));
}
if !destination.is_dir() {
return Err(format!(
"move-to path `{}` is not a directory",
destination.display()
));
}
let meta = std::fs::symlink_metadata(destination).map_err(|e| e.to_string())?;
if meta.file_type().is_symlink() {
return Err(format!(
"move-to directory `{}` is a symlink; refusing for security",
destination.display()
));
}
let canonical = if destination.exists() || destination.is_symlink() {
destination.canonicalize().map_err(|e| {
format!(
"move-to directory `{}` cannot be verified: {e}",
destination.display()
)
})?
} else {
let parent = destination.parent().ok_or_else(|| {
format!(
"move-to directory `{}` has no parent directory",
destination.display()
)
})?;
let name = destination.file_name().ok_or_else(|| {
format!(
"move-to directory `{}` has no file name component",
destination.display()
)
})?;
let canonical_parent = parent.canonicalize().map_err(|e| {
format!(
"move-to directory parent `{}` cannot be verified: {e}",
parent.display()
)
})?;
canonical_parent.join(name)
};
let canonical_str = canonical.to_string_lossy();
for prefix in BLOCKED_DESTINATION_PREFIXES {
if canonical_str.starts_with(prefix) {
return Err(format!(
"move-to directory `{}` resolves to blocked system path `{canonical_str}`",
destination.display()
));
}
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let sub_dir = destination.join(format!("{timestamp}"));
std::fs::create_dir_all(&sub_dir)
.map_err(|e| format!("failed to create subdirectory `{}`: {e}", sub_dir.display()))?;
let mut moved = 0usize;
let mut used_names = std::collections::HashSet::new();
for target in targets {
let src = PathBuf::from(target);
let base_name = src
.file_name()
.unwrap_or(src.as_os_str())
.to_string_lossy()
.into_owned();
let unique_name = if used_names.contains(&base_name) {
let mut counter = 2u32;
loop {
let candidate = format!("{base_name}_{counter}");
if !used_names.contains(&candidate) {
break candidate;
}
counter += 1;
}
} else {
base_name.clone()
};
used_names.insert(unique_name.clone());
let dest = sub_dir.join(&unique_name);
std::fs::rename(&src, &dest).map_err(|e| {
if e.raw_os_error() == Some(18) {
format!(
"cannot move `{target}` to `{}`: cross-device move is not supported (use a destination on the same volume)",
destination.display()
)
} else {
format!("failed to move `{target}` to `{}`: {e}", dest.display())
}
})?;
moved += 1;
}
Ok(moved)
}
fn git_stash(&mut self) -> Result<(), String> {
let mut cmd = Command::new("git");
cmd.arg("stash").arg("push").arg("--include-untracked");
for key in &self.detector_env_keys {
cmd.env_remove(key);
}
let status = cmd.status().map_err(|error| error.to_string())?;
if status.success() {
Ok(())
} else {
Err(format!("git stash exited with status {:?}", status.code()))
}
}
}
pub struct ActionExecutor<T: ExecOps> {
ops: T,
}
impl<T: ExecOps> ActionExecutor<T> {
pub fn new(ops: T) -> Self {
Self { ops }
}
pub fn exec_passthrough(
&mut self,
invocation: &CommandInvocation,
) -> Result<ActionOutcome, io::Error> {
let exit_code = self.ops.passthrough(invocation)?;
Ok(ActionOutcome::PassedThrough { exit_code })
}
pub fn execute(
&mut self,
invocation: &CommandInvocation,
rule: &RuleConfig,
) -> Result<ActionOutcome, io::Error> {
let message = rule
.message
.clone()
.unwrap_or_else(|| format!("omamori applied `{}`", rule.name));
let outcome = match rule.action {
ActionKind::Trash => {
let targets: Vec<String> = invocation
.target_args()
.into_iter()
.map(String::from)
.collect();
if targets.is_empty() {
ActionOutcome::Failed {
message: "omamori could not identify any rm targets to move to Trash"
.to_string(),
}
} else {
match self.ops.move_to_trash(&targets) {
Ok(()) => ActionOutcome::Trashed {
exit_code: 0,
message,
},
Err(error) => ActionOutcome::Failed {
message: format!(
"omamori failed to move the targets to Trash and refused to run rm: {error}"
),
},
}
}
}
ActionKind::StashThenExec => match self.ops.git_stash() {
Ok(()) => {
let exit_code = self.ops.passthrough(invocation)?;
ActionOutcome::ExecutedAfterStash { exit_code, message }
}
Err(error) => ActionOutcome::Failed {
message: format!(
"omamori could not create a git stash before execution: {error}"
),
},
},
ActionKind::MoveTo => {
let destination = match &rule.destination {
Some(dest) => PathBuf::from(dest),
None => {
return Ok(ActionOutcome::Failed {
message: "omamori move-to rule has no destination configured"
.to_string(),
});
}
};
let targets: Vec<String> = invocation
.target_args()
.into_iter()
.map(String::from)
.collect();
if targets.is_empty() {
ActionOutcome::Failed {
message: "omamori could not identify any targets to move".to_string(),
}
} else {
match self.ops.move_to_dir(&targets, &destination) {
Ok(count) => ActionOutcome::MovedTo {
exit_code: 0,
message: format!(
"{message} ({count} target(s) moved to {})",
destination.display()
),
},
Err(error) => ActionOutcome::Failed {
message: format!(
"omamori failed to move targets to `{}` and refused to run the original command: {error}",
destination.display()
),
},
}
}
}
ActionKind::Block => ActionOutcome::Blocked { message },
ActionKind::LogOnly => {
let exit_code = self.ops.passthrough(invocation)?;
ActionOutcome::LoggedOnly { exit_code, message }
}
};
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::RuleConfig;
#[derive(Default)]
struct FakeOps {
passthrough_calls: usize,
passthrough_status: i32,
trash_error: Option<String>,
move_to_dir_error: Option<String>,
stash_error: Option<String>,
last_trash_targets: Vec<String>,
last_move_targets: Vec<String>,
last_move_destination: Option<PathBuf>,
}
impl ExecOps for FakeOps {
fn passthrough(&mut self, _invocation: &CommandInvocation) -> io::Result<i32> {
self.passthrough_calls += 1;
Ok(self.passthrough_status)
}
fn move_to_trash(&mut self, targets: &[String]) -> Result<(), String> {
self.last_trash_targets = targets.to_vec();
match &self.trash_error {
Some(error) => Err(error.clone()),
None => Ok(()),
}
}
fn move_to_dir(&mut self, targets: &[String], destination: &Path) -> Result<usize, String> {
self.last_move_targets = targets.to_vec();
self.last_move_destination = Some(destination.to_path_buf());
match &self.move_to_dir_error {
Some(error) => Err(error.clone()),
None => Ok(targets.len()),
}
}
fn git_stash(&mut self) -> Result<(), String> {
match &self.stash_error {
Some(error) => Err(error.clone()),
None => Ok(()),
}
}
}
fn rule(action: ActionKind, command: &str) -> RuleConfig {
RuleConfig::new(
"test",
command,
action,
Vec::new(),
Vec::new(),
Some("message".to_string()),
)
}
#[test]
fn trash_failure_does_not_fall_back_to_passthrough() {
let mut executor = ActionExecutor::new(FakeOps {
trash_error: Some("no trash".to_string()),
..Default::default()
});
let invocation = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "danger".to_string()],
);
let outcome = executor
.execute(&invocation, &rule(ActionKind::Trash, "rm"))
.unwrap();
assert!(matches!(outcome, ActionOutcome::Failed { .. }));
}
#[test]
fn stash_then_exec_runs_both_steps() {
let mut executor = ActionExecutor::new(FakeOps::default());
let invocation = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let outcome = executor
.execute(&invocation, &rule(ActionKind::StashThenExec, "git"))
.unwrap();
assert!(matches!(outcome, ActionOutcome::ExecutedAfterStash { .. }));
}
#[test]
fn trash_with_double_dash_captures_dash_prefixed_targets() {
let mut executor = ActionExecutor::new(FakeOps::default());
let invocation = CommandInvocation::new(
"rm".to_string(),
vec![
"-rf".to_string(),
"--".to_string(),
"-dangerous.txt".to_string(),
],
);
let outcome = executor
.execute(&invocation, &rule(ActionKind::Trash, "rm"))
.unwrap();
assert!(matches!(outcome, ActionOutcome::Trashed { .. }));
assert_eq!(executor.ops.last_trash_targets, vec!["-dangerous.txt"]);
}
#[test]
fn move_to_succeeds_with_destination() {
let mut executor = ActionExecutor::new(FakeOps::default());
let invocation = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "target/".to_string()],
);
let rule = RuleConfig::new(
"rm-move",
"rm",
ActionKind::MoveTo,
Vec::new(),
Vec::new(),
Some("moved".to_string()),
)
.with_destination("/tmp/backup".to_string());
let outcome = executor.execute(&invocation, &rule).unwrap();
assert!(matches!(outcome, ActionOutcome::MovedTo { .. }));
assert_eq!(executor.ops.last_move_targets, vec!["target/"]);
}
#[test]
fn move_to_without_destination_fails() {
let mut executor = ActionExecutor::new(FakeOps::default());
let invocation = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "target/".to_string()],
);
let rule = RuleConfig::new(
"rm-move",
"rm",
ActionKind::MoveTo,
Vec::new(),
Vec::new(),
None,
);
let outcome = executor.execute(&invocation, &rule).unwrap();
assert!(matches!(outcome, ActionOutcome::Failed { .. }));
}
#[test]
fn move_to_failure_does_not_fall_back_to_passthrough() {
let mut executor = ActionExecutor::new(FakeOps {
move_to_dir_error: Some("disk full".to_string()),
..Default::default()
});
let invocation = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "target/".to_string()],
);
let rule = RuleConfig::new(
"rm-move",
"rm",
ActionKind::MoveTo,
Vec::new(),
Vec::new(),
None,
)
.with_destination("/tmp/backup".to_string());
let outcome = executor.execute(&invocation, &rule).unwrap();
assert!(matches!(outcome, ActionOutcome::Failed { .. }));
assert_eq!(executor.ops.passthrough_calls, 0);
}
#[test]
fn trash_with_only_double_dash_fails() {
let mut executor = ActionExecutor::new(FakeOps::default());
let invocation =
CommandInvocation::new("rm".to_string(), vec!["-rf".to_string(), "--".to_string()]);
let outcome = executor
.execute(&invocation, &rule(ActionKind::Trash, "rm"))
.unwrap();
assert!(matches!(outcome, ActionOutcome::Failed { .. }));
}
fn make_temp_dir(suffix: &str) -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let dir = PathBuf::from(home).join(format!(
".omamori-test/move-{}-{}",
suffix,
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn real_ops() -> SystemOps {
SystemOps::new(PathBuf::from("/usr/bin/true"), vec![])
}
#[test]
fn move_to_dir_happy_path() {
let root = make_temp_dir("g04-happy");
let dest = root.join("dest");
std::fs::create_dir(&dest).unwrap();
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &dest);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_nonexistent_destination() {
let root = make_temp_dir("g04-noexist");
let dest = root.join("nonexistent");
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &dest);
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_non_directory_destination() {
let root = make_temp_dir("g04-nondir");
let dest = root.join("afile");
std::fs::write(&dest, "not a dir").unwrap();
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &dest);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a directory"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_symlink_destination_rejected() {
let root = make_temp_dir("g04-symlink");
let real_dest = root.join("real_dest");
std::fs::create_dir(&real_dest).unwrap();
let link_dest = root.join("link_dest");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_dest, &link_dest).unwrap();
#[cfg(not(unix))]
{
let _ = std::fs::remove_dir_all(&root);
return; }
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &link_dest);
assert!(result.is_err());
assert!(result.unwrap_err().contains("symlink"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_blocked_prefix() {
let root = make_temp_dir("g04-blocked");
let dest = root.join("dest");
std::fs::create_dir(&dest).unwrap();
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &dest);
assert!(result.is_ok(), "non-blocked path should succeed");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_dedup_basenames() {
let root = make_temp_dir("g04-dedup");
let dest = root.join("dest");
std::fs::create_dir(&dest).unwrap();
let sub1 = root.join("sub1");
let sub2 = root.join("sub2");
std::fs::create_dir(&sub1).unwrap();
std::fs::create_dir(&sub2).unwrap();
std::fs::write(sub1.join("file.txt"), "data1").unwrap();
std::fs::write(sub2.join("file.txt"), "data2").unwrap();
let mut ops = real_ops();
let targets = vec![
sub1.join("file.txt").to_string_lossy().into_owned(),
sub2.join("file.txt").to_string_lossy().into_owned(),
];
let result = ops.move_to_dir(&targets, &dest);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 2);
let entries: Vec<_> = std::fs::read_dir(&dest)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 1); let sub_entries: Vec<String> = std::fs::read_dir(entries[0].path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(sub_entries.contains(&"file.txt".to_string()));
assert!(sub_entries.contains(&"file.txt_2".to_string()));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
#[ignore] fn move_to_dir_cross_device_error() {
}
#[test]
fn move_to_dir_canonicalize_fail_close_blocked() {
let root = make_temp_dir("g04-canon-fc");
let dest = root.join("safe_dest");
std::fs::create_dir(&dest).unwrap();
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &dest);
assert!(result.is_ok());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_symlink_to_blocked_prefix_caught() {
let root = make_temp_dir("g04-symlink-blocked");
let real_dir = root.join("real_safe");
std::fs::create_dir(&real_dir).unwrap();
let link_dir = root.join("sneaky_link");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &link_dir);
assert!(result.is_err());
assert!(result.unwrap_err().contains("symlink"));
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_blocked_system_path_rejected() {
let root = make_temp_dir("g04-sysblocked");
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], Path::new("/usr"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("blocked"),
"expected blocked prefix error, got: {}",
err
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn move_to_dir_relative_path_bypass_prevented() {
let root = make_temp_dir("g04-relpath");
let dest = root.join("dest");
std::fs::create_dir(&dest).unwrap();
let src = root.join("file.txt");
std::fs::write(&src, "data").unwrap();
let mut ops = real_ops();
let result = ops.move_to_dir(&[src.to_string_lossy().into_owned()], &dest);
assert!(result.is_ok());
let _ = std::fs::remove_dir_all(&root);
}
}