Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,29 @@ impl Interpreter {
} else {
None
};

// Suspend output callback while output redirects are active
// so that maybe_emit_output inside the compound body does not
// leak output that will be redirected (e.g. `{ cmd; } 2>/dev/null`).
let has_output_redirect = redirects.iter().any(|r| {
!matches!(
r.kind,
RedirectKind::Input | RedirectKind::HereDoc | RedirectKind::HereString
)
});
let saved_callback = if has_output_redirect {
self.output_callback.take()
} else {
None
};

let result = self.execute_compound(compound).await?;

// Restore callback before applying redirections
if let Some(cb) = saved_callback {
self.output_callback = Some(cb);
}

if let Some(prev) = prev_pipeline_stdin {
self.pipeline_stdin = prev;
}
Expand Down Expand Up @@ -3593,7 +3615,8 @@ impl Interpreter {
let err_msg = first.trim_start_matches("\x00ERR\x00").to_string();
self.last_exit_code = 1;
self.restore_variables(var_saves);
return Ok(ExecResult::err(err_msg, 1));
let result = ExecResult::err(err_msg, 1);
return self.apply_redirections(result, &command.redirects).await;
}

let xtrace_line = self.build_xtrace_line(&name, &args);
Expand Down Expand Up @@ -10853,4 +10876,26 @@ echo "count=$COUNT"
let result = run_script(r#"cat <( x=5; (( x > 3 )) && echo YES )"#).await;
assert_eq!(result.stdout.trim(), "YES");
}

#[tokio::test]
async fn test_stderr_redirect_devnull_simple_and_compound() {
// Issue #1116: 2>/dev/null must suppress stderr from builtins
let result = run_script("ls /nonexistent 2>/dev/null; echo exit:$?").await;
assert_eq!(result.stderr, "", "simple: stderr should be suppressed");
assert_eq!(result.stdout.trim(), "exit:2");

// Compound command
let result = run_script("{ ls /nonexistent; } 2>/dev/null; echo exit:$?").await;
assert_eq!(result.stderr, "", "compound: stderr should be suppressed");
assert_eq!(result.stdout.trim(), "exit:2");

// &>/dev/null
let result = run_script("ls /nonexistent &>/dev/null; echo exit:$?").await;
assert_eq!(result.stderr, "", "&>: stderr should be suppressed");
assert_eq!(result.stdout.trim(), "exit:2");

// failglob + redirect
let result = run_script("shopt -s failglob; ls ./*.html 2>/dev/null; echo exit:$?").await;
assert_eq!(result.stderr, "", "failglob: stderr should be suppressed");
}
}
29 changes: 29 additions & 0 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5410,4 +5410,33 @@ echo missing fi"#,
// 25 doublings of 10 bytes = 335 544 320 without limits; must be capped ≤ 1024
assert!(len <= 1024, "string length {len} must be ≤ 1024");
}

/// Issue #1116: 2>/dev/null must suppress stderr in streaming mode
#[tokio::test]
async fn test_stderr_redirect_devnull_streaming() {
let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
let stderr_cb = stderr_chunks.clone();
let mut bash = Bash::new();

// Compound command — the main bug: callback fired before redirect applied
let result = bash
.exec_streaming(
"{ ls /nonexistent; } 2>/dev/null; echo exit:$?",
Box::new(move |_stdout, stderr| {
if !stderr.is_empty() {
stderr_cb.lock().unwrap().push(stderr.to_string());
}
}),
)
.await
.unwrap();

assert_eq!(result.stderr, "", "final stderr should be empty");
let stderr_chunks = stderr_chunks.lock().unwrap();
assert!(
stderr_chunks.is_empty(),
"no stderr should be streamed when 2>/dev/null is used, got: {:?}",
*stderr_chunks
);
}
}
24 changes: 24 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/pipes-redirects.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,30 @@ echo exit: $?
exit: 1
### end

### redirect_stderr_suppress_ls
# Suppress stderr from ls with 2>/dev/null (issue #1116)
ls /nonexistent 2>/dev/null
echo exit: $?
### expect
exit: 2
### end

### redirect_stderr_suppress_compound
# Suppress stderr from compound command with 2>/dev/null (issue #1116)
{ ls /nonexistent; } 2>/dev/null
echo exit: $?
### expect
exit: 2
### end

### redirect_combined_suppress_ls
# Suppress both stdout and stderr with &>/dev/null (issue #1116)
ls /nonexistent &>/dev/null
echo exit: $?
### expect
exit: 2
### end

### redirect_stderr_to_file_content
# Redirect stderr content to file and verify it
sleep abc 2>/tmp/sleep_err.txt
Expand Down
Loading