Skip to main content

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;