Skip to content

feat(shell): stderr redirects (2>, 2>>) and 2>&1 via std::io::pipe#48

Merged
hartsock merged 1 commit into
mainfrom
issue-45/stderr-redirects
Jun 25, 2026
Merged

feat(shell): stderr redirects (2>, 2>>) and 2>&1 via std::io::pipe#48
hartsock merged 1 commit into
mainfrom
issue-45/stderr-redirects

Conversation

@hartsock

Copy link
Copy Markdown
Member

Closes #45 (a #34 follow-up). Adds stderr fd redirects to the confined engine — 2> / 2>> (to file) and 2>&1 (merge into stdout) — via a shared pipe with no new dependency.

The shared pipe

std::io::pipe() is stable on our toolchain (Rust 1.87+; we're on 1.96). run_pipeline is rewritten onto it for all non-file stdout wiring, so the writer can be cloned and handed to both the child's stdout and stderr → a real 2>&1 merge that works in every position: captured last stage, file-redirected, and piped-to-next (cmd 2>&1 | next). The parent keeps no pipe writers after spawn, so capture readers hit EOF on child exit (deadlock-free). No os_pipe crate.

What

  • parse.rsread_fd_redirect: 1>/1>>/0< alias the bare forms; 2>/2>>Redirect::Stderr; 2>&1Redirect::StderrToStdout. fd ≥ 3 and other dups (1>&2) refused. Command::stderr_disposition() resolves the effective target (last wins): Capture / File / merge-to-Stdout.
  • shell_tool.rs — the std::io::pipe rewrite; 2> file targets leash-checked (fs_write) in invoke before any spawn.

Testing (fully mocked + deep)

  • Parser: 2>/2>>/2>&1 parsed; 1>/0< aliases; > out 2>&1 both-to-file; head -2 stays an arg (digit not touching a redirect op); 3> / 1>&2 / 2<&1 refused.
  • Mocked: 2> /etc/passwd denied before spawn; 2>&1 reaches the spawner.
  • Integration (real): 2> to a file; 2>&1 merges the error into captured stdout; 2>&1 | cat feeds stderr downstream.

Test plan

just check green (fmt + clippy all-features & no-default-features + workspace tests); every prior pipeline test still passes (the rewrite preserved basic piping).

🤖 Generated with Claude Code

WHAT: The confined engine now supports stderr fd redirects. parse.rs: read_fd_redirect parses a bare fd touching a redirect op — 1>/1>>/0< alias the bare forms, 2>/2>> add Redirect::Stderr{path,append}, 2>&1 adds Redirect::StderrToStdout; fd>=3 and other dups (1>&2) refused. Command::stderr_disposition() resolves the effective stderr target (last wins): Capture | File | Stdout(merge).

WHY (shared pipe, no new dep): run_pipeline is rewritten onto std::io::pipe() (stable since Rust 1.87; toolchain is 1.96) for all non-file stdout wiring, so the writer can be CLONED for 2>&1 in any position — captured last stage, file-redirected, AND piped-to-next. The clone is moved into the child's stderr; the parent retains no pipe writers, so capture readers hit EOF on child exit (deadlock-free). 2> file targets are leash-checked (fs_write) in invoke before any spawn, like other redirects. No os_pipe crate.

TEST: parser (2>/2>>/2>&1 parsed; 1>/0< aliases; >file 2>&1 both-to-file; head -2 stays an arg; fd>=3 / 1>&2 / 2<&1 refused) + mocked (2> /etc/passwd denied before spawn; 2>&1 reaches spawner) + real (2> to a file; 2>&1 merges into captured stdout; 2>&1 before a pipe feeds stderr downstream). just check green; all prior pipeline tests still pass (the std::io::pipe rewrite preserved basic piping). Fixes #45.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01HMGPEApE4XfwgMhgFbRn6c
@hartsock hartsock added the risk:low Low-risk change label Jun 25, 2026
@hartsock hartsock merged commit 7c0f06d into main Jun 25, 2026
1 check passed
@hartsock hartsock deleted the issue-45/stderr-redirects branch June 25, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:low Low-risk change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

shell: stderr redirects (2>, 2>>) and the 2>&1 merge via std::io::pipe

1 participant