1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
//! `fd` is a read-only file finder, EXCEPT `-x/--exec` and `-X/--exec-batch`, which run a command
//! per result (or once for all results) — the same arbitrary-command entry point as `find -exec`.
//! So this handler delegates the inner command to the classifier, bound to each search path, and
//! routes the non-exec case to the TOML flag grammar. All flag data lives in
//! `commands/search/fd.toml`; this file is dispatch logic only.
use crate::parse::Token;
use crate::registry;
use crate::verdict::{SafetyLevel, Verdict};
const EXEC_FLAGS: &[&str] = &["-x", "--exec", "-X", "--exec-batch"];
pub fn check_fd(tokens: &[Token]) -> Verdict {
let Some(xi) = tokens.iter().position(|t| EXEC_FLAGS.contains(&t.as_str())) else {
// No exec: a plain search — validate flags against the TOML grammar.
return registry::try_fallback_grammar("fd", tokens).unwrap_or(Verdict::Denied);
};
// The pre-exec portion is an ordinary fd search; its flags must still be valid (an unknown
// flag denies) — reuse the same grammar on just that slice.
match registry::try_fallback_grammar("fd", &tokens[..xi]) {
Some(Verdict::Allowed(_)) => {}
_ => return Verdict::Denied,
}
// The exec command runs to a `;`/`+` terminator or the end of the line.
let cmd_start = xi + 1;
let cmd_end = tokens[cmd_start..]
.iter()
.position(|t| *t == ";" || *t == "+")
.map(|p| cmd_start + p)
.unwrap_or(tokens.len());
if cmd_start >= cmd_end {
return Verdict::Denied;
}
// `{}` is substituted with each matched path, which lives UNDER fd's search path(s). Bind it to
// a path under each — deny-absorbing, so a system/home search denies. Every bare pre-exec operand
// is a candidate base (the pattern is benign worktree, `--search-path`/`--base-directory` VALUES
// are real bases), plus fd's default `.`.
let mut bases: Vec<&str> = tokens[1..xi]
.iter()
.map(Token::as_str)
.filter(|s| !s.starts_with('-'))
.collect();
bases.push(".");
// With no `{}` placeholder, fd APPENDS each matched path as a trailing argument (`fd -X cat`
// runs `cat <matches>`), so the path must still be bound in that form or a `/etc` search leaks.
let has_placeholder =
tokens[cmd_start..cmd_end].iter().any(|t| t.as_str().contains('{'));
let mut level = SafetyLevel::Inert;
for base in &bases {
let bound = format!("{}/f", base.trim_end_matches('/'));
let mut words: Vec<String> = tokens[cmd_start..cmd_end]
.iter()
.map(|t| bind_placeholders(t.as_str(), &bound))
.collect();
if !has_placeholder {
words.push(bound.clone());
}
match crate::command_verdict(&shell_words::join(&words)) {
Verdict::Denied => return Verdict::Denied,
Verdict::Allowed(l) => level = level.max(l),
}
}
Verdict::Allowed(level)
}
/// fd's placeholders (`{}` full path, `{.}` no-ext, `{/}` basename, `{//}` parent, `{/.}` basename
/// no-ext) all name the matched file — bind each to the traversal-bound path. Longest tokens first
/// so `{//}` isn't partially eaten by `{}`.
fn bind_placeholders(tok: &str, bound: &str) -> String {
tok.replace("{//}", bound)
.replace("{/.}", bound)
.replace("{/}", bound)
.replace("{.}", bound)
.replace("{}", bound)
}
#[cfg(test)]
mod tests {
use crate::is_safe_command;
fn check(cmd: &str) -> bool {
is_safe_command(cmd)
}
safe! {
// plain search (the non-exec fallback grammar)
bare: "fd",
pattern: "fd pattern",
flags: "fd --type f --hidden pattern",
pattern_and_path: "fd pattern src",
ext_valued: "fd -e rs",
// exec delegates to a safe inner command, bound under the worktree
exec_echo: "fd -x echo {}",
exec_wc: "fd pattern -x wc -l {}",
exec_batch_cat_worktree: "fd -X cat",
exec_rm_worktree: "fd -x rm {}", // worktree destroy is editor-level (like `rm ./f`)
exec_placeholder_variants: "fd -x cat {//}/{/}",
}
denied! {
// exec into a SYSTEM search path denies through the inner command's locus
exec_system_read: "fd /etc -x cat {}",
exec_batch_system: "fd /etc -X cat",
exec_system_glued_placeholder: "fd -x rm /etc/{}",
exec_search_path_flag_system: "fd --search-path /etc -X cat",
// an unknown search flag before the exec still denies
unknown_flag: "fd --evil",
unknown_flag_before_exec: "fd --evil -x echo {}",
// a dangling exec with no command
empty_exec: "fd -x",
}
use proptest::prelude::*;
proptest! {
/// fd's `-x`/`-X` is a delegation flag exactly like `find -exec`: the finder's verdict must
/// FOLLOW the inner command bound to the search path. A read/write inner command over a SYSTEM
/// base must deny; the same over the worktree must allow. Guards the class the way find's own
/// exec tests do — a regression that stopped delegating (allowing everything, or denying
/// everything) fails here.
#[test]
fn fd_exec_follows_the_inner_command_locus(
inner in prop::sample::select(vec!["cat", "od", "base64", "wc -l"]),
) {
// System base → the inner read of a system path denies.
let sys = format!("fd /etc -x {} {{}}", inner);
prop_assert!(!crate::is_safe_command(&sys), "system search must deny: {}", sys);
// Worktree base → the same read allows.
let wt = format!("fd src -x {} {{}}", inner);
prop_assert!(crate::is_safe_command(&wt), "worktree search must allow: {}", wt);
}
/// A batch exec with NO placeholder (`fd <base> -X <cmd>`) appends the match as a trailing arg;
/// it must gate the base identically to the placeholder form — never a silent leak.
#[test]
fn fd_batch_without_placeholder_gates_the_base(
reader in prop::sample::select(vec!["cat", "od", "base64"]),
) {
let sys = format!("fd /etc -X {}", reader);
prop_assert!(!crate::is_safe_command(&sys), "batch system read must deny: {}", sys);
}
}
}