From e3432b76108d179c47c1400392a005d3448d3bfe Mon Sep 17 00:00:00 2001 From: Anubhav Date: Sun, 25 Oct 2026 19:59:30 +0530 Subject: [PATCH] expr: anchor every top-level alternative of a regex --- src/uu/expr/src/syntax_tree.rs | 30 ++++++++++++++++++++++++------ tests/by-util/test_expr.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/uu/expr/src/syntax_tree.rs b/src/uu/expr/src/syntax_tree.rs index 2121fa34a6c..3bbfc54f6da 100644 --- a/src/uu/expr/src/syntax_tree.rs +++ b/src/uu/expr/src/syntax_tree.rs @@ -275,11 +275,6 @@ fn transpile_bre_to_ere(pattern_str: &str) -> ExprResult { let mut is_start_of_expression = true; let mut after_anchor_caret = false; - // All patterns are anchored so they begin with a caret (^) - if pattern_chars.peek() != Some(&'^') { - re_string.push('^'); - } - while let Some(curr) = pattern_chars.next() { let curr_is_escaped = prev == '\\' && !prev_is_escaped; let mut next_is_start = false; @@ -425,7 +420,9 @@ fn build_regex(pattern_bytes: Vec) -> ExprResult { let re_string = transpile_bre_to_ere(&pattern_str)?; - RegexBuilder::new(&format!("(?s){re_string}")) + // Patterns are anchored at the start of the string. The group makes the + // anchor apply to every top-level `\|` alternative, not just the first one. + RegexBuilder::new(&format!("(?s)^(?:{re_string})")) .oniguruma_mode(true) .leftmost_longest(true) .build() @@ -1504,6 +1501,27 @@ mod test { let result = evaluate_match_expression(b"world".to_vec(), b"world$".to_vec()).unwrap(); assert_eq!(result.eval_as_string(), b"5"); + + // Every top-level alternative is anchored, not just the first one + let result = evaluate_match_expression(b"xab".to_vec(), b"a\\|b".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"0"); + + let result = evaluate_match_expression(b"xab".to_vec(), b"^a\\|b".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"0"); + + let result = evaluate_match_expression( + b"evil status".to_vec(), + b"start$\\|stop$\\|status$".to_vec(), + ) + .unwrap(); + assert_eq!(result.eval_as_string(), b"0"); + + let result = + evaluate_match_expression(b"zabc".to_vec(), b"x\\|\\(abc\\)".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b""); + + let result = evaluate_match_expression(b"bx".to_vec(), b"a\\|b".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"1"); } #[test] diff --git a/tests/by-util/test_expr.rs b/tests/by-util/test_expr.rs index 9cb2a118d9c..7c1dca7310b 100644 --- a/tests/by-util/test_expr.rs +++ b/tests/by-util/test_expr.rs @@ -288,6 +288,38 @@ fn test_regex_trailing_backslash() { .stderr_only("expr: Trailing backslash\n"); } +#[test] +fn test_regex_alternation_is_anchored() { + new_ucmd!() + .args(&["xab", ":", "a\\|b"]) + .fails_with_code(1) + .stdout_only("0\n"); + new_ucmd!() + .args(&["xab", ":", "^a\\|b"]) + .fails_with_code(1) + .stdout_only("0\n"); + new_ucmd!() + .args(&["file:///etc/shadow#ssh://", ":", "https://\\|ssh://"]) + .fails_with_code(1) + .stdout_only("0\n"); + new_ucmd!() + .args(&["evil status", ":", "start$\\|stop$\\|status$"]) + .fails_with_code(1) + .stdout_only("0\n"); + new_ucmd!() + .args(&["zabc", ":", "x\\|\\(abc\\)"]) + .fails_with_code(1) + .stdout_only("\n"); + new_ucmd!() + .args(&["match", "xab", "a\\|b"]) + .fails_with_code(1) + .stdout_only("0\n"); + new_ucmd!() + .args(&["ssh://host", ":", "https://\\|ssh://"]) + .succeeds() + .stdout_only("6\n"); +} + #[test] fn test_regex_caret() { new_ucmd!()