Skip to content

Commit 55c5df8

Browse files
brevity1swosclaude
andcommitted
feat(redos): optional ReDoS analysis via rxray (Ctrl+A)
Add an optional, default-off `redos` feature that depends on the rxray crate. Ctrl+A analyzes the current pattern for catastrophic backtracking; when vulnerable it reports the verdict (linear/polynomial/exponential) and loads a verified attack string into the test panel to step through in the debugger (Ctrl+D). The attack is loaded WITHOUT a live rematch so the UI never hangs on the DoS input. Default build/binary/publish are unchanged (rxray is an optional crates.io dependency, not pulled unless `--features redos`), so Road A holds for the core CLI. Ctrl+A also registered as a vim global shortcut. Feature-gated tests verify the wiring; clippy --all-features and fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3b91903 commit 55c5df8

5 files changed

Lines changed: 111 additions & 1 deletion

File tree

‎Cargo.lock‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ path = "src/lib.rs"
2424
[features]
2525
default = []
2626
pcre2-engine = ["dep:pcre2", "dep:pcre2-sys"]
27+
# Optional ReDoS analysis via the rxray crate (Ctrl+A). Default-off: the core
28+
# binary and its build are unchanged unless explicitly enabled.
29+
redos = ["dep:rxray"]
2730

2831
[dependencies]
2932
arboard = "3"
@@ -43,6 +46,7 @@ serde = { version = "1", features = ["derive"] }
4346
toml = "1.0"
4447
dirs = "6"
4548
unicode-width = "0.2"
49+
rxray = { version = "0.1", optional = true }
4650
anyhow = "1"
4751
tokio-stream = "0.1"
4852

‎src/app.rs‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,72 @@ impl App {
219219
self.rematch();
220220
}
221221

222+
/// Analyze the current pattern for ReDoS (Ctrl+A). When vulnerable, load a
223+
/// verified attack string into the test panel so it can be stepped through
224+
/// in the debugger (Ctrl+D). Requires the `redos` feature.
225+
pub fn analyze_redos(&mut self) {
226+
#[cfg(feature = "redos")]
227+
{
228+
use rxray::{analyze, attack, ComplexityClass, Engine};
229+
let pattern = self.regex_editor.content().to_string();
230+
if pattern.is_empty() {
231+
self.status.set("ReDoS: empty pattern".to_string());
232+
return;
233+
}
234+
let engine = match self.engine_kind {
235+
EngineKind::RustRegex => Engine::RustRegex,
236+
EngineKind::FancyRegex => Engine::FancyRegex,
237+
#[cfg(feature = "pcre2-engine")]
238+
EngineKind::Pcre2 => Engine::Pcre2,
239+
};
240+
// A modest pump count: dramatic in the step debugger, but bounded so
241+
// the normal rematch does not itself hang on the loaded attack.
242+
let load_attack = |app: &mut Self| -> bool {
243+
match attack(&pattern, engine, 12) {
244+
Some(a) => {
245+
// Load WITHOUT rematching: running the catastrophic input on
246+
// the live engine would freeze the UI. It is meant to be
247+
// stepped through in the debugger (Ctrl+D), which is bounded.
248+
app.test_editor = Editor::with_content(a.value);
249+
true
250+
}
251+
None => false,
252+
}
253+
};
254+
match analyze(&pattern, engine) {
255+
Ok(report) => match report.worst {
256+
ComplexityClass::Linear => {
257+
self.status.set("ReDoS: linear — safe".to_string());
258+
}
259+
ComplexityClass::Polynomial(k) => {
260+
let loaded = load_attack(self);
261+
self.status.set(if loaded {
262+
format!("ReDoS: polynomial O(n^{k}) — attack loaded, Ctrl+D to step")
263+
} else {
264+
format!("ReDoS: polynomial O(n^{k})")
265+
});
266+
}
267+
ComplexityClass::Exponential => {
268+
let loaded = load_attack(self);
269+
self.status.set(if loaded {
270+
"ReDoS: EXPONENTIAL — attack loaded, Ctrl+D to step".to_string()
271+
} else {
272+
"ReDoS: EXPONENTIAL".to_string()
273+
});
274+
}
275+
},
276+
Err(e) => {
277+
self.status.set(format!("ReDoS: {e}"));
278+
}
279+
}
280+
}
281+
#[cfg(not(feature = "redos"))]
282+
{
283+
self.status
284+
.set("ReDoS analysis unavailable — rebuild with --features redos".to_string());
285+
}
286+
}
287+
222288
pub fn switch_engine(&mut self) {
223289
self.engine_kind = self.engine_kind.next();
224290
self.engine = engine::create_engine(self.engine_kind);
@@ -957,6 +1023,9 @@ impl App {
9571023
Action::ExportRegex101 => {
9581024
self.copy_regex101_url();
9591025
}
1026+
Action::AnalyzeRedos => {
1027+
self.analyze_redos();
1028+
}
9601029
Action::GenerateCode => {
9611030
self.overlay.codegen = true;
9621031
self.overlay.codegen_language_index = 0;
@@ -1217,3 +1286,27 @@ fn print_colored_replace(output: &str, segments: &[engine::ReplaceSegment]) {
12171286
println!();
12181287
}
12191288
}
1289+
1290+
#[cfg(all(test, feature = "redos"))]
1291+
mod redos_tests {
1292+
use super::*;
1293+
1294+
#[test]
1295+
fn ctrl_a_flags_exponential_and_loads_attack() {
1296+
let mut app = App::new(EngineKind::FancyRegex, EngineFlags::default());
1297+
app.set_pattern("(a+)+$");
1298+
app.analyze_redos();
1299+
let msg = app.status.text.clone().unwrap_or_default();
1300+
assert!(msg.contains("EXPONENTIAL"), "got: {msg}");
1301+
assert!(!app.test_editor.content().is_empty(), "attack not loaded");
1302+
}
1303+
1304+
#[test]
1305+
fn ctrl_a_marks_safe_pattern_safe() {
1306+
let mut app = App::new(EngineKind::FancyRegex, EngineFlags::default());
1307+
app.set_pattern("a+b+");
1308+
app.analyze_redos();
1309+
let msg = app.status.text.clone().unwrap_or_default();
1310+
assert!(msg.contains("safe"), "got: {msg}");
1311+
}
1312+
}

‎src/input/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ pub enum Action {
6464
EnterInsertModeLineEnd,
6565
EnterNormalMode,
6666
ToggleDebugger,
67+
AnalyzeRedos,
6768
Quit,
6869
None,
6970
}
@@ -104,6 +105,7 @@ pub fn key_to_action(key: KeyEvent) -> Action {
104105
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
105106
Action::ToggleDebugger
106107
}
108+
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::AnalyzeRedos,
107109
KeyCode::Char('i') if key.modifiers.contains(KeyModifiers::ALT) => {
108110
Action::ToggleCaseInsensitive
109111
}

‎src/input/vim.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ const fn is_global_shortcut(key: &KeyEvent) -> bool {
5252
return matches!(
5353
key.code,
5454
KeyCode::Char(
55-
'd' | 'e'
55+
'a' | 'd'
56+
| 'e'
5657
| 'z'
5758
| 'Z'
5859
| 'y'

0 commit comments

Comments
 (0)