use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use tokio::sync::RwLock;
use super::command_risk::split_by_operators;
const FILE_MODIFYING_COMMANDS: &[&str] = &[
"rm", "shred", "mv", "cp", "chmod", "chown", "chattr", "dd", "mkfs", "ln",
];
const CONDITIONAL_MODIFYING: &[(&str, &str)] = &[
("sed", "-i"),
("tee", ""), ];
const PATH_RECORDING_COMMANDS: &[&str] = &[
"ls", "cat", "head", "tail", "less", "more", "file", "stat", "wc", "du", "find", "tree", "fd",
"grep", "rg", "diff", "bat", "exa", "eza", "readlink", "test", "mkdir", "touch",
];
pub struct VerificationWarning {
pub unverified_paths: Vec<String>,
pub message: String,
}
pub struct VerificationTracker {
seen_paths: RwLock<HashMap<String, HashSet<PathBuf>>>,
}
impl VerificationTracker {
pub fn new() -> Self {
Self {
seen_paths: RwLock::new(HashMap::new()),
}
}
pub async fn record_seen_path(&self, session_id: &str, path: &str) {
let expanded = shellexpand::tilde(path).to_string();
let pb = PathBuf::from(&expanded);
let canonical = if pb.is_absolute() {
pb
} else {
pb
};
let mut map = self.seen_paths.write().await;
let set = map.entry(session_id.to_string()).or_default();
set.insert(canonical.clone());
if let Some(parent) = canonical.parent() {
set.insert(parent.to_path_buf());
}
}
pub async fn record_from_command(&self, session_id: &str, command: &str) {
let segments = split_by_operators(command);
for (segment, _) in &segments {
let trimmed = segment.trim();
if trimmed.is_empty() {
continue;
}
if let Some((base_cmd, args)) = parse_command_and_args(trimmed) {
let cmd_name = strip_sudo(&base_cmd);
if PATH_RECORDING_COMMANDS.contains(&cmd_name.as_str()) {
let paths = extract_path_args(&args);
for p in paths {
self.record_seen_path(session_id, &p).await;
}
}
if cmd_name == "cd" {
let paths = extract_path_args(&args);
for p in paths {
self.record_seen_path(session_id, &p).await;
}
}
}
}
}
pub async fn check_modifying_command(
&self,
session_id: &str,
command: &str,
) -> Option<VerificationWarning> {
let segments = split_by_operators(command);
let mut unverified = Vec::new();
for (segment, _) in &segments {
let trimmed = segment.trim();
if trimmed.is_empty() {
continue;
}
if let Some((base_cmd, args)) = parse_command_and_args(trimmed) {
let cmd_name = strip_sudo(&base_cmd);
let is_modifying = FILE_MODIFYING_COMMANDS.contains(&cmd_name.as_str())
|| CONDITIONAL_MODIFYING.iter().any(|(cmd, flag)| {
cmd_name == *cmd && (flag.is_empty() || args.iter().any(|a| a == flag))
});
if !is_modifying {
continue;
}
let paths = extract_path_args(&args);
if paths.is_empty() {
continue;
}
let map = self.seen_paths.read().await;
let seen = map.get(session_id);
for p in &paths {
let expanded = shellexpand::tilde(p).to_string();
let pb = PathBuf::from(&expanded);
let is_verified = if let Some(seen_set) = seen {
seen_set.contains(&pb) || ancestors_seen(&pb, seen_set)
} else {
false
};
if !is_verified {
unverified.push(p.clone());
}
}
}
}
if unverified.is_empty() {
None
} else {
Some(VerificationWarning {
message: format!(
"The following paths have not been verified to exist in this session: {}. \
Use 'ls' or 'stat' to verify before modifying.",
unverified.join(", ")
),
unverified_paths: unverified,
})
}
}
#[allow(dead_code)]
pub async fn clear_session(&self, session_id: &str) {
let mut map = self.seen_paths.write().await;
map.remove(session_id);
}
}
fn ancestors_seen(path: &std::path::Path, seen: &HashSet<PathBuf>) -> bool {
let mut current = path.parent();
while let Some(ancestor) = current {
if seen.contains(ancestor) {
return true;
}
current = ancestor.parent();
}
false
}
fn strip_sudo(cmd: &str) -> String {
if cmd == "sudo" {
return cmd.to_string();
}
cmd.to_string()
}
fn parse_command_and_args(segment: &str) -> Option<(String, Vec<String>)> {
let tokens = match shell_words::split(segment) {
Ok(t) => t,
Err(_) => return None,
};
if tokens.is_empty() {
return None;
}
let mut idx = 0;
if tokens[idx] == "sudo" {
idx += 1;
while idx < tokens.len() {
if tokens[idx].starts_with('-') {
idx += 1;
if idx < tokens.len() && !tokens[idx].starts_with('-') {
let prev = &tokens[idx - 1];
if prev == "-u" || prev == "-g" || prev == "-C" {
idx += 1;
}
}
} else {
break;
}
}
}
if idx >= tokens.len() {
return None;
}
let cmd = tokens[idx].clone();
let args = tokens[idx + 1..].to_vec();
Some((cmd, args))
}
fn extract_path_args(args: &[String]) -> Vec<String> {
let mut paths = Vec::new();
let mut skip_next = false;
for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}
if arg.starts_with('-') {
if i + 1 < args.len() {
let next = &args[i + 1];
if (arg.len() == 2 || arg.starts_with("--")) && !next.starts_with('-') {
let value_flags = [
"-o",
"-f",
"-t",
"-m",
"-T",
"--target-directory",
"--output",
"--suffix",
"--backup",
];
if value_flags.contains(&arg.as_str()) {
skip_next = true;
}
}
}
continue;
}
if arg.starts_with('$') || arg.contains("$(") {
continue;
}
if is_chmod_mode_arg(arg) {
continue;
}
paths.push(arg.clone());
}
paths
}
fn is_chmod_mode_arg(arg: &str) -> bool {
let bytes = arg.as_bytes();
if bytes.is_empty() {
return false;
}
if bytes.len() >= 3
&& bytes.len() <= 4
&& bytes.iter().all(|b| b.is_ascii_digit() && *b <= b'7')
{
return true;
}
for part in arg.split(',') {
let part_bytes = part.as_bytes();
if part_bytes.is_empty() {
return false;
}
let op_pos = part_bytes
.iter()
.position(|b| *b == b'+' || *b == b'-' || *b == b'=');
match op_pos {
Some(pos) => {
if !part_bytes[..pos]
.iter()
.all(|b| matches!(b, b'u' | b'g' | b'o' | b'a'))
{
return false;
}
let after = &part_bytes[pos + 1..];
if after.is_empty()
|| !after.iter().all(|b| {
matches!(
b,
b'r' | b'w' | b'x' | b'X' | b's' | b't' | b'u' | b'g' | b'o'
)
})
{
return false;
}
}
None => return false,
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_record_and_verify_path() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "rm /tmp/foo.txt")
.await;
assert!(warning.is_some());
assert!(warning
.unwrap()
.unverified_paths
.contains(&"/tmp/foo.txt".to_string()));
tracker.record_seen_path(sid, "/tmp/foo.txt").await;
let warning = tracker
.check_modifying_command(sid, "rm /tmp/foo.txt")
.await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_parent_directory_verification() {
let tracker = VerificationTracker::new();
let sid = "test-session";
tracker.record_from_command(sid, "ls /foo").await;
let warning = tracker.check_modifying_command(sid, "rm /foo/bar").await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_unverified_path_returns_warning() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "rm -rf /important/data")
.await;
assert!(warning.is_some());
let w = warning.unwrap();
assert!(w.unverified_paths.contains(&"/important/data".to_string()));
assert!(w.message.contains("not been verified"));
}
#[tokio::test]
async fn test_compound_commands() {
let tracker = VerificationTracker::new();
let sid = "test-session";
tracker.record_from_command(sid, "cd /foo && ls /bar").await;
let warning = tracker
.check_modifying_command(sid, "rm /foo/file.txt")
.await;
assert!(warning.is_none());
let warning = tracker
.check_modifying_command(sid, "rm /bar/file.txt")
.await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_flag_filtering() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker.check_modifying_command(sid, "rm -rf /dir").await;
assert!(warning.is_some());
let w = warning.unwrap();
assert_eq!(w.unverified_paths, vec!["/dir".to_string()]);
}
#[tokio::test]
async fn test_session_isolation() {
let tracker = VerificationTracker::new();
tracker.record_seen_path("session-a", "/tmp/file").await;
let warning = tracker
.check_modifying_command("session-b", "rm /tmp/file")
.await;
assert!(warning.is_some());
let warning = tracker
.check_modifying_command("session-a", "rm /tmp/file")
.await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_sudo_handling() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "sudo rm /etc/foo")
.await;
assert!(warning.is_some());
let w = warning.unwrap();
assert!(w.unverified_paths.contains(&"/etc/foo".to_string()));
}
#[tokio::test]
async fn test_tilde_expansion() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
let expanded_path = format!("{}/file.txt", home);
tracker.record_seen_path(sid, &expanded_path).await;
let warning = tracker.check_modifying_command(sid, "rm ~/file.txt").await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_read_only_commands_not_flagged() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "cat /etc/passwd")
.await;
assert!(warning.is_none());
let warning = tracker.check_modifying_command(sid, "ls /var/log").await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_sed_inplace_flagged() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "sed -i 's/foo/bar/' /tmp/config.txt")
.await;
assert!(warning.is_some());
let warning = tracker
.check_modifying_command(sid, "sed 's/foo/bar/' /tmp/config.txt")
.await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_tee_flagged() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "tee /tmp/output.txt")
.await;
assert!(warning.is_some());
}
#[tokio::test]
async fn test_clear_session() {
let tracker = VerificationTracker::new();
let sid = "test-session";
tracker.record_seen_path(sid, "/tmp/file.txt").await;
let warning = tracker
.check_modifying_command(sid, "rm /tmp/file.txt")
.await;
assert!(warning.is_none());
tracker.clear_session(sid).await;
let warning = tracker
.check_modifying_command(sid, "rm /tmp/file.txt")
.await;
assert!(warning.is_some());
}
#[tokio::test]
async fn test_record_from_command_ls() {
let tracker = VerificationTracker::new();
let sid = "test-session";
tracker.record_from_command(sid, "ls /var/log").await;
let warning = tracker
.check_modifying_command(sid, "rm /var/log/syslog")
.await;
assert!(warning.is_none());
}
#[tokio::test]
async fn test_mv_flagged() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "mv /tmp/a /tmp/b")
.await;
assert!(warning.is_some());
let w = warning.unwrap();
assert!(w.unverified_paths.contains(&"/tmp/a".to_string()));
assert!(w.unverified_paths.contains(&"/tmp/b".to_string()));
}
#[test]
fn test_parse_command_and_args_basic() {
let (cmd, args) = parse_command_and_args("rm -rf /foo").unwrap();
assert_eq!(cmd, "rm");
assert_eq!(args, vec!["-rf", "/foo"]);
}
#[test]
fn test_parse_command_and_args_sudo() {
let (cmd, args) = parse_command_and_args("sudo rm -f /etc/file").unwrap();
assert_eq!(cmd, "rm");
assert_eq!(args, vec!["-f", "/etc/file"]);
}
#[test]
fn test_extract_path_args_filters_flags() {
let args: Vec<String> = vec!["-rf".into(), "/dir".into(), "-v".into()];
let paths = extract_path_args(&args);
assert_eq!(paths, vec!["/dir".to_string()]);
}
#[test]
fn test_extract_path_args_skips_shell_vars() {
let args: Vec<String> = vec!["$HOME/file".into(), "/real/path".into()];
let paths = extract_path_args(&args);
assert_eq!(paths, vec!["/real/path".to_string()]);
}
#[tokio::test]
async fn test_mkdir_not_blocked() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "mkdir -p /tmp/new_project")
.await;
assert!(
warning.is_none(),
"mkdir should not be blocked by verification"
);
let warning = tracker
.check_modifying_command(sid, "mkdir /tmp/another_dir")
.await;
assert!(warning.is_none(), "mkdir without -p should not be blocked");
}
#[tokio::test]
async fn test_touch_not_blocked() {
let tracker = VerificationTracker::new();
let sid = "test-session";
let warning = tracker
.check_modifying_command(sid, "touch /tmp/new_file.txt")
.await;
assert!(
warning.is_none(),
"touch should not be blocked by verification"
);
}
#[tokio::test]
async fn test_mkdir_records_paths() {
let tracker = VerificationTracker::new();
let sid = "test-session";
tracker
.record_from_command(sid, "mkdir -p /tmp/new_project")
.await;
let warning = tracker
.check_modifying_command(sid, "cp /verified/file /tmp/new_project/file")
.await;
let w = warning.unwrap();
assert!(
!w.unverified_paths
.contains(&"/tmp/new_project/file".to_string()),
"path under mkdir'd dir should be verified"
);
}
#[tokio::test]
async fn test_chmod_mode_args_not_treated_as_paths() {
let tracker = VerificationTracker::new();
let sid = "test-session";
tracker.record_seen_path(sid, "/tmp/script.sh").await;
let warning = tracker
.check_modifying_command(sid, "chmod +x /tmp/script.sh")
.await;
assert!(warning.is_none(), "chmod +x on verified path should pass");
let warning = tracker
.check_modifying_command(sid, "chmod u+x /tmp/script.sh")
.await;
assert!(warning.is_none(), "chmod u+x on verified path should pass");
let warning = tracker
.check_modifying_command(sid, "chmod 755 /tmp/script.sh")
.await;
assert!(warning.is_none(), "chmod 755 on verified path should pass");
let warning = tracker
.check_modifying_command(sid, "chmod go-rw /tmp/script.sh")
.await;
assert!(
warning.is_none(),
"chmod go-rw on verified path should pass"
);
let warning = tracker
.check_modifying_command(sid, "chmod a=rwx /tmp/script.sh")
.await;
assert!(
warning.is_none(),
"chmod a=rwx on verified path should pass"
);
let warning = tracker
.check_modifying_command(sid, "chmod +x /srv/unknown.sh")
.await;
assert!(warning.is_some());
let w = warning.unwrap();
assert_eq!(w.unverified_paths, vec!["/srv/unknown.sh".to_string()]);
}
#[test]
fn test_is_chmod_mode_arg() {
assert!(is_chmod_mode_arg("+x"));
assert!(is_chmod_mode_arg("u+x"));
assert!(is_chmod_mode_arg("go-rw"));
assert!(is_chmod_mode_arg("a=rwx"));
assert!(is_chmod_mode_arg("u+s,g-w"));
assert!(is_chmod_mode_arg("+X"));
assert!(is_chmod_mode_arg("755"));
assert!(is_chmod_mode_arg("0644"));
assert!(is_chmod_mode_arg("777"));
assert!(!is_chmod_mode_arg("/tmp/file"));
assert!(!is_chmod_mode_arg("hello"));
assert!(!is_chmod_mode_arg(""));
assert!(!is_chmod_mode_arg("999")); }
}