use serde_json::{json, Value};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RouteDecision {
Route { tool: &'static str, args: Value },
Exec,
}
#[derive(Debug)]
struct ShellRoute {
program: &'static str,
tool: &'static str,
}
const SHELL_ROUTES: &[ShellRoute] = &[
ShellRoute {
program: "cat",
tool: "read_file",
},
ShellRoute {
program: "ls",
tool: "list_dir",
},
ShellRoute {
program: "find",
tool: "find",
},
ShellRoute {
program: "rm",
tool: "delete_file",
},
ShellRoute {
program: "unlink",
tool: "delete_file",
},
];
const CARGO_BUILD_SUBCOMMANDS: &[&str] = &["build", "check", "test", "clippy"];
pub(crate) fn is_build_tool_program(program: &str) -> bool {
program == "cargo" || program == "just"
}
pub(crate) fn is_build_work(program: &str, argv: &[String]) -> bool {
let name = std::path::Path::new(program)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(program);
let rest = argv.get(1..).unwrap_or_default();
if rest
.iter()
.any(|arg| matches!(arg.as_str(), "--version" | "-V" | "--help" | "-h"))
{
return false;
}
match name {
"cargo" => {
let mut words = rest.iter().map(String::as_str);
while let Some(word) = words.next() {
match word {
word if is_toolchain_selector(word) => {}
"-q" | "--quiet" | "-v" | "-vv" | "--verbose" | "--offline" | "--locked"
| "--frozen" => {}
"--color" | "--config" | "-C" | "-Z" => {
if words.next().is_none() {
return false;
}
}
word if word.starts_with("--color=") || word.starts_with("--config=") => {}
sub => return CARGO_BUILD_SUBCOMMANDS.contains(&sub),
}
}
false
}
"just" => !rest.iter().any(|arg| {
matches!(
arg.as_str(),
"--list"
| "-l"
| "--summary"
| "--show"
| "--dump"
| "--evaluate"
| "--fmt"
| "--completion"
| "--completions"
)
}),
"make" => true,
_ => false,
}
}
const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
const GLOB: &[char] = &['*', '?', '[', ']'];
const BUILD_UNSAFE: &[char] = &['"', '\'', '\\', '~', '*', '?', '[', ']'];
#[derive(Debug, Clone)]
pub(crate) struct RouteTable {
shell_routes: &'static [ShellRoute],
}
impl RouteTable {
#[must_use]
pub(crate) fn builtin() -> Self {
Self {
shell_routes: SHELL_ROUTES,
}
}
#[must_use]
pub(crate) fn classify_call(
&self,
call: &Value,
workspace: &Path,
read_scope: &crate::caveats::Scope<String>,
) -> RouteDecision {
let decision = self.classify(
call.get("command").and_then(Value::as_str).unwrap_or(""),
workspace,
read_scope,
);
let RouteDecision::Route { tool, .. } = &decision else {
return decision;
};
let extra_keys_allowed: &[&str] = if *tool == "build_exec" {
&["command", "cwd"]
} else {
&["command"]
};
let keys_ok = call.as_object().is_some_and(|obj| {
obj.keys()
.all(|key| extra_keys_allowed.contains(&key.as_str()))
});
if !keys_ok {
return RouteDecision::Exec;
}
let already_has_cwd =
matches!(&decision, RouteDecision::Route { args, .. } if args.get("cwd").is_some());
if already_has_cwd {
return if call.get("cwd").is_some() {
RouteDecision::Exec
} else {
decision
};
}
let Some(cwd_field) = call.get("cwd").and_then(Value::as_str) else {
return decision;
};
match resolve_workspace_relative_dir(cwd_field, workspace, read_scope) {
Some(cwd) => attach_cwd(decision, cwd),
None => RouteDecision::Exec,
}
}
#[must_use]
pub(crate) fn classify(
&self,
command: &str,
workspace: &Path,
read_scope: &crate::caveats::Scope<String>,
) -> RouteDecision {
let trimmed = command.trim();
if trimmed.is_empty() {
return RouteDecision::Exec;
}
let (effective, cwd) = fold_leading_cd(trimmed, workspace, read_scope);
let (effective, echo_dropped) = strip_trailing_exit_echo(effective);
let decision = self.classify_stripped(effective);
let decision = if echo_dropped {
mark_echo_dropped(decision)
} else {
decision
};
match cwd {
Some(cwd) => attach_cwd(decision, cwd),
None => decision,
}
}
fn classify_stripped(&self, command: &str) -> RouteDecision {
if let Some((timeout_secs, remainder)) = strip_leading_timeout(command) {
return match self.classify_build_shapes(remainder) {
RouteDecision::Route {
tool: "build_exec",
args,
} => mark_timeout_secs(
RouteDecision::Route {
tool: "build_exec",
args,
},
timeout_secs,
),
_ => RouteDecision::Exec,
};
}
self.classify_build_shapes(command)
}
fn classify_build_shapes(&self, command: &str) -> RouteDecision {
if command.contains('|') {
if let route @ RouteDecision::Route { .. } = build_piped_to_trim_route(command) {
return route;
}
}
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 is_build_tool_program(program) {
let rest: Vec<&str> = tokens.collect();
return build_lane_route(program, &rest);
}
let Some(route) = self.shell_routes.iter().find(|r| r.program == program) else {
return RouteDecision::Exec;
};
let rest: Vec<&str> = tokens.collect();
build_shell_route(route.tool, &rest)
}
}
fn resolve_workspace_relative_dir(
dir: &str,
workspace: &Path,
read_scope: &crate::caveats::Scope<String>,
) -> Option<String> {
if dir.is_empty()
|| dir == "-"
|| dir.starts_with('~')
|| dir.contains(SHELL_META)
|| dir.contains(GLOB)
|| dir.contains(BUILD_UNSAFE)
|| dir.contains(char::is_whitespace)
|| Path::new(dir)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return None;
}
let lexical = workspace.join(dir);
if !crate::caveats::permits_path(read_scope, &lexical.to_string_lossy()) {
return None;
}
let canonical_workspace = workspace.canonicalize().ok()?;
let canonical_dir = lexical.canonicalize().ok()?;
if !canonical_dir.is_dir() || !canonical_dir.starts_with(&canonical_workspace) {
return None;
}
if !crate::caveats::permits_path(
&canonicalize_scope(read_scope),
&canonical_dir.to_string_lossy(),
) {
return None;
}
let relative = canonical_dir.strip_prefix(&canonical_workspace).ok()?;
Some(if relative.as_os_str().is_empty() {
".".to_string()
} else {
relative.to_string_lossy().into_owned()
})
}
fn canonicalize_scope(scope: &crate::caveats::Scope<String>) -> crate::caveats::Scope<String> {
match scope {
crate::caveats::Scope::All => crate::caveats::Scope::All,
crate::caveats::Scope::Only(set) => crate::caveats::Scope::only(set.iter().map(|root| {
std::fs::canonicalize(root)
.ok()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| root.clone())
})),
}
}
fn attach_cwd(decision: RouteDecision, cwd: String) -> RouteDecision {
let RouteDecision::Route { tool, mut args } = decision else {
return decision;
};
if tool != "build_exec" {
return if cwd == "." {
RouteDecision::Route { tool, args }
} else {
RouteDecision::Exec
};
}
if let Some(obj) = args.as_object_mut() {
obj.insert("cwd".to_string(), Value::String(cwd));
}
RouteDecision::Route { tool, args }
}
fn split_leading_cd(command: &str) -> Option<(&str, &str)> {
let after = command.strip_prefix("cd ")?;
let amp = after.find("&&");
let semi = after.find(';');
let (dir, rest) = match (amp, semi) {
(Some(a), Some(s)) if s < a => (&after[..s], &after[s + 1..]),
(Some(a), _) => (&after[..a], &after[a + 2..]),
(None, Some(s)) => (&after[..s], &after[s + 1..]),
(None, None) => return None,
};
Some((dir.trim(), rest.trim_start()))
}
fn fold_leading_cd<'a>(
command: &'a str,
workspace: &Path,
read_scope: &crate::caveats::Scope<String>,
) -> (&'a str, Option<String>) {
let Some((dir, rest)) = split_leading_cd(command) else {
return (command, None);
};
match resolve_workspace_relative_dir(dir, workspace, read_scope) {
Some(cwd) => (rest, Some(cwd)),
None => (command, None),
}
}
fn strip_trailing_exit_echo(command: &str) -> (&str, bool) {
let Some((build_part, echo_arg)) = split_trailing_echo(command) else {
return (command, false);
};
if build_part.contains(['\'', '"']) {
return (command, false);
}
if !is_inert_exit_status_arg(echo_arg) {
return (command, false);
}
let build_part = build_part.trim();
let build_part = build_part
.strip_suffix("2>&1")
.map_or(build_part, str::trim);
(build_part, true)
}
fn split_trailing_echo(command: &str) -> Option<(&str, &str)> {
let amp = command.rfind("&&").map(|i| (i, i + 2));
let semi = command.rfind(';').map(|i| (i, i + 1));
let (sep_start, tail_start) = match (amp, semi) {
(Some(a), Some(s)) => {
if a.0 > s.0 {
a
} else {
s
}
}
(Some(a), None) => a,
(None, Some(s)) => s,
(None, None) => return None,
};
let build_part = &command[..sep_start];
let echo_arg = command[tail_start..].trim_start().strip_prefix("echo ")?;
let echo_arg = echo_arg.trim();
if echo_arg.is_empty() {
return None;
}
Some((build_part, echo_arg))
}
fn is_inert_exit_status_arg(arg: &str) -> bool {
if arg.contains(['\n', '\r']) {
return false;
}
let inner = if let Some(unquoted) = arg.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
if unquoted.contains('"') {
return false;
}
unquoted
} else if arg.contains(['"', '\'']) {
return false;
} else {
arg
};
if !inner.contains("$?") && !inner.contains("${PIPESTATUS[0]}") {
return false;
}
let scrubbed = inner.replace("${PIPESTATUS[0]}", "").replace("$?", "");
!scrubbed
.chars()
.any(|c| SHELL_META.contains(&c) || c == '\r')
}
fn mark_echo_dropped(decision: RouteDecision) -> RouteDecision {
match decision {
RouteDecision::Route {
tool: "build_exec",
mut args,
} => {
if let Some(obj) = args.as_object_mut() {
obj.insert("echo_dropped".to_string(), Value::Bool(true));
}
RouteDecision::Route {
tool: "build_exec",
args,
}
}
other => other,
}
}
fn strip_leading_timeout(command: &str) -> Option<(u64, &str)> {
let after = command.strip_prefix("timeout")?;
let mut rest = after.strip_prefix(char::is_whitespace)?.trim_start();
loop {
let (token, tail) = split_first_token(rest)?;
match consume_timeout_option(token, tail) {
Some(next_rest) => rest = next_rest.trim_start(),
None => break,
}
}
let (duration, tail) = split_first_token(rest)?;
if !is_plain_duration(duration) {
return None;
}
let remainder = tail.trim_start();
if remainder.is_empty() {
return None;
}
Some((duration_secs(duration), remainder))
}
fn duration_secs(token: &str) -> u64 {
let (digits, multiplier) = match token.chars().last() {
Some('s') => (&token[..token.len() - 1], 1),
Some('m') => (&token[..token.len() - 1], 60),
Some('h') => (&token[..token.len() - 1], 3600),
Some('d') => (&token[..token.len() - 1], 86400),
_ => (token, 1),
};
digits
.parse::<u64>()
.unwrap_or(0)
.saturating_mul(multiplier)
}
fn split_first_token(s: &str) -> Option<(&str, &str)> {
let s = s.trim_start();
if s.is_empty() {
return None;
}
match s.find(char::is_whitespace) {
Some(i) => Some((&s[..i], &s[i..])),
None => Some((s, "")),
}
}
fn consume_timeout_option<'a>(token: &str, tail: &'a str) -> Option<&'a str> {
match token {
"--preserve-status" | "--foreground" => Some(tail),
"-s" => {
split_first_token(tail).and_then(|(value, rest)| is_plain_signal(value).then_some(rest))
}
"-k" => split_first_token(tail)
.and_then(|(value, rest)| is_plain_duration(value).then_some(rest)),
_ if token.starts_with("--signal=") => {
is_plain_signal(&token["--signal=".len()..]).then_some(tail)
}
_ if token.starts_with("--kill-after=") => {
is_plain_duration(&token["--kill-after=".len()..]).then_some(tail)
}
_ => None,
}
}
fn is_plain_signal(token: &str) -> bool {
!token.is_empty() && token.bytes().all(|b| b.is_ascii_alphanumeric())
}
fn is_plain_duration(token: &str) -> bool {
let digits = match token.chars().last() {
Some('s' | 'm' | 'h' | 'd') => &token[..token.len() - 1],
_ => token,
};
!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
}
fn mark_timeout_secs(decision: RouteDecision, timeout_secs: u64) -> RouteDecision {
match decision {
RouteDecision::Route {
tool: "build_exec",
mut args,
} => {
if let Some(obj) = args.as_object_mut() {
obj.insert(
"timeout_secs".to_string(),
Value::Number(timeout_secs.into()),
);
}
RouteDecision::Route {
tool: "build_exec",
args,
}
}
other => other,
}
}
fn build_piped_to_trim_route(command: &str) -> RouteDecision {
let Some((build_part, trim_part)) = split_single_pipe(command) else {
return RouteDecision::Exec;
};
let build_part = build_part.trim();
let build_part = build_part
.strip_suffix("2>&1")
.map_or(build_part, str::trim);
if build_part.contains(SHELL_META) {
return RouteDecision::Exec;
}
let Some(trim) = parse_trim_spec(trim_part.trim()) else {
return RouteDecision::Exec;
};
let mut tokens = build_part.split_ascii_whitespace();
let Some(program) = tokens.next() else {
return RouteDecision::Exec;
};
if !is_build_tool_program(program) {
return RouteDecision::Exec;
}
let rest: Vec<&str> = tokens.collect();
match build_lane_route(program, &rest) {
RouteDecision::Route {
tool: "build_exec",
mut args,
} => {
args["trim"] = trim;
RouteDecision::Route {
tool: "build_exec",
args,
}
}
other => other,
}
}
fn split_single_pipe(command: &str) -> Option<(&str, &str)> {
let mut parts = command.split('|');
let build_part = parts.next()?;
let trim_part = parts.next()?;
if parts.next().is_some() {
return None;
}
Some((build_part, trim_part))
}
pub(super) fn parse_trim_spec(spec: &str) -> Option<Value> {
let tokens: Vec<&str> = spec.split_ascii_whitespace().collect();
let mode = match tokens.first().copied() {
Some("tail") => "tail",
Some("head") => "head",
_ => return None,
};
let n_str = match &tokens[1..] {
[flag] if flag.len() > 1 && flag.starts_with('-') => &flag[1..],
["-n", n] => *n,
_ => return None,
};
if n_str.is_empty() || !n_str.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n: u32 = n_str.parse().ok()?;
if n == 0 {
return None;
}
Some(json!({ "mode": mode, "n": n }))
}
fn build_lane_route(program: &str, rest: &[&str]) -> RouteDecision {
if rest.iter().any(|tok| tok.contains(BUILD_UNSAFE)) {
return RouteDecision::Exec;
}
match program {
"cargo" => cargo_build_route(rest),
"just" => just_build_route(rest),
_ => RouteDecision::Exec,
}
}
fn cargo_build_route(rest: &[&str]) -> RouteDecision {
let (toolchain, rest) = match rest.split_first() {
Some((first, tail)) if first.starts_with('+') => {
if !is_toolchain_selector(first) {
return RouteDecision::Exec;
}
(Some(*first), tail)
}
_ => (None, rest),
};
let Some((sub, rest)) = rest.split_first() else {
return RouteDecision::Exec;
};
if !CARGO_BUILD_SUBCOMMANDS.contains(sub) {
return RouteDecision::Exec;
}
if rest
.iter()
.any(|tok| *tok == "--config" || tok.starts_with("--config=") || tok.starts_with("-Z"))
{
return RouteDecision::Exec;
}
let mut argv = vec!["cargo".to_string()];
argv.extend(toolchain.map(str::to_string));
argv.push((*sub).to_string());
argv.extend(rest.iter().map(|t| (*t).to_string()));
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": argv }),
}
}
pub(crate) fn is_toolchain_selector(token: &str) -> bool {
match token.strip_prefix('+') {
Some(name) if !name.is_empty() => name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
_ => false,
}
}
fn just_build_route(rest: &[&str]) -> RouteDecision {
match rest {
[recipe] if !recipe.starts_with('-') => RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": ["just", recipe] }),
},
_ => RouteDecision::Exec,
}
}
fn build_shell_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),
"delete_file" => build_delete_route(rest),
_ => RouteDecision::Exec,
}
}
fn operands<'a>(rest: &[&'a str]) -> Vec<&'a str> {
rest.iter()
.copied()
.filter(|t| !t.starts_with('-'))
.collect()
}
fn build_delete_route(rest: &[&str]) -> RouteDecision {
let mut operands = Vec::new();
let mut end_of_flags = false;
for token in rest {
if !end_of_flags && *token == "--" {
end_of_flags = true;
continue;
}
if !end_of_flags && token.starts_with('-') {
if token.chars().skip(1).all(|c| c == 'f') {
continue;
}
return RouteDecision::Exec;
}
operands.push(*token);
}
match operands.as_slice() {
[path] if !path.contains(GLOB) => RouteDecision::Route {
tool: "delete_file",
args: json!({ "path": path }),
},
_ => RouteDecision::Exec,
}
}
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 no_cd_match() -> &'static Path {
Path::new("/never-matches-a-test-cwd")
}
fn classify(cmd: &str) -> RouteDecision {
RouteTable::builtin().classify(cmd, no_cd_match(), &crate::caveats::Scope::All)
}
fn classify_at(
cmd: &str,
workspace: &Path,
read_scope: &crate::caveats::Scope<String>,
) -> RouteDecision {
RouteTable::builtin().classify(cmd, workspace, read_scope)
}
struct CdFixture {
_dir: tempfile::TempDir,
root: std::path::PathBuf,
#[allow(dead_code)]
sub: std::path::PathBuf,
}
impl CdFixture {
fn new() -> Self {
let dir = tempfile::TempDir::new().expect("tempdir");
let root = dir.path().canonicalize().expect("canonicalize tempdir");
let sub = root.join("sub");
std::fs::create_dir(&sub).expect("mkdir sub");
std::fs::write(root.join("file.txt"), b"not a directory").expect("write file");
std::fs::write(root.join("x"), b"root x").expect("write root x");
std::fs::write(root.join("f"), b"root f").expect("write root f");
std::fs::write(sub.join("x"), b"sub x").expect("write sub x");
std::fs::write(sub.join("f"), b"sub f").expect("write sub f");
Self {
_dir: dir,
root,
sub,
}
}
fn read_scope(&self) -> crate::caveats::Scope<String> {
crate::caveats::Scope::only([self.root.to_string_lossy().into_owned()])
}
}
#[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 native_git_commands_keep_flags_revisions_pathspecs_and_cwd() {
let fx = CdFixture::new();
let table = RouteTable::builtin();
for command in [
"git status",
"git status --porcelain=v1 --untracked-files=all",
"git log -5 A..B -- src/lib.rs docs/",
"git log HEAD~1...HEAD -- 'src/a b.rs'",
"git diff --cached --stat HEAD~1 -- src/lib.rs",
"git diff --word-diff A B -- '*.rs'",
"git branch",
"git branch --all",
"git branch --remotes",
"git -C sub status",
"git --git-dir=sub/.git status",
"cd sub && git status",
"cd sub; git log -n 5 -- src/lib.rs",
] {
for extra in [json!({}), json!({"cwd": "sub"}), json!({"timeout": 5})] {
let mut call = extra;
call["command"] = json!(command);
assert_eq!(
table.classify_call(&call, &fx.root, &fx.read_scope()),
RouteDecision::Exec,
"preserve the original shell request: {call}"
);
}
}
}
#[test]
fn read_only_git_uses_native_exec() {
for cmd in ["git status", "git log", "git diff"] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn git_read_commands_preserve_operands() {
for cmd in [
"git status -s",
"git status --porcelain",
"git diff --cached",
"git diff --cached --stat",
"git diff --staged",
"git log -5",
"git log -n 5",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn git_reads_preserve_revisions_pathspecs_and_stat() {
for cmd in [
"git log A..B",
"git log HEAD~3",
"git log -5 A..B -- src/lib.rs",
"git log -- src/lib.rs docs/",
"git diff HEAD~1",
"git diff A B",
"git diff A..B",
"git diff --stat",
"git diff --stat HEAD~1 -- src/lib.rs",
"git log HEAD~1...HEAD",
"git log src/lib.rs",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn git_presentation_options_stay_on_native_exec() {
assert_eq!(classify("git log --oneline"), RouteDecision::Exec);
assert_eq!(classify("git log -p"), RouteDecision::Exec);
assert_eq!(classify("git log -p A..B"), RouteDecision::Exec);
assert_eq!(classify("git diff --word-diff"), RouteDecision::Exec);
assert_eq!(classify("git diff --name-only"), RouteDecision::Exec);
assert_eq!(classify("git diff A B C"), RouteDecision::Exec);
assert_eq!(classify("git log --all"), RouteDecision::Exec);
assert_eq!(classify("git diff --numstat"), RouteDecision::Exec);
}
#[test]
fn branch_listings_use_native_exec() {
for cmd in [
"git branch",
"git branch --list",
"git branch -a",
"git branch --all",
"git branch --list --all",
"git branch -a --list",
"git branch -r",
"git branch --remotes",
"git branch --list -r",
"git branch --remotes --list",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn unrouted_calls_preserve_extra_arguments() {
let table = RouteTable::builtin();
for command in ["git branch", "git branch --all", "git branch --remotes"] {
assert_eq!(
table.classify_call(
&json!({"command": command}),
no_cd_match(),
&crate::caveats::Scope::All
),
classify(command)
);
for extra in [json!({"cwd": "elsewhere"}), json!({"timeout": 5})] {
let mut call = extra;
call["command"] = json!(command);
assert_eq!(
table.classify_call(&call, no_cd_match(), &crate::caveats::Scope::All),
RouteDecision::Exec,
"{call}"
);
}
}
for command in ["git status", "cat file", "ls"] {
assert_eq!(
table.classify_call(
&json!({"command": command, "cwd": "elsewhere"}),
no_cd_match(),
&crate::caveats::Scope::All
),
RouteDecision::Exec,
"{command}"
);
}
}
#[test]
fn branch_mutations_and_unrepresented_filters_never_become_listing() {
for cmd in [
"git branch topic",
"git branch topic HEAD",
"git branch -d topic",
"git branch -D topic",
"git branch -m old new",
"git branch -M topic",
"git branch -c old new",
"git branch -C topic",
"git branch --set-upstream-to=origin/main",
"git branch --unset-upstream",
"git branch --track topic origin/main",
"git branch --edit-description",
"git branch --list topic",
"git branch --list 'feat/*'",
"git branch --merged",
"git branch --no-merged main",
"git branch --contains HEAD",
"git branch --format=%(refname)",
"git branch --sort=-committerdate",
"git branch --show-current",
"git branch --unknown",
"git branch -a -r",
"git branch -ar",
"git -C other branch",
"git --git-dir=other branch",
"git branch -a | wc -l",
"git branch -a > branches.txt",
"git branch --all && git branch topic",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{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 topic",
"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 simple_delete_routes_to_delete_file() {
for cmd in [
"rm src/cockpit.rs",
"rm -f src/cockpit.rs",
"unlink src/cockpit.rs",
] {
assert_eq!(
classify(cmd),
RouteDecision::Route {
tool: "delete_file",
args: json!({ "path": "src/cockpit.rs" }),
},
"{cmd}"
);
}
for cmd in [
"rm -rf src",
"rm -r src",
"rm a.txt b.txt",
"rm *.tmp",
"unlink a.txt b.txt",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[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 ["echo hi", "grep foo bar", "bash script.sh", ""] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd:?}");
}
}
#[test]
fn cargo_build_commands_route_with_literal_argv() {
assert_eq!(
classify("cargo test -p newt-core"),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": ["cargo", "test", "-p", "newt-core"] }),
}
);
for (cmd, argv) in [
("cargo build", json!(["cargo", "build"])),
("cargo check", json!(["cargo", "check"])),
(
"cargo clippy --workspace",
json!(["cargo", "clippy", "--workspace"]),
),
(
"cargo test -p x --lib -- --test-threads=1",
json!([
"cargo",
"test",
"-p",
"x",
"--lib",
"--",
"--test-threads=1"
]),
),
] {
assert_eq!(
classify(cmd),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": argv }),
},
"{cmd}"
);
}
}
#[test]
fn just_recipe_routes_with_literal_argv() {
assert_eq!(
classify("just test"),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": ["just", "test"] }),
}
);
for cmd in ["just", "just test extra", "just -n test", "just --list"] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn cargo_near_misses_never_route() {
for cmd in [
"cargo run",
"cargo install ripgrep",
"cargo publish",
"cargo test --config net.offline=false",
"cargo build -Zunstable-options",
"cargo test | tail",
"cargo",
"cargo frobnicate",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn cargo_toolchain_selector_routes_and_is_kept_in_the_argv() {
assert_eq!(
classify("cargo +stable test"),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": ["cargo", "+stable", "test"] }),
}
);
for (cmd, argv) in [
(
"cargo +nightly build",
json!(["cargo", "+nightly", "build"]),
),
("cargo +1.85.0 check", json!(["cargo", "+1.85.0", "check"])),
(
"cargo +nightly-2026-09-01 clippy",
json!(["cargo", "+nightly-2026-09-01", "clippy"]),
),
] {
assert_eq!(
classify(cmd),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": argv }),
},
"{cmd}"
);
}
}
#[test]
fn cargo_invalid_toolchain_selector_never_routes() {
for cmd in [
"cargo + test",
"cargo +$X test",
"cargo +stable/../etc test",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn build_route_call_requires_bare_call() {
let table = RouteTable::builtin();
assert_eq!(
table.classify_call(
&json!({"command": "cargo test"}),
no_cd_match(),
&crate::caveats::Scope::All
),
classify("cargo test")
);
let call = json!({"command": "cargo test", "timeout": 5});
assert_eq!(
table.classify_call(&call, no_cd_match(), &crate::caveats::Scope::All),
RouteDecision::Exec,
"{call}"
);
}
#[test]
fn a_cwd_field_on_a_bare_call_routes_the_same_way() {
let fx = CdFixture::new();
let scope = fx.read_scope();
let table = RouteTable::builtin();
assert_eq!(
table.classify_call(
&json!({"command": "cargo test", "cwd": "sub"}),
&fx.root,
&scope
),
RouteDecision::Route {
tool: "build_exec",
args: json!({"argv": ["cargo", "test"], "cwd": "sub"}),
}
);
assert_eq!(
table.classify_call(
&json!({"command": "cargo test", "cwd": "sub", "timeout": 5}),
&fx.root,
&scope
),
RouteDecision::Exec
);
assert_eq!(
table.classify_call(
&json!({"command": "cargo test", "cwd": "missing"}),
&fx.root,
&scope
),
RouteDecision::Exec
);
}
#[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);
}
#[test]
fn build_piped_to_tail_or_head_routes_with_the_trim() {
assert_eq!(
classify(
"cargo +stable test -j 4 -p newt-core --test config_publishing_ratchet 2>&1 | tail -40"
),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "+stable", "test", "-j", "4", "-p", "newt-core",
"--test", "config_publishing_ratchet"],
"trim": {"mode": "tail", "n": 40},
}),
}
);
for (cmd, mode, n) in [
("cargo test -p newt-core | tail -40", "tail", 40),
("cargo test -p newt-core | tail -n 40", "tail", 40),
("cargo build | head -20", "head", 20),
("cargo build | head -n 20", "head", 20),
("just test 2>&1 | tail -5", "tail", 5),
] {
let RouteDecision::Route { tool, args } = classify(cmd) else {
panic!("{cmd} must route");
};
assert_eq!(tool, "build_exec", "{cmd}");
assert_eq!(args["trim"]["mode"], mode, "{cmd}");
assert_eq!(args["trim"]["n"], n, "{cmd}");
}
}
#[test]
fn build_piped_to_anything_else_never_routes() {
for cmd in [
"cargo test | tail -40 | head",
"cargo test | tail -f",
"cargo build | head -c 10",
"cargo test | grep FAILED",
"cargo test > log.txt | tail -40",
"cargo test | tail",
"cargo test | tail -0",
"cargo test | tail -n 0",
"cargo test | tail -abc",
"cargo test | tail -40 extra",
"pytest | tail -40",
"cargo run | tail -40",
"cargo test | tail -n +40",
"cargo test | head -n +40",
"cargo test | tail -+40",
"cargo test || tail -40",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[cfg(not(windows))]
#[test]
fn cd_into_a_real_subdirectory_routes_with_cwd() {
let fx = CdFixture::new();
let scope = fx.read_scope();
assert_eq!(
classify_at("cd sub && cargo test", &fx.root, &scope),
RouteDecision::Route {
tool: "build_exec",
args: json!({"argv": ["cargo", "test"], "cwd": "sub"}),
}
);
assert_eq!(
classify_at("cd sub; cargo test", &fx.root, &scope),
RouteDecision::Route {
tool: "build_exec",
args: json!({"argv": ["cargo", "test"], "cwd": "sub"}),
}
);
let RouteDecision::Route { args, .. } =
classify_at("cd sub && cargo test | tail -20", &fx.root, &scope)
else {
panic!("must route");
};
assert_eq!(args["cwd"], "sub");
assert_eq!(args["trim"], json!({"mode": "tail", "n": 20}));
assert_eq!(
classify_at(
&format!("cd {} && cargo test", fx.root.display()),
&fx.root,
&scope
),
RouteDecision::Route {
tool: "build_exec",
args: json!({"argv": ["cargo", "test"], "cwd": "."}),
}
);
assert_eq!(
classify_at("cd sub && git status", &fx.root, &scope),
RouteDecision::Exec
);
}
#[test]
fn cd_stays_exec_when_the_target_does_not_resolve_or_is_out_of_authority() {
let fx = CdFixture::new();
let scope = fx.read_scope();
for cmd in [
"cd missing && cargo test",
"cd file.txt && cargo test",
"cd ../outside-the-workspace && cargo test",
"cd - && cargo test",
"cd ~ && cargo test",
"cd \"sub\" && cargo test",
"cd su* && cargo test",
"cd sub$X && cargo test",
"cd sub && cd sub && cargo test",
"cd sub && rm -rf x",
"cd su b && cargo test",
] {
assert_eq!(
classify_at(cmd, &fx.root, &scope),
RouteDecision::Exec,
"{cmd}"
);
}
}
#[cfg(not(windows))]
#[test]
fn cd_through_a_symlink_that_escapes_the_workspace_stays_exec() {
let fx = CdFixture::new();
let scope = fx.read_scope();
let outside = std::env::temp_dir();
std::os::unix::fs::symlink(&outside, fx.root.join("escape")).expect("symlink");
assert_eq!(
classify_at("cd escape && cargo test", &fx.root, &scope),
RouteDecision::Exec
);
}
#[test]
fn cd_into_a_real_subdirectory_outside_the_fence_stays_exec() {
let fx = CdFixture::new();
let elsewhere = tempfile::TempDir::new().expect("tempdir");
let scope = crate::caveats::Scope::only([elsewhere
.path()
.canonicalize()
.expect("canonicalize")
.to_string_lossy()
.into_owned()]);
assert_eq!(
classify_at("cd sub && cargo test", &fx.root, &scope),
RouteDecision::Exec
);
}
#[test]
fn a_resolved_cwd_never_reaches_a_route_that_does_not_honour_it() {
let fx = CdFixture::new();
let scope = fx.read_scope();
for cmd in ["cd sub && rm x", "cd sub && cat f", "cd sub && ls"] {
assert_eq!(
classify_at(cmd, &fx.root, &scope),
RouteDecision::Exec,
"{cmd}"
);
}
assert_eq!(
RouteTable::builtin().classify_call(
&json!({"command": "cat f", "cwd": "sub"}),
&fx.root,
&scope
),
RouteDecision::Exec
);
#[cfg(not(windows))]
assert_eq!(
classify_at(
&format!("cd {} && cat f", fx.root.display()),
&fx.root,
&scope
),
RouteDecision::Route {
tool: "read_file",
args: json!({ "path": "f" }),
}
);
}
#[cfg(not(windows))]
#[test]
fn a_symlink_whose_real_target_is_outside_the_fence_stays_exec() {
let fx = CdFixture::new();
let other = fx.root.join("other");
std::fs::create_dir(&other).expect("mkdir other");
std::os::unix::fs::symlink(&other, fx.sub.join("link")).expect("symlink");
let scope = crate::caveats::Scope::only([fx.sub.to_string_lossy().into_owned()]);
assert_eq!(
classify_at("cd sub/link && cargo test", &fx.root, &scope),
RouteDecision::Exec
);
}
#[test]
fn a_parent_dir_component_in_the_cd_target_always_refuses() {
let fx = CdFixture::new();
let scope = fx.read_scope();
assert_eq!(
classify_at("cd sub/../sub && cargo test", &fx.root, &scope),
RouteDecision::Exec
);
assert_eq!(
classify_at("cd sub/.. && cargo test", &fx.root, &scope),
RouteDecision::Exec
);
}
#[test]
fn a_cwd_field_alongside_a_folded_leading_cd_refuses() {
let fx = CdFixture::new();
let other = fx.root.join("other");
std::fs::create_dir(&other).expect("mkdir other");
std::fs::create_dir(other.join("sub")).expect("mkdir other/sub");
let scope = fx.read_scope();
let call = json!({ "command": "cd sub && cargo test", "cwd": "other" });
assert_eq!(
RouteTable::builtin().classify_call(&call, &fx.root, &scope),
RouteDecision::Exec
);
}
#[test]
#[cfg(not(windows))]
fn a_symlinked_workspace_root_still_folds_a_cd() {
let real = tempfile::TempDir::new().expect("tempdir");
let real_root = real.path().canonicalize().expect("canonicalize tempdir");
std::fs::create_dir(real_root.join("sub")).expect("mkdir sub");
let parent = real_root.parent().expect("tempdir has a parent");
let link = parent.join(format!(
"{}-link",
real_root.file_name().unwrap().to_string_lossy()
));
std::os::unix::fs::symlink(&real_root, &link).expect("symlink workspace root");
let scope = crate::caveats::Scope::only([link.to_string_lossy().into_owned()]);
let call = json!({ "command": "cd sub && cargo test" });
assert_eq!(
RouteTable::builtin().classify_call(&call, &link, &scope),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": ["cargo", "test"], "cwd": "sub" }),
}
);
std::fs::remove_file(&link).ok();
}
#[test]
fn a_folded_cd_with_2_and_1_and_a_trailing_exit_echo_routes_with_cwd() {
let fx = CdFixture::new();
let scope = fx.read_scope();
assert_eq!(
classify_at(
"cd sub && cargo test 2>&1; echo \"EXIT: $?\"",
&fx.root,
&scope,
),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": ["cargo", "test"], "cwd": "sub", "echo_dropped": true }),
}
);
}
#[test]
fn every_trailing_exit_echo_variant_is_stripped_and_routes() {
for cmd in [
"cargo test; echo \"EXIT: $?\"",
"cargo test && echo \"EXIT: $?\"",
"cargo test; echo \"EXIT=$?\"",
"cargo test; echo \"exit $?\"",
"cargo test; echo $?",
"cargo test; echo \"TEST_EXIT=${PIPESTATUS[0]}\"",
] {
assert_eq!(
classify(cmd),
RouteDecision::Route {
tool: "build_exec",
args: json!({ "argv": ["cargo", "test"], "echo_dropped": true }),
},
"{cmd}"
);
}
}
#[test]
fn a_trailing_echo_that_is_not_provably_inert_stays_compound() {
for cmd in [
"cargo test; echo \"$HOME\"",
"cargo test; echo $(date)",
"cargo test; echo x > f",
"cargo test || echo \"$?\"",
"cargo test; echo a; echo \"$?\"",
] {
assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
}
}
#[test]
fn a_trailing_exit_echo_on_a_command_that_would_fail_still_routes() {
assert_eq!(
classify("cargo test --this-flag-does-not-exist; echo \"EXIT: $?\""),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "test", "--this-flag-does-not-exist"],
"echo_dropped": true,
}),
}
);
}
#[test]
fn a_newline_in_the_echo_tail_refuses_the_whole_strip() {
assert_eq!(
classify("cargo test; echo EXIT=$?\ntouch marker"),
RouteDecision::Exec
);
}
#[test]
fn a_quote_inside_the_kept_half_refuses_the_whole_strip() {
assert_eq!(classify("rm \"x; echo \"$?\"\""), RouteDecision::Exec);
}
#[test]
fn a_leading_timeout_wrapper_is_stripped_and_the_build_routes() {
assert_eq!(
classify("timeout 300 cargo build -p newt-core"),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "build", "-p", "newt-core"],
"timeout_secs": 300,
}),
}
);
}
#[test]
fn a_leading_timeout_wrapper_composes_with_the_cd_fold() {
let fx = CdFixture::new();
let scope = fx.read_scope();
assert_eq!(
classify_at(
"cd sub && timeout 300 cargo build -p newt-core",
&fx.root,
&scope,
),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "build", "-p", "newt-core"],
"cwd": "sub",
"timeout_secs": 300,
}),
}
);
}
#[test]
fn a_leading_timeout_wrapper_composes_with_the_tail_trim() {
assert_eq!(
classify("timeout 300 cargo build 2>&1 | tail -20"),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "build"],
"trim": { "mode": "tail", "n": 20 },
"timeout_secs": 300,
}),
}
);
}
#[test]
fn a_leading_timeout_wrapper_composes_with_the_exit_echo_strip() {
assert_eq!(
classify("timeout 300 cargo build -p newt-core; echo \"EXIT: $?\""),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "build", "-p", "newt-core"],
"echo_dropped": true,
"timeout_secs": 300,
}),
}
);
}
#[test]
fn a_timeout_wrapper_around_a_non_routable_command_stays_exec() {
assert_eq!(classify("timeout 5 rm -rf x"), RouteDecision::Exec);
}
#[test]
fn a_timeout_wrapper_with_a_shell_variable_duration_stays_exec() {
assert_eq!(classify("timeout $T cargo test"), RouteDecision::Exec);
}
#[test]
fn two_stacked_timeout_wrappers_never_recursively_strip() {
assert_eq!(
classify("timeout 1 timeout 2 cargo test"),
RouteDecision::Exec
);
}
#[test]
fn a_metacharacter_in_the_signal_value_refuses_the_whole_strip() {
assert_eq!(
classify("timeout -s $(touch${IFS}pwned) 5 cargo test"),
RouteDecision::Exec
);
}
#[test]
fn a_metacharacter_in_the_kill_after_value_refuses_the_whole_strip() {
assert_eq!(
classify("timeout --kill-after=1;touch${IFS}x 5 cargo test"),
RouteDecision::Exec
);
}
#[test]
fn a_plain_signal_value_still_strips_and_routes() {
assert_eq!(
classify("timeout -s KILL 5 cargo test"),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "test"],
"timeout_secs": 5,
}),
}
);
}
#[test]
fn a_plain_signal_and_kill_after_value_still_strips_and_routes() {
assert_eq!(
classify("timeout --signal=TERM -k 5 60 cargo test"),
RouteDecision::Route {
tool: "build_exec",
args: json!({
"argv": ["cargo", "test"],
"timeout_secs": 60,
}),
}
);
}
}