Skip to main content

eggup_core/
candidate.rs

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