diff --git a/brush-core/src/gate.rs b/brush-core/src/gate.rs index 8aff87f..49404b7 100644 --- a/brush-core/src/gate.rs +++ b/brush-core/src/gate.rs @@ -91,6 +91,18 @@ pub struct PipeEvent { pub from_index: usize, /// Zero-based index of the consuming stage. pub to_index: usize, + /// The producing stage as written, rendered from its AST at spawn time. + /// + /// Carried on the link itself so an observer never has to *infer* which command fed a pipe + /// from the exec events around it: a stage that is a compound command (`{ a; b; } | c`) emits + /// one exec per command inside it, and none of them says which owned the pipe. Rendering the + /// stage answers it outright — and does so at spawn, before the producer has even exited. + /// + /// It is also the *pre-expansion* text, so `curl -H "Bearer $TOKEN" | sh` names the stage with + /// `$TOKEN` rather than the expanded secret an argv-derived name would carry. + pub from_text: String, + /// The consuming stage as written. See [`PipeEvent::from_text`]. + pub to_text: String, /// Total bytes that crossed the link. pub total_bytes: u64, /// A bounded prefix of the bytes (observer applies its own caps/redaction). diff --git a/brush-core/src/interp.rs b/brush-core/src/interp.rs index 4acfddc..9cb0091 100644 --- a/brush-core/src/interp.rs +++ b/brush-core/src/interp.rs @@ -518,7 +518,17 @@ async fn spawn_pipeline_processes( let (b_read, b_write) = std::io::pipe()?; // Link `link` connects producer stage (len-2-link) → consumer (len-1-link). let from_index = pipeline_len - 2 - link; - nash_spawn_pipe_tee(a_read, b_write, pipeline_id, from_index, from_index + 1); + // Name both ends from their AST *here*, where the stage is unambiguous. Deriving + // the name later from exec events cannot distinguish which command inside a + // compound stage owned the pipe, and cannot name either end until it exits. + nash_spawn_pipe_tee( + a_read, + b_write, + pipeline_id, + from_index, + nash_stage_text(pipeline, from_index), + nash_stage_text(pipeline, from_index + 1), + ); pipe_readers.push(Some(openfiles::OpenFile::PipeReader(b_read))); pipe_writers.push(Some(openfiles::OpenFile::PipeWriter(a_write))); continue; @@ -1964,17 +1974,45 @@ pub(crate) async fn setup_redirect( // nash: bounded prefix captured per pipe link before reporting (docs/NASH.md §5.3). const NASH_PIPE_CAPTURE_CAP: usize = 64 * 1024; +// nash: longest stage label carried on a pipe event. A stage can be an entire `while` loop, and +// this rides on every link of every pipeline — one of the highest-volume kinds nash emits. +const NASH_STAGE_TEXT_CAP: usize = 200; + +// nash: the pipeline stage at `index`, rendered back to shell syntax to label a pipe link +// (docs/NASH.md §5.3). +// +// Collapsing and capping happen here rather than in the observer because this is a *label*, and +// only brush-core has the AST to render one; redaction still happens observer-side with every +// other outbound string. `Display` re-renders the parsed command, so the result is the stage as +// parsed rather than byte-identical source — which is what a one-line label wants anyway. +fn nash_stage_text(pipeline: &ast::Pipeline, index: usize) -> String { + let Some(command) = pipeline.seq.get(index) else { + return String::new(); + }; + // A compound stage renders multi-line; a label has one line. + let collapsed = command.to_string(); + let mut text = collapsed.split_whitespace().collect::>().join(" "); + if text.chars().count() > NASH_STAGE_TEXT_CAP { + text = text.chars().take(NASH_STAGE_TEXT_CAP).collect::(); + text.push('…'); + } + text +} + // nash: spawn a copier that moves bytes from `src` (producer's output) to `dst` // (consumer's input), mirroring a bounded prefix, then reports the link to the // gate (docs/NASH.md §5.3). The synchronous copy preserves pipe backpressure; // EOF on `src` or EPIPE on `dst` (consumer gone, e.g. `yes | head`) ends it and // closes `dst` so the consumer sees EOF. +// `to_index` is always `from_index + 1` — a link joins adjacent stages — so it is derived rather +// than passed; `from_text`/`to_text` are the two stages already rendered by `nash_stage_text`. fn nash_spawn_pipe_tee( mut src: std::io::PipeReader, mut dst: std::io::PipeWriter, pipeline_id: u64, from_index: usize, - to_index: usize, + from_text: String, + to_text: String, ) { std::thread::Builder::new() .name("nash-pipe-tee".into()) @@ -2013,7 +2051,9 @@ fn nash_spawn_pipe_tee( crate::gate::gate().on_pipe(crate::gate::PipeEvent { pipeline_id, from_index, - to_index, + to_index: from_index + 1, + from_text, + to_text, total_bytes: total, captured, truncated,