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, 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/// An argv-based candidate command with bounded execution policy.
18#[derive(Debug, Clone)]
19pub struct CommandSpec {
20    program: PathBuf,
21    args: Vec<String>,
22    current_dir: Option<PathBuf>,
23    timeout: Duration,
24    max_output_bytes: usize,
25    environment: Vec<(String, String)>,
26}
27
28impl CommandSpec {
29    /// Creates a command that never invokes a shell.
30    pub fn new(program: impl Into<PathBuf>) -> Self {
31        Self {
32            program: program.into(),
33            args: Vec::new(),
34            current_dir: None,
35            timeout: DEFAULT_TIMEOUT,
36            max_output_bytes: DEFAULT_OUTPUT_LIMIT,
37            environment: Vec::new(),
38        }
39    }
40
41    /// Appends one literal argv argument.
42    pub fn arg(mut self, argument: impl Into<String>) -> Self {
43        self.args.push(argument.into());
44        self
45    }
46
47    /// Appends literal argv arguments.
48    pub fn args<I, S>(mut self, arguments: I) -> Self
49    where
50        I: IntoIterator<Item = S>,
51        S: Into<String>,
52    {
53        self.args.extend(arguments.into_iter().map(Into::into));
54        self
55    }
56
57    /// Sets the controlled child working directory.
58    pub fn current_dir(mut self, path: impl Into<PathBuf>) -> Self {
59        self.current_dir = Some(path.into());
60        self
61    }
62
63    /// Sets the maximum wall-clock execution time.
64    pub fn timeout(mut self, timeout: Duration) -> Self {
65        self.timeout = timeout;
66        self
67    }
68
69    /// Sets the combined per-stream output bound.
70    pub fn max_output_bytes(mut self, limit: usize) -> Self {
71        self.max_output_bytes = limit;
72        self
73    }
74
75    /// Adds one explicit environment variable; inherited environment is cleared.
76    pub fn environment(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
77        self.environment.push((name.into(), value.into()));
78        self
79    }
80}
81
82/// A bounded child-process result with timeout and overflow state.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct CommandOutput {
85    exit_code: Option<i32>,
86    stdout: Vec<u8>,
87    stderr: Vec<u8>,
88    timed_out: bool,
89    output_limited: bool,
90}
91
92impl CommandOutput {
93    /// Returns the platform exit code when one was available.
94    pub fn exit_code(&self) -> Option<i32> {
95        self.exit_code
96    }
97
98    /// Returns bounded stdout bytes.
99    pub fn stdout(&self) -> &[u8] {
100        &self.stdout
101    }
102
103    /// Returns bounded stderr bytes.
104    pub fn stderr(&self) -> &[u8] {
105        &self.stderr
106    }
107
108    /// Returns whether the child was killed because its deadline elapsed.
109    pub fn timed_out(&self) -> bool {
110        self.timed_out
111    }
112
113    /// Returns whether the child was killed because an output stream exceeded its bound.
114    pub fn output_limited(&self) -> bool {
115        self.output_limited
116    }
117
118    /// Returns whether the child exited successfully without a timeout or overflow.
119    pub fn success(&self) -> bool {
120        !self.timed_out && !self.output_limited && self.exit_code == Some(0)
121    }
122}
123
124/// Executes a command with cleared environment, null stdin, bounded output, and kill/reap.
125pub fn run_bounded(spec: &CommandSpec) -> Result<CommandOutput> {
126    let mut command = Command::new(&spec.program);
127    command
128        .args(&spec.args)
129        .env_clear()
130        .stdin(Stdio::null())
131        .stdout(Stdio::piped())
132        .stderr(Stdio::piped());
133    if let Some(current_dir) = &spec.current_dir {
134        command.current_dir(current_dir);
135    }
136    for (name, value) in &spec.environment {
137        command.env(name, value);
138    }
139    let mut child = command
140        .spawn()
141        .map_err(|source| Error::io("spawning bounded candidate", source))?;
142    let stdout = child
143        .stdout
144        .take()
145        .ok_or_else(|| Error::CandidateExecution("missing stdout pipe".into()))?;
146    let stderr = child
147        .stderr
148        .take()
149        .ok_or_else(|| Error::CandidateExecution("missing stderr pipe".into()))?;
150    let (stdout_tx, stdout_rx) = mpsc::channel();
151    let (stderr_tx, stderr_rx) = mpsc::channel();
152    let limit = spec.max_output_bytes;
153    let stdout_thread = thread::spawn(move || {
154        let _ = stdout_tx.send(read_limited(stdout, limit));
155    });
156    let stderr_thread = thread::spawn(move || {
157        let _ = stderr_tx.send(read_limited(stderr, limit));
158    });
159
160    let deadline = Instant::now() + spec.timeout;
161    let mut timed_out = false;
162    let mut output_limited = false;
163    let mut status = None;
164    let mut stdout_result = None;
165    let mut stderr_result = None;
166    loop {
167        if stdout_result.is_none() {
168            if let Ok(result) = stdout_rx.try_recv() {
169                output_limited |= result.is_err();
170                stdout_result = Some(result.unwrap_or_default());
171            }
172        }
173        if stderr_result.is_none() {
174            if let Ok(result) = stderr_rx.try_recv() {
175                output_limited |= result.is_err();
176                stderr_result = Some(result.unwrap_or_default());
177            }
178        }
179        if status.is_none() {
180            status = child
181                .try_wait()
182                .map_err(|source| Error::io("polling bounded candidate", source))?;
183        }
184        if output_limited {
185            let _ = child.kill();
186            status = child.wait().ok();
187        } else if status.is_none() && Instant::now() >= deadline {
188            timed_out = true;
189            let _ = child.kill();
190            status = child.wait().ok();
191        }
192        if ((timed_out || output_limited) && status.is_some())
193            || (status.is_some() && stdout_result.is_some() && stderr_result.is_some())
194        {
195            break;
196        }
197        thread::sleep(Duration::from_millis(2));
198    }
199    if !timed_out && !output_limited {
200        let _ = stdout_thread.join();
201        let _ = stderr_thread.join();
202    }
203    Ok(CommandOutput {
204        exit_code: status.and_then(|status| status.code()),
205        stdout: stdout_result.unwrap_or_default(),
206        stderr: stderr_result.unwrap_or_default(),
207        timed_out,
208        output_limited,
209    })
210}
211
212fn read_limited(mut reader: impl Read, limit: usize) -> io::Result<Vec<u8>> {
213    let mut output = Vec::new();
214    let mut buffer = [0_u8; 4096];
215    loop {
216        let count = reader.read(&mut buffer)?;
217        if count == 0 {
218            return Ok(output);
219        }
220        if output.len().saturating_add(count) > limit {
221            return Err(io::Error::other("candidate output limit exceeded"));
222        }
223        output.extend_from_slice(&buffer[..count]);
224    }
225}
226
227/// A candidate validator supplied by a consumer or by eggup-core helpers.
228pub trait CandidateValidator {
229    /// Validates all candidates in an integrity-verified transaction.
230    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()>;
231}
232
233/// A strict exact-output check for one or more staged member programs.
234#[derive(Debug, Clone)]
235pub struct ExactIdentityValidator {
236    checks: Vec<IdentityCheck>,
237    timeout: Duration,
238    max_output_bytes: usize,
239}
240
241#[derive(Debug, Clone)]
242struct IdentityCheck {
243    member: MemberId,
244    args: Vec<String>,
245    expected_output: String,
246}
247
248impl ExactIdentityValidator {
249    /// Creates a check for one member's exact stdout bytes.
250    pub fn new(member: MemberId, expected_output: impl Into<String>) -> Self {
251        Self {
252            checks: vec![IdentityCheck {
253                member,
254                args: Vec::new(),
255                expected_output: expected_output.into(),
256            }],
257            timeout: DEFAULT_TIMEOUT,
258            max_output_bytes: DEFAULT_OUTPUT_LIMIT,
259        }
260    }
261
262    /// Creates matching exact-output checks for a bundle of members.
263    pub fn for_members<I>(members: I, expected_output: impl Into<String>) -> Self
264    where
265        I: IntoIterator<Item = MemberId>,
266    {
267        let expected_output = expected_output.into();
268        let checks = members
269            .into_iter()
270            .map(|member| IdentityCheck {
271                member,
272                args: Vec::new(),
273                expected_output: expected_output.clone(),
274            })
275            .collect();
276        Self {
277            checks,
278            timeout: DEFAULT_TIMEOUT,
279            max_output_bytes: DEFAULT_OUTPUT_LIMIT,
280        }
281    }
282
283    /// Sets literal argv arguments for every configured identity check.
284    pub fn args<I, S>(mut self, args: I) -> Self
285    where
286        I: IntoIterator<Item = S>,
287        S: Into<String>,
288    {
289        let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
290        for check in &mut self.checks {
291            check.args = args.clone();
292        }
293        self
294    }
295
296    /// Sets the per-candidate deadline.
297    pub fn timeout(mut self, timeout: Duration) -> Self {
298        self.timeout = timeout;
299        self
300    }
301
302    /// Sets the per-stream output cap.
303    pub fn max_output_bytes(mut self, limit: usize) -> Self {
304        self.max_output_bytes = limit;
305        self
306    }
307}
308
309impl CandidateValidator for ExactIdentityValidator {
310    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()> {
311        for check in &self.checks {
312            let program = transaction.staged_path(&check.member)?;
313            let output = run_bounded(
314                &CommandSpec::new(program)
315                    .args(check.args.clone())
316                    .current_dir(transaction.stage_root())
317                    .timeout(self.timeout)
318                    .max_output_bytes(self.max_output_bytes),
319            )?;
320            if !output.success()
321                || !output.stderr().is_empty()
322                || output.stdout() != check.expected_output.as_bytes()
323            {
324                return Err(Error::CandidateExecution(format!(
325                    "member {} did not produce the exact expected identity",
326                    check.member
327                )));
328            }
329        }
330        Ok(())
331    }
332}
333
334/// A named helper for requiring one exact identity across all bundle members.
335#[derive(Debug, Clone)]
336pub struct CrossMemberAgreementValidator(ExactIdentityValidator);
337
338impl CrossMemberAgreementValidator {
339    /// Creates an exact-output agreement check for every supplied member.
340    pub fn new<I>(members: I, expected_output: impl Into<String>) -> Self
341    where
342        I: IntoIterator<Item = MemberId>,
343    {
344        Self(ExactIdentityValidator::for_members(
345            members,
346            expected_output,
347        ))
348    }
349
350    /// Sets the per-member deadline.
351    pub fn timeout(mut self, timeout: Duration) -> Self {
352        self.0 = self.0.timeout(timeout);
353        self
354    }
355
356    /// Sets the per-stream output cap.
357    pub fn max_output_bytes(mut self, limit: usize) -> Self {
358        self.0 = self.0.max_output_bytes(limit);
359        self
360    }
361}
362
363impl CandidateValidator for CrossMemberAgreementValidator {
364    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()> {
365        self.0.validate(transaction)
366    }
367}
368
369/// A validator composition that requires every contained validator to pass.
370pub struct AllValidators<'a> {
371    validators: Vec<&'a dyn CandidateValidator>,
372}
373
374impl<'a> AllValidators<'a> {
375    /// Creates an empty composition.
376    pub fn new() -> Self {
377        Self {
378            validators: Vec::new(),
379        }
380    }
381
382    /// Adds a validator while retaining its borrowed lifetime.
383    pub fn push(mut self, validator: &'a dyn CandidateValidator) -> Self {
384        self.validators.push(validator);
385        self
386    }
387}
388
389impl Default for AllValidators<'_> {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395impl CandidateValidator for AllValidators<'_> {
396    fn validate(&self, transaction: &VerifiedTransaction) -> Result<()> {
397        for validator in &self.validators {
398            validator.validate(transaction)?;
399        }
400        Ok(())
401    }
402}
403
404/// A transaction whose integrity-verified candidates passed a consumer validator.
405#[derive(Debug)]
406pub struct ValidatedTransaction {
407    pub(crate) verified: VerifiedTransaction,
408}
409
410impl VerifiedTransaction {
411    /// Runs a consumer-supplied validator before exposing the commit operation.
412    pub fn validate(self, validator: &dyn CandidateValidator) -> Result<ValidatedTransaction> {
413        if self.artifacts().iter().any(|member| {
414            self.integrity(member.id())
415                .is_none_or(|result| result.status() != IntegrityStatus::Verified)
416        }) {
417            return Err(Error::VerificationFailed(
418                "candidate execution requires verified integrity evidence for every member".into(),
419            ));
420        }
421        validator.validate(&self)?;
422        Ok(ValidatedTransaction { verified: self })
423    }
424}
425
426impl ValidatedTransaction {
427    /// Returns the validated artifact set.
428    pub fn artifacts(&self) -> &crate::domain::ArtifactSet {
429        self.verified.artifacts()
430    }
431
432    /// Returns a validated member's private staged path.
433    ///
434    /// Exposed so consumers and tests can inspect staged bytes; mutating the
435    /// staged copy after validation causes the final digest revalidation in
436    /// [`Self::commit`] to fail before any live mutation.
437    pub fn staged_path(&self, member: &MemberId) -> Result<std::path::PathBuf> {
438        self.verified.staged_path(member)
439    }
440
441    /// Commits only after integrity and candidate validation have passed.
442    ///
443    /// Destination ownership is proven again under the mutation lock via
444    /// `ownership`, and every staged member is re-hashed against the digest
445    /// recorded at [`VerifiedTransaction`](crate::integrity::VerifiedTransaction)
446    /// time before any live destination is mutated. `NotRequired` integrity
447    /// state can never reach this path: validation rejects it, and the final
448    /// revalidation requires a verified digest for every member.
449    pub fn commit(self, ownership: CommitOwnership<'_>) -> Result<TransactionReceipt> {
450        let digests = self.verified.verified_digests();
451        self.verified
452            .prepared
453            .commit_inner(ownership, &digests, None, None)
454    }
455
456    /// Commits the complete artifact set, runs one caller check while the
457    /// mutation lock and rollback evidence remain held, then resolves its
458    /// result according to `policy`.
459    ///
460    /// The callback runs only after all members are live. Its error is copied
461    /// into bounded core-owned evidence. Panics are caught and recorded as a
462    /// failed check, so `RollBack` still restores the prior generation during
463    /// unwinding. The callback must itself bound its work; core invents no
464    /// timeout. Process termination or power loss is outside this guarantee.
465    pub fn commit_with_post_commit<F, E>(
466        self,
467        ownership: CommitOwnership<'_>,
468        policy: PostCommitFailurePolicy,
469        check: F,
470    ) -> Result<TransactionReceipt>
471    where
472        F: FnOnce() -> std::result::Result<(), E>,
473        E: std::fmt::Display,
474    {
475        let digests = self.verified.verified_digests();
476        let check = Box::new(move || check().map_err(|error| bounded_display(&error)));
477        self.verified
478            .prepared
479            .commit_inner(ownership, &digests, None, Some((policy, check)))
480    }
481
482    /// Test-only commit with injected faults. The ownership and staged
483    /// revalidation contracts are identical to [`Self::commit`].
484    #[cfg(test)]
485    pub(crate) fn commit_with_fault(
486        self,
487        ownership: CommitOwnership<'_>,
488        fault: crate::transaction::CommitFault,
489    ) -> Result<TransactionReceipt> {
490        let digests = self.verified.verified_digests();
491        self.verified
492            .prepared
493            .commit_inner(ownership, &digests, Some(fault), None)
494    }
495
496    #[cfg(test)]
497    pub(crate) fn commit_with_post_commit_fault(
498        self,
499        ownership: CommitOwnership<'_>,
500        policy: PostCommitFailurePolicy,
501        fault: crate::transaction::CommitFault,
502    ) -> Result<TransactionReceipt> {
503        let digests = self.verified.verified_digests();
504        self.verified.prepared.commit_inner(
505            ownership,
506            &digests,
507            Some(fault),
508            Some((
509                policy,
510                Box::new(|| Err("injected post-commit failure".to_string())),
511            )),
512        )
513    }
514}
515
516fn bounded_display(value: &impl std::fmt::Display) -> String {
517    struct Bounded(String);
518    impl std::fmt::Write for Bounded {
519        fn write_str(&mut self, text: &str) -> std::fmt::Result {
520            for character in text.chars() {
521                if self.0.len() + character.len_utf8() > 512 {
522                    break;
523                }
524                self.0.push(character);
525            }
526            Ok(())
527        }
528    }
529    let mut output = Bounded(String::new());
530    let _ = std::write!(&mut output, "{value}");
531    output.0
532}