telos_agent/tools/command_security/bash/
quote_context.rs1use super::parser::Node;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct QuoteContext {
18 pub with_double_quotes: String,
21 pub fully_unquoted: String,
23 pub unquoted_keep_quote_chars: String,
25}
26
27impl QuoteContext {
28 pub fn from_node(root: &Node, source: &str) -> Self {
30 let spans = collect_quote_spans(root);
31
32 let single_quote_set: std::collections::HashSet<usize> = spans
33 .iter()
34 .filter(|s| s.kind != SpanKind::Double)
35 .flat_map(|s| s.start..s.end)
36 .collect();
37
38 let double_quote_delims: std::collections::HashSet<usize> = spans
39 .iter()
40 .filter(|s| s.kind == SpanKind::Double)
41 .flat_map(|s| [s.start, s.end - 1])
42 .collect();
43
44 let mut with_double_quotes = String::with_capacity(source.len());
45 for (i, c) in source.char_indices() {
46 if single_quote_set.contains(&i) {
47 continue;
48 }
49 if double_quote_delims.contains(&i) {
50 continue;
51 }
52 with_double_quotes.push(c);
53 }
54
55 let fully_unquoted = remove_spans(source, &spans);
56 let unquoted_keep_quote_chars = replace_spans_keep_quotes(source, &spans);
57
58 Self { with_double_quotes, fully_unquoted, unquoted_keep_quote_chars }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum SpanKind {
64 Single,
65 Double,
66 AnsiC,
67 Heredoc,
68}
69
70#[derive(Debug, Clone, Copy)]
71struct Span {
72 start: usize,
73 end: usize,
74 kind: SpanKind,
75}
76
77fn collect_quote_spans(node: &Node) -> Vec<Span> {
78 let mut out = Vec::new();
79 collect_quote_spans_inner(node, &mut out, false);
80 out
81}
82
83fn collect_quote_spans_inner(node: &Node, out: &mut Vec<Span>, in_double: bool) {
84 match node.kind.as_str() {
85 "raw_string" => {
86 out.push(Span { start: node.start_byte, end: node.end_byte, kind: SpanKind::Single });
87 return;
88 }
89 "ansi_c_string" => {
90 out.push(Span { start: node.start_byte, end: node.end_byte, kind: SpanKind::AnsiC });
91 return;
92 }
93 "string" => {
94 if !in_double {
95 out.push(Span {
96 start: node.start_byte,
97 end: node.end_byte,
98 kind: SpanKind::Double,
99 });
100 }
101 for child in &node.children {
102 collect_quote_spans_inner(child, out, true);
103 }
104 return;
105 }
106 "heredoc_redirect" if is_quoted_heredoc(node) => {
107 out.push(Span { start: node.start_byte, end: node.end_byte, kind: SpanKind::Heredoc });
108 return;
109 }
110 _ => {}
111 }
112
113 for child in &node.children {
114 collect_quote_spans_inner(child, out, in_double);
115 }
116}
117
118fn is_quoted_heredoc(node: &Node) -> bool {
119 node.children
120 .iter()
121 .find(|c| c.kind == "heredoc_start")
122 .map(|c| {
123 let first = c.text.chars().next().unwrap_or('\0');
124 first == '\'' || first == '"' || first == '\\'
125 })
126 .unwrap_or(false)
127}
128
129fn drop_contained_spans(spans: &mut Vec<Span>) {
131 let mut keep = vec![true; spans.len()];
132 for (i, s) in spans.iter().enumerate() {
133 for (j, other) in spans.iter().enumerate() {
134 if i == j {
135 continue;
136 }
137 if other.start <= s.start
138 && other.end >= s.end
139 && (other.start < s.start || other.end > s.end)
140 {
141 keep[i] = false;
142 break;
143 }
144 }
145 }
146 let mut i = 0;
147 spans.retain(|_| {
148 let k = keep[i];
149 i += 1;
150 k
151 });
152}
153
154fn remove_spans(source: &str, spans: &[Span]) -> String {
155 if spans.is_empty() {
156 return source.to_string();
157 }
158 let mut sorted: Vec<Span> = spans.to_vec();
159 drop_contained_spans(&mut sorted);
160 sorted.sort_by_key(|s| s.start);
161
162 let mut result = String::with_capacity(source.len());
163 let mut last_end = 0usize;
164 for span in sorted {
165 result.push_str(&source[last_end..span.start.min(source.len())]);
166 last_end = span.end.min(source.len());
167 }
168 result.push_str(&source[last_end..]);
169 result
170}
171
172fn replace_spans_keep_quotes(source: &str, spans: &[Span]) -> String {
173 if spans.is_empty() {
174 return source.to_string();
175 }
176 let sorted: Vec<(usize, usize, &'static str, &'static str)> = spans
177 .iter()
178 .map(|s| {
179 let (open, close) = match s.kind {
180 SpanKind::Single => ("'", "'"),
181 SpanKind::Double => ("\"", "\""),
182 SpanKind::AnsiC => ("$'", "'"),
183 SpanKind::Heredoc => ("", ""),
184 };
185 (s.start, s.end, open, close)
186 })
187 .collect();
188
189 let len = sorted.len();
191 let mut keep = vec![true; len];
192 for i in 0..len {
193 for j in 0..len {
194 if i == j {
195 continue;
196 }
197 let (s_start, s_end, _, _) = sorted[i];
198 let (o_start, o_end, _, _) = sorted[j];
199 if o_start <= s_start && o_end >= s_end && (o_start < s_start || o_end > s_end) {
200 keep[i] = false;
201 break;
202 }
203 }
204 }
205 let mut filtered = Vec::new();
206 for i in 0..len {
207 if keep[i] {
208 filtered.push(sorted[i]);
209 }
210 }
211
212 filtered.sort_by_key(|s| s.0);
213
214 let mut result = String::with_capacity(source.len());
215 let mut last_end = 0usize;
216 for (start, end, open, close) in filtered {
217 result.push_str(&source[last_end..start.min(source.len())]);
218 result.push_str(open);
219 result.push_str(close);
220 last_end = end.min(source.len());
221 }
222 result.push_str(&source[last_end..]);
223 result
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::tools::command_security::bash::parser;
230
231 fn ctx(cmd: &str) -> QuoteContext {
232 let ast = parser::parse(cmd).unwrap();
233 QuoteContext::from_node(&ast, cmd)
234 }
235
236 #[test]
237 fn removes_single_quoted_content() {
238 let c = ctx("echo 'hello world'");
239 assert_eq!(c.fully_unquoted, "echo ");
240 assert_eq!(c.with_double_quotes, "echo ");
241 assert_eq!(c.unquoted_keep_quote_chars, "echo ''");
242 }
243
244 #[test]
245 fn keeps_double_quoted_content() {
246 let c = ctx(r#"echo "hello world""#);
247 assert_eq!(c.fully_unquoted, "echo ");
248 assert_eq!(c.with_double_quotes, "echo hello world");
249 assert_eq!(c.unquoted_keep_quote_chars, "echo \"\"");
250 }
251
252 #[test]
253 fn nested_quotes() {
254 let c = ctx(r#"echo "$(echo 'hi')""#);
255 assert_eq!(c.with_double_quotes, "echo $(echo )");
257 }
258}