use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RouteDecision {
Route { tool: &'static str, args: Value },
Exec,
}
#[derive(Debug)]
struct ReadRoute {
program: &'static str,
tool: &'static str,
}
const READ_ROUTES: &[ReadRoute] = &[
ReadRoute {
program: "cat",
tool: "read_file",
},
ReadRoute {
program: "ls",
tool: "list_dir",
},
ReadRoute {
program: "find",
tool: "find",
},
];
const GIT_READ_ONLY_SUBCOMMANDS: &[&str] = &["status", "log", "diff"];
const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
const GLOB: &[char] = &['*', '?', '[', ']'];
#[derive(Debug, Clone)]
pub(crate) struct RouteTable {
reads: &'static [ReadRoute],
git_read_only: &'static [&'static str],
}
impl RouteTable {
#[must_use]
pub(crate) fn builtin() -> Self {
Self {
reads: READ_ROUTES,
git_read_only: GIT_READ_ONLY_SUBCOMMANDS,
}
}
#[must_use]
pub(crate) fn classify(&self, command: &str) -> RouteDecision {
let command = command.trim();
if command.is_empty() {
return RouteDecision::Exec;
}
if command.contains(SHELL_META) {
return RouteDecision::Exec;
}
let mut tokens = command.split_ascii_whitespace();
let Some(program) = tokens.next() else {
return RouteDecision::Exec;
};
if program == "git" {
return match tokens.next() {
Some(sub) if self.git_read_only.contains(&sub) => RouteDecision::Route {
tool: "git",
args: json!({ "op": sub }),
},
_ => RouteDecision::Exec,
};
}
let Some(route) = self.reads.iter().find(|r| r.program == program) else {
return RouteDecision::Exec;
};
let rest: Vec<&str> = tokens.collect();
build_read_route(route.tool, &rest)
}
}
fn build_read_route(tool: &'static str, rest: &[&str]) -> RouteDecision {
match tool {
"read_file" => match operands(rest).as_slice() {
[path] if !path.contains(GLOB) => RouteDecision::Route {
tool,
args: json!({ "path": path }),
},
_ => RouteDecision::Exec,
},
"list_dir" => match operands(rest).as_slice() {
[] => RouteDecision::Route {
tool,
args: json!({ "path": "." }),
},
[path] if !path.contains(GLOB) => RouteDecision::Route {
tool,
args: json!({ "path": path }),
},
_ => RouteDecision::Exec,
},
"find" => build_find_route(rest),
_ => RouteDecision::Exec,
}
}
fn operands<'a>(rest: &[&'a str]) -> Vec<&'a str> {
rest.iter()
.copied()
.filter(|t| !t.starts_with('-'))
.collect()
}
fn build_find_route(rest: &[&str]) -> RouteDecision {
let mut path = ".";
let mut name: Option<&str> = None;
let mut type_filter: Option<&str> = None;
let mut i = 0;
if let Some(first) = rest.first() {
if !first.starts_with('-') {
path = first;
i = 1;
}
}
while i < rest.len() {
match rest[i] {
"-name" | "-iname" => match rest.get(i + 1).copied() {
Some(v) => {
name = Some(strip_quotes(v));
i += 2;
}
None => return RouteDecision::Exec,
},
"-type" => match rest.get(i + 1).copied() {
Some(v @ ("f" | "d")) => {
type_filter = Some(v);
i += 2;
}
_ => return RouteDecision::Exec,
},
_ => return RouteDecision::Exec,
}
}
let mut args = serde_json::Map::new();
args.insert("path".into(), json!(path));
if let Some(n) = name {
args.insert("name".into(), json!(n));
}
if let Some(t) = type_filter {
args.insert("type".into(), json!(t));
}
RouteDecision::Route {
tool: "find",
args: Value::Object(args),
}
}
fn strip_quotes(token: &str) -> &str {
let bytes = token.as_bytes();
if bytes.len() >= 2
&& (bytes[0] == b'\'' || bytes[0] == b'"')
&& bytes[bytes.len() - 1] == bytes[0]
{
&token[1..token.len() - 1]
} else {
token
}
}
#[must_use]
pub(crate) fn audit_line(original: &str, decision: &RouteDecision) -> Option<String> {
match decision {
RouteDecision::Route { tool, args } => Some(format!(
"facade P4 routing: rewrote `{original}` → {tool} {args}"
)),
RouteDecision::Exec => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classify(cmd: &str) -> RouteDecision {
RouteTable::builtin().classify(cmd)
}
#[test]
fn cat_routes_to_read_file() {
assert_eq!(
classify("cat src/main.rs"),
RouteDecision::Route {
tool: "read_file",
args: json!({ "path": "src/main.rs" }),
}
);
assert_eq!(
classify("cat -n src/main.rs"),
RouteDecision::Route {
tool: "read_file",
args: json!({ "path": "src/main.rs" }),
}
);
}
#[test]
fn read_only_git_routes_to_the_git_builtin() {
for (cmd, op) in [
("git status", "status"),
("git status -s", "status"),
("git log", "log"),
("git diff", "diff"),
] {
assert_eq!(
classify(cmd),
RouteDecision::Route {
tool: "git",
args: json!({ "op": op }),
},
"{cmd}"
);
}
}
#[test]
fn state_modifying_git_gates_as_exec() {
for cmd in [
"git add a.txt",
"git add .",
"git commit -m x",
"git push",
"git checkout -b feat",
"git reset --hard",
"git stash",
"git show HEAD",
"git branch",
"git",
"git frobnicate",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn ls_routes_to_list_dir() {
assert_eq!(
classify("ls"),
RouteDecision::Route {
tool: "list_dir",
args: json!({ "path": "." }),
}
);
assert_eq!(
classify("ls -la src"),
RouteDecision::Route {
tool: "list_dir",
args: json!({ "path": "src" }),
}
);
}
#[test]
fn find_routes_with_translated_predicates() {
assert_eq!(
classify("find . -name '*.rs' -type f"),
RouteDecision::Route {
tool: "find",
args: json!({ "path": ".", "name": "*.rs", "type": "f" }),
}
);
assert_eq!(
classify("find"),
RouteDecision::Route {
tool: "find",
args: json!({ "path": "." }),
}
);
assert_eq!(classify("find . -newer ref.txt"), RouteDecision::Exec);
}
#[test]
fn compound_commands_gate_not_route() {
for cmd in [
"cat secret | grep token",
"cat a && rm -rf b",
"cat $(echo /etc/passwd)",
"ls > out.txt",
"cat a; cat b",
"cat `whoami`",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn glob_operands_gate() {
assert_eq!(classify("cat *.txt"), RouteDecision::Exec);
assert_eq!(classify("ls sub/*"), RouteDecision::Exec);
assert_eq!(classify("cat a.txt b.txt"), RouteDecision::Exec);
}
#[test]
fn unknown_programs_gate() {
for cmd in ["rm -rf /", "echo hi", "grep foo bar", "bash script.sh", ""] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd:?}");
}
}
#[test]
fn audit_line_logs_every_rewrite_and_only_rewrites() {
let routed = classify("cat foo.txt");
let line = audit_line("cat foo.txt", &routed).expect("a route is logged");
assert!(line.contains("cat foo.txt"), "{line}");
assert!(line.contains("read_file"), "{line}");
assert_eq!(audit_line("git add .", &classify("git add .")), None);
}
}