double_o/rewrite.rs
1//! `oo rewrite` — print an `oo`-prefixed version of a command for agent hooks.
2//!
3//! Shared rewriter that hook preexec handlers (pi, Claude Code) call:
4//! exit 0 with the rewritten command on stdout when at least one shell
5//! segment matches an oo pattern, exit 1 with no output otherwise. It never
6//! executes the command and never touches the store.
7
8/// The loaded patterns shared by `oo rewrite` and the hook processors: the
9/// canonical set assembled in [`crate::pattern_load`] (project-local, then
10/// user config, then builtins), loaded once per process. The assembly lives
11/// in the leaf `pattern_load` module so this module stays a pure rewriter
12/// with no dependency on the dispatch layer.
13pub(crate) use crate::pattern_load::PATTERNS;
14
15use crate::pattern::{self, Pattern};
16
17/// Maximum command length (bytes) accepted by [`rewrite`]. Realistic shell
18/// commands stay well below this; the bound caps the worst-case regex work
19/// done over attacker-influenced input.
20const MAX_REWRITE_INPUT_BYTES: usize = 16 * 1024;
21
22/// Rewrite `command` if at least one of its shell segments has an oo pattern.
23///
24/// Returns `None` (→ exit 1, no output) when the command is empty/blank,
25/// longer than [`MAX_REWRITE_INPUT_BYTES`], contains an unterminated quote or
26/// a trailing lone backslash (neither can be re-parsed identically), or
27/// contains a construct we refuse to reason about: pipes, redirections,
28/// heredocs, command substitution, or background `&`.
29///
30/// The input is a **raw shell string**, and the printed output is meant to be
31/// shell-executed by the hook. The scanner is quote- and backslash-aware:
32/// quote content is opaque to the refused-construct scan (a quoted
33/// `|`/`&&`/`$()` does not trigger the refusal), and outside single quotes a
34/// backslash escapes the next character (an escaped quote character does not
35/// toggle quoting, an escaped `&` is not a background operator, and two
36/// backslash-escaped `&` characters are not a separator). Inside single
37/// quotes a backslash is literal. The exact original text — backslashes
38/// included — is preserved byte-for-byte in the rewritten output, so the
39/// output is guaranteed to re-parse identically under POSIX shell quoting
40/// rules only.
41///
42/// Matching is **unanchored and per segment**: a segment is rewritten when
43/// *any* pattern's `command_match` regex matches *anywhere* in the segment's
44/// text — the same patterns and the same unanchored matching as
45/// `oo <command>` (see [`rewrite_segment`]). Quoted text is not excluded
46/// from matching.
47pub fn rewrite(command: &str, patterns: &[Pattern]) -> Option<String> {
48 let trimmed = command.trim();
49 if trimmed.is_empty() || trimmed.len() > MAX_REWRITE_INPUT_BYTES {
50 return None;
51 }
52
53 // One quote-aware pass over the command: it both rejects refused
54 // constructs and splits on `&&`/`||`/`;` separators (outside quotes), so
55 // the input is scanned exactly once per call.
56 let split = split_segments(trimmed)?;
57 let mut out = String::with_capacity(trimmed.len());
58 let mut changed = false;
59
60 for (seg_text, sep_after) in split.iter() {
61 let rewritten = rewrite_segment(seg_text, patterns);
62 match &rewritten {
63 Some(r) => {
64 changed = true;
65 out.push_str(r);
66 out.push_str(sep_after);
67 }
68 None => {
69 out.push_str(seg_text);
70 out.push_str(sep_after);
71 }
72 }
73 }
74
75 changed.then_some(out)
76}
77
78/// Rewrite a single (trimmed) shell segment, preserving a leading `VAR=value`
79/// environment prefix, or `None` when the segment needs no rewriting.
80///
81/// The segment text is preserved byte-for-byte: the only modification is the
82/// inserted `oo ` prefix, spliced at the end of the last leading `VAR=value`
83/// token (or byte 0 when there is no env prefix) — so internal whitespace,
84/// quotes, newlines, and tabs are all kept exactly as written.
85///
86/// Matching is unanchored: the segment is rewritten when any pattern's
87/// `command_match` regex matches anywhere in the env-stripped rest (this is
88/// the same matching semantics as `oo <command>`, so a segment like
89/// `nope pytest -q` *is* rewritten). Quoted text is not excluded from
90/// matching. Whitespace-normalizing the env-stripped rest is acceptable for
91/// matching purposes only — the output is never rebuilt from it.
92///
93/// Tokens are located with a quote- and backslash-aware scan of the original
94/// segment (see [`scan_tokens`]): a leading `VAR=value` token whose value
95/// contains a quote character or a backslash is refused (the function returns
96/// `None` and the caller exits 1), because such a value would need a full
97/// shell tokenizer to re-emit safely, and we choose the conservative option.
98fn rewrite_segment(segment: &str, patterns: &[Pattern]) -> Option<String> {
99 // A bare newline (or carriage return) outside quotes is not a command
100 // separator (only `&&`/`||`/`;` are), so the segment spans multiple
101 // lines. Rewriting it would join lines into one, which changes semantics.
102 // Refuse conservatively.
103 // A bare newline (or carriage return) *outside* quotes is not a command
104 // separator (only `&&`/`||`/`;` are), so the segment spans multiple
105 // lines. Rewriting it would join lines into one, which changes semantics.
106 // Refuse conservatively. Quoted newlines are fine (they are part of a
107 // single token and preserved verbatim).
108 if has_unquoted_newline(segment) {
109 return None;
110 }
111 let tokens = scan_tokens(segment)?;
112 let first = tokens
113 .first()
114 .map(|t| segment[t.start..t.end].to_owned())
115 .filter(|w| !w.is_empty())?;
116 if first == "oo" {
117 return None;
118 }
119 // Count the leading `NAME=value` tokens; `rest_start` is the byte offset
120 // where `oo ` is inserted (start of the first non-env token, or 0 when
121 // there is no env prefix).
122 let mut env_count = 0;
123 for (i, t) in tokens.iter().enumerate() {
124 let word = &segment[t.start..t.end];
125 let is_env = word
126 .split_once('=')
127 .is_some_and(|(name, _)| is_valid_var_name(name));
128 if !is_env {
129 env_count = i;
130 break;
131 }
132 env_count = i + 1;
133 }
134 // `rest_start` is where `oo ` is inserted: the start of the first
135 // non-env token, or byte 0 when the segment has no env prefix.
136 let rest_start = if env_count == 0 {
137 0
138 } else if env_count < tokens.len() {
139 tokens[env_count].start
140 } else {
141 return None; // all tokens are env: nothing to rewrite
142 };
143 let rest = normalize_whitespace(&segment[rest_start..]);
144 // The `regex` crate guarantees linear-time matching, so unanchored
145 // pattern matching over user-supplied input is not a ReDoS vector.
146 pattern::find_matching(&rest, patterns)?;
147 // The output is the original segment with `oo ` spliced in at
148 // `rest_start`; everything else is copied verbatim.
149 let mut out = String::with_capacity(segment.len() + 3);
150 out.push_str(&segment[..rest_start]);
151 out.push_str("oo ");
152 out.push_str(&segment[rest_start..]);
153 Some(out)
154}
155
156/// True when `name` is a valid shell variable name: non-empty and made up
157/// solely of ASCII alphanumerics and underscores.
158fn is_valid_var_name(name: &str) -> bool {
159 !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
160}
161
162/// A token's byte range within its source string.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164struct TokenRange {
165 start: usize,
166 end: usize,
167}
168
169/// Scan `segment` into byte-ranged tokens, honoring the same quote and
170/// backslash rules as [`split_segments`]: outside single quotes a backslash
171/// escapes the next character; inside single quotes a backslash is literal.
172/// A quoted region (single or double) is consumed as part of the token it
173/// belongs to, so whitespace inside quotes does not delimit tokens — this is
174/// what makes a quoted newline inside a segment stay part of one token.
175///
176/// A leading `VAR=value` token whose value contains a quote or backslash is
177/// refused (returns `None`): such a value would need a full shell tokenizer
178/// to re-emit safely, and we choose the conservative option (documented in
179/// [`rewrite_segment`]).
180///
181/// Returns `None` when the scan hits an unterminated quote or a trailing
182/// lone backslash — the same conservative refusal as [`split_segments`].
183fn scan_tokens(segment: &str) -> Option<Vec<TokenRange>> {
184 let chars: Vec<(usize, char)> = segment.char_indices().collect();
185 let n = chars.len();
186 let mut tokens = Vec::new();
187 let mut i = 0usize;
188 while i < n {
189 let (byte_idx, c) = chars[i];
190 if c.is_whitespace() {
191 i += 1;
192 continue;
193 }
194 // Start of a token at byte offset `byte_idx`.
195 let token_start = byte_idx;
196 let token_end = scan_token(&chars, &mut i)?;
197 tokens.push(TokenRange {
198 start: token_start,
199 end: token_end,
200 });
201 // `i` now points at the first char after the token (whitespace or
202 // end); the outer loop skips whitespace.
203 }
204 Some(tokens)
205}
206
207/// Scan one token starting at `chars[*i]` (a non-whitespace char) and return
208/// the byte offset where the token ends (exclusive). Advances `*i` to the
209/// first character after the token. Quote and backslash handling mirrors
210/// [`split_segments`]: a backslash outside single quotes escapes the next
211/// char; a quoted region is consumed whole. A quote or backslash appearing
212/// inside a `NAME=value` env token is refused — `None`.
213fn scan_token(chars: &[(usize, char)], i: &mut usize) -> Option<usize> {
214 let n = chars.len();
215 let start = chars[*i].0;
216 let mut in_sq = false;
217 let mut in_dq = false;
218 let mut escaped = false;
219 let mut env_value = false;
220 let mut j = *i;
221 while j < n {
222 let (_, c) = chars[j];
223 if escaped {
224 escaped = false;
225 j += 1;
226 continue;
227 }
228 if in_sq {
229 if c == '\'' {
230 in_sq = false;
231 j += 1;
232 continue;
233 }
234 j += 1;
235 continue;
236 }
237 if in_dq {
238 if c == '\\' {
239 escaped = true;
240 j += 1;
241 continue;
242 }
243 if c == '"' {
244 in_dq = false;
245 j += 1;
246 continue;
247 }
248 j += 1;
249 continue;
250 }
251 // Outside quotes.
252 if c == '\'' {
253 if env_value {
254 return None; // env value with a quote: refused
255 }
256 in_sq = true;
257 j += 1;
258 continue;
259 }
260 if c == '"' {
261 if env_value {
262 return None; // env value with a quote: refused
263 }
264 in_dq = true;
265 j += 1;
266 continue;
267 }
268 if c == '\\' {
269 if env_value {
270 return None; // env value with a backslash: refused
271 }
272 escaped = true;
273 j += 1;
274 continue;
275 }
276 if c == '=' && !env_value && is_env_token_prefix(chars, j) {
277 // This is a `NAME=value` token; the value half is opaque. Any
278 // quote or backslash inside the value is refused.
279 env_value = true;
280 j += 1;
281 continue;
282 }
283 // A plain whitespace ends the token.
284 if c.is_whitespace() {
285 break;
286 }
287 j += 1;
288 }
289 *i = j;
290 // The token ends where the next char starts (or at the end of the string).
291 let end = if *i < n {
292 chars[*i].0
293 } else {
294 segment_len(chars)
295 };
296 Some(end.max(start))
297}
298
299/// True when the token containing `chars[i]` (i.e. the run of non-whitespace
300/// chars ending at `=`) is a `NAME=value` env token: a valid variable name
301/// followed by `=`. The name must contain no `=`, quotes, backslashes, or
302/// whitespace.
303fn is_env_token_prefix(chars: &[(usize, char)], i: usize) -> bool {
304 // Walk back over the token's name portion (all non-whitespace chars
305 // before `chars[i]` in the current token).
306 let mut j = i;
307 while j > 0 {
308 let jc = chars[j - 1].1;
309 if jc.is_whitespace() || jc == '=' || jc == '\'' || jc == '"' || jc == '\\' {
310 return false;
311 }
312 j -= 1;
313 }
314 true
315}
316
317/// True when `segment` contains a newline or carriage return *outside* single
318/// or double quotes. A bare (unquoted) newline inside a segment is not a
319/// command separator (only `&&`/`||`/`;` are), so a segment that spans
320/// multiple lines via an unquoted newline cannot be safely rewritten — the
321/// rewrite would join lines into one, changing semantics. Quoted newlines
322/// are part of a single token and are preserved verbatim.
323fn has_unquoted_newline(segment: &str) -> bool {
324 let mut quote: Option<char> = None;
325 let mut escaped = false;
326 for c in segment.chars() {
327 if escaped {
328 escaped = false;
329 continue;
330 }
331 match quote {
332 Some('"') => {
333 if c == '\\' {
334 escaped = true;
335 } else if c == '"' {
336 quote = None;
337 }
338 }
339 Some('\'') if c == '\'' => quote = None,
340 Some('\'') => {}
341 None => {
342 if c == '\n' || c == '\r' {
343 return true;
344 }
345 if c == '\'' || c == '"' {
346 quote = Some(c);
347 } else if c == '\\' {
348 escaped = true;
349 }
350 }
351 _ => {}
352 }
353 }
354 false
355}
356
357/// Collapse internal whitespace runs to a single space for matching purposes
358/// only (the output is never rebuilt from this string).
359fn normalize_whitespace(s: &str) -> String {
360 s.split_whitespace().collect::<Vec<_>>().join(" ")
361}
362
363/// Total byte length of the string described by `chars`.
364fn segment_len(chars: &[(usize, char)]) -> usize {
365 chars.last().map(|(idx, c)| idx + c.len_utf8()).unwrap_or(0)
366}
367
368/// Split `command` into (trimmed_segment, separator_after) pairs on `&&`,
369/// `||`, and `;` outside single or double quotes, refusing (returning `None`)
370/// when the command contains any construct [`rewrite`] won't reason about:
371/// pipes, redirections, heredocs, command substitution, or background `&`.
372///
373/// The scanner is **backslash-aware**, mirroring POSIX shell quoting:
374/// outside single quotes, a backslash escapes the next character — the
375/// escaped character is skipped for quote toggling, separator detection, and
376/// the refused-construct scan (so an escaped quote character in
377/// `pytest <backslash><double-quote>x && cargo build` does not open a quote
378/// and the `&&` is a real separator, while `echo <backslash>&<backslash>&
379/// pytest` is not split). Inside single quotes a backslash is literal (the
380/// quote itself ends the quoted region). A trailing lone backslash (its
381/// escape has no target) and an unterminated quote both yield `None` —
382/// conservative refusal, since neither re-parses identically.
383///
384/// The separator is the canonical form with a single leading space:
385/// `" && "`, `" || "`, or `" ; "`. The last segment has an empty separator.
386///
387/// The scan works over `char_indices` with a two-element lookahead (no
388/// intermediate `Vec`), so every slice of `command` lands on a valid UTF-8
389/// boundary regardless of multi-byte characters in the input — the function
390/// cannot panic for any UTF-8 string.
391fn split_segments(command: &str) -> Option<Vec<(String, &'static str)>> {
392 let mut iter = command.char_indices().peekable();
393 let mut result: Vec<(String, &'static str)> = Vec::new();
394 let mut start = 0usize;
395 let mut quote: Option<char> = None;
396
397 while let Some((byte_idx, c)) = iter.next() {
398 match quote {
399 // Inside double quotes, a backslash escapes the next character
400 // (an escaped double-quote does not close the quote).
401 Some('"') if c == '\\' => {
402 iter.peek()?;
403 iter.next();
404 }
405 Some(q) if c == q => quote = None,
406 Some(_) => {}
407 None if c == '\'' || c == '"' => quote = Some(c),
408 None if c == '\\' => {
409 // Outside single quotes a backslash escapes the next
410 // character, whatever it is. A trailing lone backslash has
411 // no target character, so the input is refused.
412 iter.peek()?;
413 iter.next();
414 }
415 None => {
416 let next = iter.peek().map(|(_, c)| *c);
417 // Separator checks first: `&&`/`||` are split points, not
418 // refused constructs (a lone `|` or `&` is refused below).
419 if c == '&' && next == Some('&') {
420 result.push((command[start..byte_idx].trim().to_string(), " && "));
421 // The next segment starts after both separator bytes.
422 start = byte_idx + 2;
423 iter.next();
424 continue;
425 }
426 if c == '|' && next == Some('|') {
427 result.push((command[start..byte_idx].trim().to_string(), " || "));
428 start = byte_idx + 2;
429 iter.next();
430 continue;
431 }
432 if c == ';' {
433 result.push((command[start..byte_idx].trim().to_string(), " ; "));
434 start = byte_idx + 1;
435 continue;
436 }
437 // Anything else is a refused construct: the command cannot be
438 // rewritten safely.
439 if c == '|' || c == '<' || c == '>' || c == '&' {
440 return None;
441 }
442 if (c == '$' && next == Some('(')) || c == '`' {
443 return None;
444 }
445 }
446 }
447 }
448 if quote.is_some() {
449 return None;
450 }
451 result.push((command[start..].trim().to_string(), ""));
452 Some(result)
453}
454
455/// Tests live in `rewrite_tests.rs` and `rewrite_ws_tests.rs` (sibling
456/// modules, see `#[path]` below) — this file holds only production code to
457/// stay under the 500-line cap.
458#[cfg(test)]
459#[path = "rewrite_tests.rs"]
460mod tests;
461
462#[cfg(test)]
463#[path = "rewrite_ws_tests.rs"]
464mod ws_tests;