Skip to main content

eggup_core/
candidate.rs

1use std::fmt::Write as _;
2use std::io::{self, Read};
3use std::path::PathBuf;
4use std::process::{Command, Stdio};
5use std::sync::mpsc;
6use std::thread;
7use std::time::{Duration, Instant};
8
9use crate::domain::{CommitOwnership, MemberId};
10use crate::error::{Error, RecoveryError, RecoveryResult, Result};
11use crate::integrity::{IntegrityStatus, VerifiedTransaction};
12use crate::transaction::{PostCommitFailurePolicy, TransactionReceipt};
13
14const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
15const DEFAULT_OUTPUT_LIMIT: usize = 64 * 1024;
16
17/// The maximum number of bytes one bounded stdin payload may carry.
18///
19/// The bound is **inclusive**: a payload of exactly `MAX_STDIN_BYTES` is
20/// accepted. A larger payload is rejected by [`run_bounded`] with
21/// [`Error::InvalidInput`] **before any child is spawned**, so the bound fails
22/// closed and no process is created for an oversized payload.
23///
24/// The bound is a single per-call bound on the whole payload. It is separate
25/// from [`CommandSpec::max_output_bytes`], which bounds each captured output
26/// stream independently.
27pub const MAX_STDIN_BYTES: usize = 1024 * 1024;
28
29/// An argv-based candidate command with bounded execution policy.
30#[derive(Debug, Clone)]
31pub struct CommandSpec {
32    program: PathBuf,
33    args: Vec<String>,
34    current_dir: Option<PathBuf>,
35    timeout: Duration,
36    max_output_bytes: usize,
37    environment: Vec<(String, String)>,
38    stdin_data: Option<Vec<u8>>,
39}
40
41impl CommandSpec {
42    /// Creates a command that never invokes a shell.
43    pub fn new(program: impl Into<PathBuf>) -> Self {
44        Self {
45            program: program.into(),
46            args: Vec::new(),
47            current_dir: None,
48            timeout: DEFAULT_TIMEOUT,
49            max_output_bytes: DEFAULT_OUTPUT_LIMIT,
50            environment: Vec::new(),
51            stdin_data: None,
52        }
53    }
54
55    /// Appends one literal argv argument.
56    pub fn arg(mut self, argument: impl Into<String>) -> Self {
57        self.args.push(argument.into());
58        self
59    }
60
61    /// Appends literal argv arguments.
62    pub fn args<I, S>(mut self, arguments: I) -> Self
63    where
64        I: IntoIterator<Item = S>,
65        S: Into<String>,
66    {
67        self.args.extend(arguments.into_iter().map(Into::into));
68        self
69    }
70
71    /// Sets the controlled child working directory.
72    pub fn current_dir(mut self, path: impl Into<PathBuf>) -> Self {
73        self.current_dir = Some(path.into());
74        self
75    }
76
77    /// Sets the maximum wall-clock execution time.
78    ///
79    /// The value is an unconditional maximum bound on the whole bounded
80    /// execution: it is evaluated whether or not the direct child has already
81    /// exited, so [`run_bounded`] always returns. The deadline starts once the
82    /// spawn has returned, so a blocking spawn is not covered by it.
83    ///
84    /// [`run_bounded`] states the exact bound, the teardown allowance, and
85    /// what a caller observes.
86    pub fn timeout(mut self, timeout: Duration) -> Self {
87        self.timeout = timeout;
88        self
89    }
90
91    /// Sets the per-stream output bound.
92    ///
93    /// The bound applies to each stream separately: stdout and stderr are
94    /// captured independently, so one run may capture up to twice the
95    /// configured value in total. A stream that exceeds the bound is reported
96    /// through [`CommandOutput::output_limited`] and its captured bytes are
97    /// discarded, so that stream is reported as empty.
98    pub fn max_output_bytes(mut self, limit: usize) -> Self {
99        self.max_output_bytes = limit;
100        self
101    }
102
103    /// Adds one explicit environment variable; inherited environment is cleared.
104    pub fn environment(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
105        self.environment.push((name.into(), value.into()));
106        self
107    }
108
109    /// Attaches a bounded stdin payload for the command to read.
110    ///
111    /// The builder is **infallible**: the bound is enforced at execution time,
112    /// not here, so an oversized payload is rejected by [`run_bounded`] before
113    /// any child is spawned rather than by this builder.
114    ///
115    /// Without a payload the child's stdin is null, exactly as before. With a
116    /// payload the child's stdin is a pipe, and a dedicated writer thread
117    /// writes the bytes and closes the pipe, so the command observes EOF when
118    /// the payload has been delivered. The writer is bounded by the same
119    /// deadline that bounds the output readers.
120    ///
121    /// The payload is bounded by [`MAX_STDIN_BYTES`], inclusively.
122    ///
123    /// [`run_bounded`]: crate::run_bounded
124    /// [`MAX_STDIN_BYTES`]: crate::MAX_STDIN_BYTES
125    pub fn stdin_data(mut self, data: impl Into<Vec<u8>>) -> Self {
126        self.stdin_data = Some(data.into());
127        self
128    }
129}
130
131/// A bounded child-process result with timeout and overflow state.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct CommandOutput {
134    exit_code: Option<i32>,
135    stdout: Vec<u8>,
136    stderr: Vec<u8>,
137    timed_out: bool,
138    output_limited: bool,
139}
140
141impl CommandOutput {
142    /// Returns the platform exit code when one was available.
143    pub fn exit_code(&self) -> Option<i32> {
144        self.exit_code
145    }
146
147    /// Returns bounded stdout bytes.
148    pub fn stdout(&self) -> &[u8] {
149        &self.stdout
150    }
151
152    /// Returns bounded stderr bytes.
153    pub fn stderr(&self) -> &[u8] {
154        &self.stderr
155    }
156
157    /// Returns whether the deadline was reached before the bounded execution completed.
158    ///
159    /// The direct child is killed only when it was still running. When it had
160    /// already exited — for example because a descendant still held the output
161    /// pipe open — the flag still reports that the deadline was reached and
162    /// the bounded execution did not complete, and the direct child's exit
163    /// code stays observable through [`Self::exit_code`].
164    pub fn timed_out(&self) -> bool {
165        self.timed_out
166    }
167
168    /// Returns whether the child was killed because an output stream exceeded its bound.
169    pub fn output_limited(&self) -> bool {
170        self.output_limited
171    }
172
173    /// Returns whether the child exited successfully without a timeout or overflow.
174    pub fn success(&self) -> bool {
175        !self.timed_out && !self.output_limited && self.exit_code == Some(0)
176    }
177}
178
179/// Executes a command with cleared environment, bounded stdin, bounded output, and kill/reap.
180///
181/// The wall-clock deadline set by [`CommandSpec::timeout`] is an unconditional
182/// maximum bound on the whole bounded execution. It is evaluated on every poll
183/// whether or not the direct child has already exited, so this call always
184/// returns. The deadline starts once the spawn has returned, so a blocking
185/// spawn is not covered by it.
186///
187/// Stdin is **null** unless the caller attached a payload with
188/// [`CommandSpec::stdin_data`]. An attached payload is bounded by
189/// [`MAX_STDIN_BYTES`], inclusively: a larger payload is rejected with
190/// [`Error::InvalidInput`] **before any child is spawned**, so no process is
191/// created for it. A payload within the bound is written by a dedicated writer
192/// thread, which closes the pipe when it ends, so the child observes EOF once
193/// the payload has been delivered. The writer thread is started only after the
194/// output readers are attached, so a child that fills stdout or stderr before
195/// draining its stdin cannot deadlock both sides.
196///
197/// The deadline bounds the stdin writer exactly as it bounds the output
198/// readers: when the deadline is reached, the direct child is killed, which
199/// breaks the child's stdin pipe **for a child that is still running and whose
200/// stdin pipe no descendant inherited**, and a writer that is still blocked in
201/// `write` is abandoned explicitly, exactly like a reader blocked on a pipe a
202/// surviving descendant holds. Killing the direct child does not break the
203/// pipe for a descendant that inherited it: that descendant keeps the write end
204/// open, so the writer stays blocked until the descendant exits or closes the
205/// pipe, and the writer is reclaimed only then. A bounded execution is not
206/// reported as complete until the writer has delivered its verdict, so a write
207/// failure is never lost behind a "the child exited" observation.
208///
209/// # The deadline and a stdin write failure
210///
211/// The deadline has priority over the writer's verdict. The writer stamps its
212/// verdict with the instant at which it observed the write's completion, so
213/// whether a write failure happened **before** or **after** the deadline is a
214/// fact about the write itself and never a fact about when this call happened
215/// to poll for the verdict:
216///
217/// - a write failure stamped **before** the deadline is an `Err`: the payload
218///   was not delivered, so the child is killed and reaped and the call fails
219///   closed rather than reporting a partial success;
220/// - a write failure stamped **at or after** the deadline does not turn the
221///   deadline outcome into an `Err`: the deadline already decided that outcome,
222///   so the call returns `Ok` with `timed_out` set;
223/// - a writer that had not delivered its verdict when the deadline was reached
224///   is abandoned, exactly like a reader blocked on a pipe a surviving
225///   descendant holds. Its verdict is never observed, and the deadline outcome
226///   stands.
227///
228/// A payload that was not delivered is therefore never reported as a success:
229/// the outcome is either an `Err` or an `Ok` whose `timed_out` flag is set.
230///
231/// A descendant of the direct child can inherit the stdout or stderr pipe and
232/// hold it open after the direct child has exited. Core terminates only the
233/// direct child it spawned, so such a descendant is never killed: the call
234/// returns at the deadline, the result reports `timed_out` together with the
235/// direct child's exit code, and the stream the descendant still holds
236/// contributes no bytes, because they were never captured.
237///
238/// The only work that may extend past the deadline is the kill and reap of a
239/// direct child that is still running. That allowance is bounded by the
240/// child's own termination; it is not a second, larger deadline.
241///
242/// A reader thread that delivered its result is joined, which is bounded
243/// because a reader only has to return after its send. A reader that was still
244/// blocked in `read()` when the bound was reached cannot be joined without
245/// waiting past the deadline, and core cannot terminate a descendant it does
246/// not own: it has no process-group or job-object dependency and `unsafe` is
247/// denied. Such a reader is abandoned explicitly, and it ends when the pipe
248/// reaches EOF, that is, when the holding descendant exits or closes the pipe.
249/// The same rule applies to the stdin writer.
250///
251/// The caller observes four distinguishable outcomes:
252///
253/// - the child ran to completion: `Ok`, with the exit code, and `timed_out`
254///   and `output_limited` both `false`;
255/// - the deadline was reached: `Ok`, with `timed_out` set; the direct child
256///   is killed only if it was still running. This outcome also covers a stdin
257///   payload that was never delivered, so an undelivered payload is never
258///   reported as a success;
259/// - a stream exceeded `max_output_bytes`: `Ok`, with `output_limited` set and
260///   that stream's captured bytes discarded;
261/// - the command could not be spawned or polled, or the stdin payload could
262///   not be written: `Err`.
263///
264/// # Error classification
265///
266/// The `operation` string of [`Error::Io`] is a **stable classification
267/// surface** for this function: a downstream adapter may branch on it, so
268/// these three strings are part of the published contract.
269///
270/// - `"spawning bounded candidate"` — the child could not be spawned. Nothing
271///   was created, so the caller should treat it as an environment problem and
272///   may retry under its own policy.
273/// - `"polling bounded candidate"` — the child's status could not be polled.
274///   The child may still be running, so the caller must not treat the outcome
275///   as a completed execution.
276/// - `"writing bounded stdin"` — the stdin payload could not be written, which
277///   includes the broken pipe a child produces when it exits without reading
278///   its stdin. The child is killed and reaped before the error is returned,
279///   so the payload was **not delivered** and the caller must not treat the
280///   execution as complete.
281///
282/// When a failed stdin write and an output overflow are live at the same time,
283/// the **write failure wins**: it is an `Err`, and the caller must not be told
284/// that the execution merely overflowed.
285///
286/// # Errors
287///
288/// Returns [`Error::InvalidInput`] when the attached stdin payload is larger
289/// than [`MAX_STDIN_BYTES`]. Nothing is spawned in that case.
290///
291/// Returns [`Error::Io`] when the command cannot be spawned, when polling the
292/// child fails, or when the stdin payload cannot be written. The `operation`
293/// string distinguishes the three cases; see
294/// [Error classification](#error-classification).
295///
296/// Returns [`Error::CandidateExecution`] when the spawned child's stdout,
297/// stderr, or stdin pipe is missing.
298///
299/// A command that ran to completion, that reached the deadline, or that
300/// exceeded its output bound is **not** an error: those outcomes are reported
301/// through [`CommandOutput::timed_out`], [`CommandOutput::output_limited`],
302/// and [`CommandOutput::exit_code`] on an `Ok` result. A reader error is
303/// surfaced as `output_limited`, never as an `Err`.
304pub fn run_bounded(spec: &CommandSpec) -> Result<CommandOutput> {
305    // The stdin bound is enforced before anything is spawned, so an oversized
306    // payload fails closed with no child created.
307    if let Some(data) = &spec.stdin_data {
308        if data.len() > MAX_STDIN_BYTES {
309            return Err(Error::InvalidInput(
310                "stdin data exceeds the bounded stdin bound".into(),
311            ));
312        }
313    }
314    let mut command = Command::new(&spec.program);
315    command
316        .args(&spec.args)
317        .env_clear()
318        .stdin(if spec.stdin_data.is_some() {
319            Stdio::piped()
320        } else {
321            Stdio::null()
322        })
323        .stdout(Stdio::piped())
324        .stderr(Stdio::piped());
325    if let Some(current_dir) = &spec.current_dir {
326        command.current_dir(current_dir);
327    }
328    for (name, value) in &spec.environment {
329        command.env(name, value);
330    }
331    let mut child = command
332        .spawn()
333        .map_err(|source| Error::io("spawning bounded candidate", source))?;
334    // The deadline starts once the spawn has returned, so a blocking spawn is
335    // not covered by it. It is fixed here, before any thread exists, so that
336    // the instant a stdin writer observes its write can be compared against a
337    // deadline that was already decided.
338    let deadline = Instant::now() + spec.timeout;
339    let stdout = child
340        .stdout
341        .take()
342        .ok_or_else(|| Error::CandidateExecution("missing stdout pipe".into()))?;
343    let stderr = child
344        .stderr
345        .take()
346        .ok_or_else(|| Error::CandidateExecution("missing stderr pipe".into()))?;
347    // The stdin handle is taken before the readers start, but the write itself
348    // starts only after both readers are attached.
349    let stdin_pipe = if spec.stdin_data.is_some() {
350        match child.stdin.take() {
351            Some(pipe) => Some(pipe),
352            None => {
353                let _ = child.kill();
354                let _ = child.wait();
355                return Err(Error::CandidateExecution("missing stdin pipe".into()));
356            }
357        }
358    } else {
359        None
360    };
361    let (stdout_tx, stdout_rx) = mpsc::channel();
362    let (stderr_tx, stderr_rx) = mpsc::channel();
363    let limit = spec.max_output_bytes;
364    let stdout_thread = thread::spawn(move || {
365        let _ = stdout_tx.send(read_limited(stdout, limit));
366    });
367    let stderr_thread = thread::spawn(move || {
368        let _ = stderr_tx.send(read_limited(stderr, limit));
369    });
370    let (stdin_tx, stdin_rx) = mpsc::channel();
371    let stdin_thread = stdin_pipe.map(|mut pipe| {
372        let payload = spec.stdin_data.clone().unwrap_or_default();
373        thread::spawn(move || {
374            use std::io::Write as _;
375            let result = pipe.write_all(&payload).and_then(|()| pipe.flush());
376            // The verdict carries the instant at which the writer observed the
377            // write's completion, so that "before or after the deadline" is a
378            // fact about the write and not about when this call polls for it.
379            let verdict = WriterVerdict {
380                observed_at: Instant::now(),
381                result,
382            };
383            let _ = stdin_tx.send(verdict);
384        })
385    });
386
387    let mut timed_out = false;
388    let mut output_limited = false;
389    let mut status = None;
390    let mut stdout_result = None;
391    let mut stderr_result = None;
392    let mut stdout_finished = false;
393    let mut stderr_finished = false;
394    let mut stdin_result: Option<WriterVerdict> = None;
395    let mut stdin_finished = stdin_thread.is_none();
396    let mut stdin_error: Option<io::Error> = None;
397    loop {
398        if !stdout_finished {
399            stdout_finished = poll_reader(&stdout_rx, &mut stdout_result, &mut output_limited);
400        }
401        if !stderr_finished {
402            stderr_finished = poll_reader(&stderr_rx, &mut stderr_result, &mut output_limited);
403        }
404        if !stdin_finished {
405            stdin_finished = poll_writer(&stdin_rx, &mut stdin_result);
406        }
407        if status.is_none() {
408            status = child
409                .try_wait()
410                .map_err(|source| Error::io("polling bounded candidate", source))?;
411        }
412        if let Some(verdict) = stdin_result.take() {
413            match classify_writer_verdict(verdict, deadline) {
414                WriterVerdictClass::Delivered => {}
415                WriterVerdictClass::FailedBeforeDeadline(source) => {
416                    // A failed stdin write decides the outcome: the payload was
417                    // not delivered, and the deadline had not yet decided it, so
418                    // the child is killed and reaped and the call fails closed
419                    // rather than reporting a partial success.
420                    stdin_error = Some(source);
421                    if status.is_none() {
422                        let _ = child.kill();
423                        status = child.wait().ok();
424                    }
425                    break;
426                }
427                WriterVerdictClass::FailedAtOrAfterDeadline => {
428                    // The write failed at or after the deadline, so the deadline
429                    // already decided the outcome and it stands.
430                    timed_out = true;
431                    if status.is_none() {
432                        let _ = child.kill();
433                        status = child.wait().ok();
434                    }
435                    break;
436                }
437            }
438        }
439        if output_limited {
440            // The output bound decides the outcome: the child is killed and
441            // reaped, and the streams are no longer waited for.
442            if status.is_none() {
443                let _ = child.kill();
444                status = child.wait().ok();
445            }
446            break;
447        }
448        if status.is_some() && stdout_result.is_some() && stderr_result.is_some() && stdin_finished
449        {
450            // The bounded execution completed: the child exited, both pipes
451            // reached EOF, and the stdin writer delivered its verdict.
452            break;
453        }
454        // The deadline is unconditional: it is evaluated whether or not the
455        // direct child has already exited, and the loop leaves without waiting
456        // for pipe EOF.
457        if Instant::now() >= deadline {
458            timed_out = true;
459            if status.is_none() {
460                let _ = child.kill();
461                status = child.wait().ok();
462            }
463            break;
464        }
465        thread::sleep(Duration::from_millis(2));
466    }
467    // One last non-blocking drain, so a result that arrived at the bound is
468    // reported rather than lost.
469    stdout_finished = poll_reader(&stdout_rx, &mut stdout_result, &mut output_limited);
470    stderr_finished = poll_reader(&stderr_rx, &mut stderr_result, &mut output_limited);
471    // The writer's verdict is drained one last time, so a verdict that arrived
472    // at the bound is classified rather than lost. The poll is guarded by the
473    // same flag the loop uses: once the writer has delivered its verdict, the
474    // slot is empty and the channel is disconnected, and a second poll would
475    // mistake that for a writer that ended without a verdict.
476    if !stdin_finished {
477        stdin_finished = poll_writer(&stdin_rx, &mut stdin_result);
478    }
479    if let Some(verdict) = stdin_result.take() {
480        // A verdict that arrived at the bound is classified by the instant the
481        // writer observed it, exactly like a verdict the loop delivered. A
482        // failure observed before the deadline is the write failure the
483        // published precedence rule gives: it wins over any other outcome. A
484        // failure observed at or after the deadline leaves the outcome the
485        // loop already decided untouched.
486        match classify_writer_verdict(verdict, deadline) {
487            WriterVerdictClass::Delivered => {}
488            WriterVerdictClass::FailedBeforeDeadline(source) => {
489                stdin_error = Some(source);
490            }
491            WriterVerdictClass::FailedAtOrAfterDeadline => {}
492        }
493    }
494    if stdout_finished || stdout_thread.is_finished() {
495        // The reader delivered its result, or it has already ended, so it only
496        // has to return: the join is bounded.
497        let _ = stdout_thread.join();
498    }
499    if stderr_finished || stderr_thread.is_finished() {
500        let _ = stderr_thread.join();
501    }
502    if let Some(writer) = stdin_thread {
503        if stdin_finished || writer.is_finished() {
504            // The writer delivered its result, or it has already ended, so it
505            // only has to return: the join is bounded.
506            let _ = writer.join();
507        }
508        // A writer that was still blocked in `write` when the bound was reached
509        // is abandoned explicitly, exactly like a reader blocked on a pipe a
510        // surviving descendant holds. It ends when the child's stdin pipe
511        // breaks, that is, when the child is killed or a holding descendant
512        // exits or closes the pipe.
513    }
514    if let Some(source) = stdin_error {
515        return Err(Error::io("writing bounded stdin", source));
516    }
517    Ok(CommandOutput {
518        exit_code: status.and_then(|status| status.code()),
519        stdout: stdout_result
520            .and_then(|result| result.ok())
521            .unwrap_or_default(),
522        stderr: stderr_result
523            .and_then(|result| result.ok())
524            .unwrap_or_default(),
525        timed_out,
526        output_limited,
527    })
528}
529
530/// The verdict a bounded stdin writer delivers.
531///
532/// The verdict carries the instant at which the writer observed the write's
533/// completion. That instant is what decides the outcome against the deadline,
534/// so the classification of a write failure never depends on when the bounded
535/// runner happened to poll for the verdict.
536struct WriterVerdict {
537    /// The instant at which the writer observed the write's completion.
538    observed_at: Instant,
539    /// The result of the write itself.
540    result: io::Result<()>,
541}
542
543/// The outcome one stdin writer verdict produces.
544#[derive(Debug)]
545enum WriterVerdictClass {
546    /// The payload was written and the pipe was closed.
547    Delivered,
548    /// The write failed before the deadline, so the payload was not delivered.
549    FailedBeforeDeadline(io::Error),
550    /// The write failed at or after the deadline, so the deadline already
551    /// decided the outcome.
552    FailedAtOrAfterDeadline,
553}
554
555/// Classifies one writer verdict against the deadline.
556///
557/// The verdict's own observation instant decides the classification, never the
558/// instant at which the bounded runner polled for it. A failure observed
559/// before the deadline is a failure the caller must see as an `Err`; a failure
560/// observed at or after the deadline does not retroactively turn the deadline
561/// outcome into an `Err`, because the deadline already decided it.
562fn classify_writer_verdict(verdict: WriterVerdict, deadline: Instant) -> WriterVerdictClass {
563    match verdict.result {
564        Ok(()) => WriterVerdictClass::Delivered,
565        Err(source) if verdict.observed_at < deadline => {
566            WriterVerdictClass::FailedBeforeDeadline(source)
567        }
568        Err(_) => WriterVerdictClass::FailedAtOrAfterDeadline,
569    }
570}
571
572/// Drains one stdin writer verdict if it has already arrived.
573///
574/// Returns `true` when the writer thread is known to have ended, that is, when
575/// it delivered its verdict, and `false` while the writer is still blocked in
576/// `write`.
577fn poll_writer(receiver: &mpsc::Receiver<WriterVerdict>, slot: &mut Option<WriterVerdict>) -> bool {
578    if slot.is_some() {
579        return true;
580    }
581    match receiver.try_recv() {
582        Ok(verdict) => {
583            *slot = Some(verdict);
584            true
585        }
586        Err(mpsc::TryRecvError::Disconnected) => {
587            // The writer ended without delivering a verdict. The writer this
588            // module spawns cannot do that, because its send is unconditional
589            // and the thread cannot panic; it is still treated as a write
590            // failure so that the outcome is never reported as a success.
591            *slot = Some(WriterVerdict {
592                observed_at: Instant::now(),
593                result: Err(io::Error::other(
594                    "bounded stdin writer ended without a result",
595                )),
596            });
597            true
598        }
599        Err(mpsc::TryRecvError::Empty) => false,
600    }
601}
602
603/// Drains one reader result if it has already arrived.
604///
605/// Returns `true` when the reader thread is known to have ended, that is, when
606/// it delivered its result or ended without one, and `false` while the reader
607/// is still blocked in `read()`.
608fn poll_reader(
609    receiver: &mpsc::Receiver<io::Result<Vec<u8>>>,
610    slot: &mut Option<io::Result<Vec<u8>>>,
611    output_limited: &mut bool,
612) -> bool {
613    if slot.is_some() {
614        return true;
615    }
616    match receiver.try_recv() {
617        Ok(result) => {
618            *output_limited |= result.is_err();
619            *slot = Some(result);
620            true
621        }
622        Err(mpsc::TryRecvError::Disconnected) => true,
623        Err(mpsc::TryRecvError::Empty) => false,
624    }
625}
626
627fn read_limited(mut reader: impl Read, limit: usize) -> io::Result<Vec<u8>> {
628    let mut output = Vec::new();
629    let mut buffer = [0_u8; 4096];
630    loop {
631        let count = reader.read(&mut buffer)?;
632        if count == 0 {
633            return Ok(output);
634        }
635        if output.len().saturating_add(count) > limit {
636            return Err(io::Error::other("candidate output limit exceeded"));
637        }
638        output.extend_from_slice(&buffer[..count]);
639    }
640}
641
642/// A candidate validator supplied by a consumer or by eggup-core helpers.
643pub trait CandidateValidator {
644    /// Validates all candidates in an integrity-verified transaction.
645    ///
646    /// # Errors
647    ///
648    /// Returns whatever the validator returns. The validators this crate
649    /// provides return [`Error::UnknownMember`] when a configured member is
650    /// not part of the transaction, [`Error::Io`] when a staged member cannot
651    /// be spawned or polled, and [`Error::CandidateExecution`] when a bounded
652    /// execution does not produce the expected identity.
653    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()>;
654}
655
656/// A strict exact-output check for one or more staged member programs.
657#[derive(Debug, Clone)]
658pub struct ExactIdentityValidator {
659    checks: Vec<IdentityCheck>,
660    timeout: Duration,
661    max_output_bytes: usize,
662}
663
664#[derive(Debug, Clone)]
665struct IdentityCheck {
666    member: MemberId,
667    args: Vec<String>,
668    expected_output: String,
669}
670
671impl ExactIdentityValidator {
672    /// Creates a check for one member's exact stdout bytes.
673    pub fn new(member: MemberId, expected_output: impl Into<String>) -> Self {
674        Self {
675            checks: vec![IdentityCheck {
676                member,
677                args: Vec::new(),
678                expected_output: expected_output.into(),
679            }],
680            timeout: DEFAULT_TIMEOUT,
681            max_output_bytes: DEFAULT_OUTPUT_LIMIT,
682        }
683    }
684
685    /// Creates matching exact-output checks for a bundle of members.
686    pub fn for_members<I>(members: I, expected_output: impl Into<String>) -> Self
687    where
688        I: IntoIterator<Item = MemberId>,
689    {
690        let expected_output = expected_output.into();
691        let checks = members
692            .into_iter()
693            .map(|member| IdentityCheck {
694                member,
695                args: Vec::new(),
696                expected_output: expected_output.clone(),
697            })
698            .collect();
699        Self {
700            checks,
701            timeout: DEFAULT_TIMEOUT,
702            max_output_bytes: DEFAULT_OUTPUT_LIMIT,
703        }
704    }
705
706    /// Sets literal argv arguments for every configured identity check.
707    pub fn args<I, S>(mut self, args: I) -> Self
708    where
709        I: IntoIterator<Item = S>,
710        S: Into<String>,
711    {
712        let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
713        for check in &mut self.checks {
714            check.args = args.clone();
715        }
716        self
717    }
718
719    /// Sets the per-candidate deadline.
720    pub fn timeout(mut self, timeout: Duration) -> Self {
721        self.timeout = timeout;
722        self
723    }
724
725    /// Sets the per-stream output cap.
726    pub fn max_output_bytes(mut self, limit: usize) -> Self {
727        self.max_output_bytes = limit;
728        self
729    }
730}
731
732impl CandidateValidator for ExactIdentityValidator {
733    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()> {
734        for check in &self.checks {
735            let program = transaction.staged_path(&check.member)?;
736            let output = run_bounded(
737                &CommandSpec::new(program)
738                    .args(check.args.clone())
739                    .current_dir(transaction.stage_root())
740                    .timeout(self.timeout)
741                    .max_output_bytes(self.max_output_bytes),
742            )?;
743            if !output.success()
744                || !output.stderr().is_empty()
745                || output.stdout() != check.expected_output.as_bytes()
746            {
747                return Err(Error::CandidateExecution(format!(
748                    "member {} did not produce the exact expected identity",
749                    check.member
750                )));
751            }
752        }
753        Ok(())
754    }
755}
756
757/// A named helper for requiring one exact identity across all bundle members.
758#[derive(Debug, Clone)]
759pub struct CrossMemberAgreementValidator(ExactIdentityValidator);
760
761impl CrossMemberAgreementValidator {
762    /// Creates an exact-output agreement check for every supplied member.
763    pub fn new<I>(members: I, expected_output: impl Into<String>) -> Self
764    where
765        I: IntoIterator<Item = MemberId>,
766    {
767        Self(ExactIdentityValidator::for_members(
768            members,
769            expected_output,
770        ))
771    }
772
773    /// Sets the per-member deadline.
774    pub fn timeout(mut self, timeout: Duration) -> Self {
775        self.0 = self.0.timeout(timeout);
776        self
777    }
778
779    /// Sets the per-stream output cap.
780    pub fn max_output_bytes(mut self, limit: usize) -> Self {
781        self.0 = self.0.max_output_bytes(limit);
782        self
783    }
784}
785
786impl CandidateValidator for CrossMemberAgreementValidator {
787    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()> {
788        self.0.validate(transaction)
789    }
790}
791
792/// A validator composition that requires every contained validator to pass.
793pub struct AllValidators<'a> {
794    validators: Vec<&'a dyn CandidateValidator>,
795}
796
797impl<'a> AllValidators<'a> {
798    /// Creates an empty composition.
799    pub fn new() -> Self {
800        Self {
801            validators: Vec::new(),
802        }
803    }
804
805    /// Adds a validator while retaining its borrowed lifetime.
806    pub fn push(mut self, validator: &'a dyn CandidateValidator) -> Self {
807        self.validators.push(validator);
808        self
809    }
810}
811
812impl Default for AllValidators<'_> {
813    fn default() -> Self {
814        Self::new()
815    }
816}
817
818impl CandidateValidator for AllValidators<'_> {
819    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()> {
820        for validator in &self.validators {
821            validator.validate(transaction)?;
822        }
823        Ok(())
824    }
825}
826
827/// A transaction whose integrity-verified candidates passed a consumer validator.
828#[derive(Debug)]
829pub struct ValidatedTransaction {
830    pub(crate) verified: VerifiedTransaction,
831}
832
833impl VerifiedTransaction {
834    /// Runs a consumer-supplied validator before exposing the commit operation.
835    ///
836    /// # Errors
837    ///
838    /// Returns [`Error::VerificationFailed`] when any member of the
839    /// transaction does not carry verified integrity evidence. A member
840    /// declared [`IntegrityRequirement::None`](crate::IntegrityRequirement::None)
841    /// is never verified, so it is always rejected here: there is no escape
842    /// hatch. Otherwise the error is the validator's own.
843    pub fn validate(self, validator: &dyn CandidateValidator) -> Result<ValidatedTransaction> {
844        if self.artifacts().iter().any(|member| {
845            self.integrity(member.id())
846                .is_none_or(|result| result.status() != IntegrityStatus::Verified)
847        }) {
848            return Err(Error::VerificationFailed(
849                "candidate execution requires verified integrity evidence for every member".into(),
850            ));
851        }
852        validator.validate(&self)?;
853        Ok(ValidatedTransaction { verified: self })
854    }
855}
856
857impl ValidatedTransaction {
858    /// Returns the validated artifact set.
859    pub fn artifacts(&self) -> &crate::domain::ArtifactSet {
860        self.verified.artifacts()
861    }
862
863    /// Returns a validated member's private staged path.
864    ///
865    /// Exposed so consumers and tests can inspect staged bytes; mutating the
866    /// staged copy after validation causes the final digest revalidation in
867    /// [`Self::commit`] to fail before any live mutation.
868    ///
869    /// # Errors
870    ///
871    /// Returns [`Error::UnknownMember`] when `member` is not part of this
872    /// transaction's artifact set.
873    pub fn staged_path(&self, member: &MemberId) -> Result<std::path::PathBuf> {
874        self.verified.staged_path(member)
875    }
876
877    /// Commits only after integrity and candidate validation have passed.
878    ///
879    /// Destination ownership is proven again under the mutation lock via
880    /// `ownership`, and every staged member is re-hashed against the digest
881    /// recorded at [`VerifiedTransaction`](crate::integrity::VerifiedTransaction)
882    /// time before any live destination is mutated. `NotRequired` integrity
883    /// state can never reach this path: validation rejects it, and the final
884    /// revalidation requires a verified digest for every member.
885    ///
886    /// # Errors
887    ///
888    /// Returns `Err` only for lock contention ([`Error::UpdateInProgress`])
889    /// and for setup failures such as [`Error::Io`] that occur while the
890    /// mutation lock is being taken. Every failure reached once the lock is
891    /// held — an ownership conflict, a staged-digest mismatch, a failed
892    /// rename, a failed rollback — is reported through the returned
893    /// [`TransactionReceipt`], whose disposition is `RolledBack` or
894    /// `RecoveryRequired`, and never through `Err`.
895    ///
896    /// The direction is fail-closed: `Owned` is never inferred, and a
897    /// verifier's ambiguity is `Unknown`, which denies mutation. Every member
898    /// must carry verified SHA-256 evidence, so a member declared `None` can
899    /// never reach this call. SHA-256 is integrity evidence only, never a
900    /// publisher-trust or signature claim.
901    ///
902    /// See `architecture/core-transaction.md` §8 "Failure semantics".
903    pub fn commit(self, ownership: CommitOwnership<'_>) -> Result<TransactionReceipt> {
904        let digests = self.verified.verified_digests();
905        self.verified
906            .prepared
907            .commit_inner(ownership, &digests, None, None, None)
908            .map_err(RecoveryError::into_core)
909    }
910
911    /// Commits the complete artifact set, runs one caller check while the
912    /// mutation lock and rollback evidence remain held, then resolves its
913    /// result according to `policy`.
914    ///
915    /// The callback runs only after all members are live. Its error is copied
916    /// into bounded core-owned evidence. Panics are caught and recorded as a
917    /// failed check, so `RollBack` still restores the prior generation during
918    /// unwinding. The callback must itself bound its work; core invents no
919    /// timeout. Process termination or power loss is outside this guarantee.
920    ///
921    /// # Errors
922    ///
923    /// The same as [`commit`](Self::commit): only lock contention and setup
924    /// failures return `Err`. A failing post-commit check is **not** an
925    /// error — it is reported in the receipt's
926    /// [`post_commit_failure`](TransactionReceipt::post_commit_failure). With
927    /// [`PostCommitFailurePolicy::KeepInstalled`] the disposition stays
928    /// `Committed`; with [`PostCommitFailurePolicy::RollBack`] the previous
929    /// generation is restored and the disposition becomes `RolledBack`. A
930    /// panic in the callback is caught and reported the same way.
931    pub fn commit_with_post_commit<F, E>(
932        self,
933        ownership: CommitOwnership<'_>,
934        policy: PostCommitFailurePolicy,
935        check: F,
936    ) -> Result<TransactionReceipt>
937    where
938        F: FnOnce() -> std::result::Result<(), E>,
939        E: std::fmt::Display,
940    {
941        let digests = self.verified.verified_digests();
942        let check = Box::new(move || check().map_err(|error| bounded_display(&error)));
943        self.verified
944            .prepared
945            .commit_inner(ownership, &digests, None, Some((policy, check)), None)
946            .map_err(RecoveryError::into_core)
947    }
948
949    /// Commits with caller-authorized stale-lock recovery.
950    ///
951    /// Identical to [`commit`](Self::commit) except that a pre-existing lock
952    /// record may be displaced — but only when `recovery` returns
953    /// [`StaleLockDecision::ProvenStale`](crate::lock::StaleLockDecision::ProvenStale)
954    /// for the exact record Core observed. Every other verdict, and every record
955    /// Core cannot prove safe, keeps the fail-closed contention result.
956    ///
957    /// Core never decides staleness from PID liveness, record age, executable
958    /// name, or service state; that evidence belongs to the caller.
959    ///
960    /// # Errors
961    ///
962    /// Returns [`RecoveryError::Core`](crate::RecoveryError::Core) carrying
963    /// [`Error::UpdateInProgress`] when the lock is contended, when the
964    /// caller's verifier does not return
965    /// [`StaleLockDecision::ProvenStale`](crate::lock::StaleLockDecision::ProvenStale)
966    /// for the exact record core observed, or when another writer wins the
967    /// race after a claim; and carrying a setup failure such as
968    /// [`Error::Io`] otherwise.
969    ///
970    /// Returns [`RecoveryError::RecoveryRequired`](crate::RecoveryError::RecoveryRequired)
971    /// when a stale record was displaced but could not be resolved
972    /// automatically; the real retained evidence path is in
973    /// [`RecoveryError::evidence`](crate::RecoveryError::evidence).
974    ///
975    /// Every failure reached once the lock is held is reported through the
976    /// returned [`TransactionReceipt`], never through `Err`.
977    pub fn commit_with_stale_lock_recovery(
978        self,
979        ownership: CommitOwnership<'_>,
980        recovery: &dyn crate::lock::StaleLockVerifier,
981    ) -> RecoveryResult<TransactionReceipt> {
982        let digests = self.verified.verified_digests();
983        self.verified
984            .prepared
985            .commit_inner(ownership, &digests, None, None, Some(recovery))
986    }
987
988    /// Commits with a caller post-commit check and caller-authorized stale-lock
989    /// recovery, combining both opt-in policies.
990    ///
991    /// # Errors
992    ///
993    /// The union of [`commit_with_post_commit`](Self::commit_with_post_commit)
994    /// and
995    /// [`commit_with_stale_lock_recovery`](Self::commit_with_stale_lock_recovery):
996    /// only lock contention and setup failures return `Err`, a failing
997    /// post-commit check is reported in the receipt's
998    /// [`post_commit_failure`](TransactionReceipt::post_commit_failure), and
999    /// displaced stale evidence that could not be resolved is reported as
1000    /// [`RecoveryError::RecoveryRequired`](crate::RecoveryError::RecoveryRequired)
1001    /// with its real retained path.
1002    pub fn commit_with_post_commit_and_stale_lock_recovery<F, E>(
1003        self,
1004        ownership: CommitOwnership<'_>,
1005        policy: PostCommitFailurePolicy,
1006        recovery: &dyn crate::lock::StaleLockVerifier,
1007        check: F,
1008    ) -> RecoveryResult<TransactionReceipt>
1009    where
1010        F: FnOnce() -> std::result::Result<(), E>,
1011        E: std::fmt::Display,
1012    {
1013        let digests = self.verified.verified_digests();
1014        let check = Box::new(move || check().map_err(|error| bounded_display(&error)));
1015        self.verified.prepared.commit_inner(
1016            ownership,
1017            &digests,
1018            None,
1019            Some((policy, check)),
1020            Some(recovery),
1021        )
1022    }
1023
1024    /// Test-only commit with injected faults. The ownership and staged
1025    /// revalidation contracts are identical to [`Self::commit`].
1026    #[cfg(test)]
1027    pub(crate) fn commit_with_fault(
1028        self,
1029        ownership: CommitOwnership<'_>,
1030        fault: crate::transaction::CommitFault,
1031    ) -> Result<TransactionReceipt> {
1032        let digests = self.verified.verified_digests();
1033        self.verified
1034            .prepared
1035            .commit_inner(ownership, &digests, Some(fault), None, None)
1036            .map_err(RecoveryError::into_core)
1037    }
1038
1039    #[cfg(test)]
1040    pub(crate) fn commit_with_post_commit_fault(
1041        self,
1042        ownership: CommitOwnership<'_>,
1043        policy: PostCommitFailurePolicy,
1044        fault: crate::transaction::CommitFault,
1045    ) -> Result<TransactionReceipt> {
1046        let digests = self.verified.verified_digests();
1047        self.verified
1048            .prepared
1049            .commit_inner(
1050                ownership,
1051                &digests,
1052                Some(fault),
1053                Some((
1054                    policy,
1055                    Box::new(|| Err("injected post-commit failure".to_string())),
1056                )),
1057                None,
1058            )
1059            .map_err(RecoveryError::into_core)
1060    }
1061}
1062
1063fn bounded_display(value: &impl std::fmt::Display) -> String {
1064    struct Bounded(String);
1065    impl std::fmt::Write for Bounded {
1066        fn write_str(&mut self, text: &str) -> std::fmt::Result {
1067            for character in text.chars() {
1068                if self.0.len() + character.len_utf8() > 512 {
1069                    break;
1070                }
1071                self.0.push(character);
1072            }
1073            Ok(())
1074        }
1075    }
1076    let mut output = Bounded(String::new());
1077    let _ = std::write!(&mut output, "{value}");
1078    output.0
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::{classify_writer_verdict, WriterVerdict, WriterVerdictClass};
1084    use std::io;
1085    use std::time::{Duration, Instant};
1086
1087    /// A write failure observed strictly before the deadline is a failure the
1088    /// caller must see as an `Err`: the payload was not delivered, so the
1089    /// outcome fails closed.
1090    #[test]
1091    fn candidate_writer_verdict_before_the_deadline_fails_closed() {
1092        let deadline = Instant::now() + Duration::from_secs(1);
1093        let verdict = WriterVerdict {
1094            observed_at: deadline - Duration::from_millis(1),
1095            result: Err(io::Error::other("broken pipe")),
1096        };
1097
1098        match classify_writer_verdict(verdict, deadline) {
1099            WriterVerdictClass::FailedBeforeDeadline(source) => {
1100                assert_eq!(
1101                    source.kind(),
1102                    io::ErrorKind::Other,
1103                    "the underlying failure must be preserved"
1104                );
1105            }
1106            other => panic!("a failure observed before the deadline must fail closed: {other:?}"),
1107        }
1108    }
1109
1110    /// A write failure observed at or after the deadline does not turn the
1111    /// deadline outcome into an `Err`: the deadline already decided it.
1112    #[test]
1113    fn candidate_writer_verdict_after_the_deadline_does_not_fail_closed() {
1114        let deadline = Instant::now() + Duration::from_secs(1);
1115
1116        for offset in [Duration::ZERO, Duration::from_millis(1)] {
1117            let verdict = WriterVerdict {
1118                observed_at: deadline + offset,
1119                result: Err(io::Error::other("broken pipe")),
1120            };
1121
1122            assert!(
1123                matches!(
1124                    classify_writer_verdict(verdict, deadline),
1125                    WriterVerdictClass::FailedAtOrAfterDeadline
1126                ),
1127                "a failure observed at or after the deadline must not fail closed"
1128            );
1129        }
1130    }
1131
1132    /// A successful write is a delivery, whatever the deadline says.
1133    #[test]
1134    fn candidate_writer_verdict_success_is_a_delivery() {
1135        let deadline = Instant::now() + Duration::from_secs(1);
1136        let verdict = WriterVerdict {
1137            observed_at: deadline + Duration::from_secs(1),
1138            result: Ok(()),
1139        };
1140
1141        assert!(matches!(
1142            classify_writer_verdict(verdict, deadline),
1143            WriterVerdictClass::Delivered
1144        ));
1145    }
1146}