fix(shell): preserve digit-suffixed commands in redirect stripping

This commit is contained in:
argenis de la rosa 2026-03-05 02:18:13 -05:00 committed by Argenis
parent cb1134ea44
commit 65fd9fdd7c

View File

@ -444,6 +444,15 @@ fn is_token_boundary_char(ch: char) -> bool {
ch.is_whitespace() || matches!(ch, ';' | '\n' | '|' | '&' | ')' | '(')
}
fn has_token_boundary_before(chars: &[char], index: usize) -> bool {
if index == 0 {
return true;
}
chars
.get(index - 1)
.is_some_and(|ch| is_token_boundary_char(*ch))
}
fn starts_with_literal(chars: &[char], start: usize, literal: &str) -> bool {
let literal_chars: Vec<char> = literal.chars().collect();
chars
@ -456,6 +465,12 @@ fn consume_stream_merge_redirect(chars: &[char], start: usize) -> Option<usize>
// - 2>&1
// - 1>&2
// - >&1
// `n>&m` should not consume trailing digits from command words
// (e.g. `python3>&1` should keep `python3`).
if chars[start].is_ascii_digit() && !has_token_boundary_before(chars, start) {
return None;
}
let mut i = start;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
@ -499,6 +514,9 @@ fn consume_dev_null_redirect(chars: &[char], start: usize) -> Option<usize> {
}
i += 1;
} else {
if chars[i].is_ascii_digit() && !has_token_boundary_before(chars, start) {
return None;
}
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
@ -1932,6 +1950,37 @@ mod tests {
assert_eq!(quoted, "echo '2>&1' \"|&\" '2>/dev/null'");
}
#[test]
fn strip_policy_preserves_command_trailing_digits_when_stripping() {
let p = SecurityPolicy {
shell_redirect_policy: ShellRedirectPolicy::Strip,
..default_policy()
};
let merged = p.apply_shell_redirect_policy("python3>&1 -V");
assert_eq!(merged, "python3 -V");
let devnull = p.apply_shell_redirect_policy("python3>/dev/null -V");
assert_eq!(devnull, "python3 -V");
let stdin_devnull = p.apply_shell_redirect_policy("python3</dev/null -V");
assert_eq!(stdin_devnull, "python3 -V");
}
#[test]
fn strip_policy_keeps_digit_suffixed_commands_allowlisted() {
let p = SecurityPolicy {
shell_redirect_policy: ShellRedirectPolicy::Strip,
allowed_commands: vec!["python3".into()],
..default_policy()
};
assert!(p.validate_command_execution("python3>&1 -V", false).is_ok());
assert!(p
.validate_command_execution("python3>/dev/null -V", false)
.is_ok());
}
#[test]
fn strip_policy_allows_normalized_stderr_redirects() {
let p = SecurityPolicy {