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}