Skip to main content

ic_build/cargo/execution/
invocation.rs

1use super::{communicate_cargo, schema};
2use crate::{artifacts::CargoBuildFacts, cargo::selection::CargoSelection};
3use ic_host_process::{
4    child::{CleanupPolicy, OwnedChild},
5    tool::{
6        CompletedExecution, OutputLimit, OutputLimits, OutputStream, ToolError,
7        capture_group_command_with_cancellation,
8    },
9};
10use std::{
11    collections::BTreeMap,
12    ffi::{OsStr, OsString},
13    io,
14    path::PathBuf,
15    process::{Command, Stdio},
16};
17
18pub const TOOL_IDENTITY_BYTES: usize = 64 * 1024;
19
20/// Supported Cargo recipes. Consumers retain profile overrides and compiler flags.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub enum CargoRecipe {
23    /// Build the selected packages' libraries.
24    Library,
25    /// Build one named example.
26    Example(String),
27    /// Compile a library with explicit arguments following Cargo's `--` separator.
28    RustcLibrary(Vec<OsString>),
29}
30
31/// One concrete Cargo invocation with an explicit, complete child environment.
32///
33/// Graph arguments are rendered from `selection`, never reconstructed from argv.
34/// Configurations and compiler arguments are caller-owned recipe policy. These
35/// observations do not validate source custody or admit a release. Debug is not
36/// implemented because environment/configuration values may contain secrets.
37#[derive(Clone, Eq, PartialEq)]
38pub struct CargoInvocation {
39    /// Selected Cargo executable.
40    pub cargo_program: OsString,
41    /// Absolute Cargo working directory, also used by identity probes.
42    pub working_directory: PathBuf,
43    /// Complete child environment. No parent variables are inherited.
44    /// Observed execution requires an explicit nonempty `RUSTC` entry, so Cargo
45    /// cannot select a different compiler through unobserved configuration.
46    pub environment: BTreeMap<OsString, OsString>,
47    /// Compilation target, rendered into the command and retained in facts.
48    pub target: String,
49    /// Profile, features and network/lock policy, rendered exactly once.
50    pub selection: CargoSelection,
51    /// Package selectors; an empty list leaves Cargo's workspace selection intact.
52    pub packages: Vec<String>,
53    /// Optional explicit manifest.
54    pub manifest_path: Option<PathBuf>,
55    /// Selected library/example/compiler recipe.
56    pub recipe: CargoRecipe,
57    /// Explicit Cargo `--config` values, retaining their order.
58    pub configurations: Vec<OsString>,
59    /// Request Cargo's `json-render-diagnostics` instead of `json` messages.
60    pub render_diagnostics: bool,
61    /// Continue independent compilation jobs after a build error.
62    pub keep_going: bool,
63}
64
65#[derive(Clone, Copy)]
66pub enum CargoTool {
67    Cargo,
68    Rustc,
69}
70
71impl CargoInvocation {
72    /// Render the selected invocation without spawning a process.
73    ///
74    /// This leaves stdio and process custody unconfigured. `execute_cargo` sets
75    /// null stdin and piped output before invoking the caller's spawner, which
76    /// may override stdio and custody. Changing executable, cwd, arguments or
77    /// environment invalidates execution facts. Separate execution earns no receipt.
78    ///
79    /// # Errors
80    /// Refuses relative working directories and invalid profile components.
81    pub fn command(&self) -> io::Result<Command> {
82        if !self.working_directory.is_absolute() {
83            return Err(io::Error::new(
84                io::ErrorKind::InvalidInput,
85                "Cargo invocation requires an absolute working directory",
86            ));
87        }
88        self.selection
89            .profile_target_dir()
90            .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?;
91        let mut command = Command::new(&self.cargo_program);
92        command
93            .current_dir(&self.working_directory)
94            .env_clear()
95            .envs(&self.environment);
96        command.arg(if matches!(self.recipe, CargoRecipe::RustcLibrary(_)) {
97            "rustc"
98        } else {
99            "build"
100        });
101        command.arg(if self.render_diagnostics {
102            "--message-format=json-render-diagnostics"
103        } else {
104            "--message-format=json"
105        });
106        match &self.recipe {
107            CargoRecipe::Example(name) => {
108                command.args(["--example", name]);
109            }
110            _ => {
111                command.arg("--lib");
112            }
113        }
114        command
115            .args(["--target", &self.target])
116            .args(self.selection.build_arguments());
117        if self.keep_going {
118            command.arg("--keep-going");
119        }
120        for package in &self.packages {
121            command.args(["-p", package]);
122        }
123        if let Some(manifest) = &self.manifest_path {
124            command.arg("--manifest-path").arg(manifest);
125        }
126        for configuration in &self.configurations {
127            command.arg("--config").arg(configuration);
128        }
129        if let CargoRecipe::RustcLibrary(arguments) = &self.recipe {
130            command.arg("--").args(arguments);
131        }
132        Ok(command)
133    }
134
135    pub(crate) fn observe<E>(
136        &self,
137        rustc_fallback: &OsStr,
138        mut capture: impl FnMut(CargoTool, &mut Command) -> Result<CompletedExecution, E>,
139    ) -> Result<CargoBuildFacts, E> {
140        let cargo_identity = command_identity(
141            &self.cargo_program,
142            &self.working_directory,
143            &self.environment,
144            &["--version", "--verbose"],
145            |command| capture(CargoTool::Cargo, command),
146        )?;
147        let rustc = self
148            .environment
149            .get(OsStr::new("RUSTC"))
150            .map_or(rustc_fallback, OsString::as_os_str);
151        let rustc_identity = command_identity(
152            rustc,
153            &self.working_directory,
154            &self.environment,
155            &["-vV"],
156            |command| capture(CargoTool::Rustc, command),
157        )?;
158        Ok(self.facts(cargo_identity, rustc_identity))
159    }
160
161    pub(crate) fn facts(
162        &self,
163        cargo_identity: Vec<u8>,
164        rustc_identity: Vec<u8>,
165    ) -> CargoBuildFacts {
166        CargoBuildFacts {
167            selection: self.selection.clone(),
168            target: self.target.clone(),
169            environment: self.environment.clone(),
170            cargo_identity,
171            rustc_identity,
172        }
173    }
174}
175
176pub fn command_identity<E>(
177    program: &OsStr,
178    directory: &std::path::Path,
179    environment: &BTreeMap<OsString, OsString>,
180    arguments: &[&str],
181    capture: impl FnOnce(&mut Command) -> Result<CompletedExecution, E>,
182) -> Result<Vec<u8>, E> {
183    let mut command = Command::new(program);
184    command
185        .current_dir(directory)
186        .env_clear()
187        .envs(environment)
188        .args(arguments);
189    let output = capture(&mut command)?;
190    let mut identity = output.stdout;
191    identity.extend_from_slice(&output.stderr);
192    Ok(identity)
193}
194
195/// Caller-owned output, decoding and probe cleanup policy. No default deadline.
196#[derive(Clone, Copy)]
197pub struct CargoExecutionPolicy {
198    /// Host retention/deadline policy for the build; raw chunks still reach observers.
199    pub output: OutputLimits,
200    /// Maximum bytes in one Cargo protocol record.
201    pub message_bytes: usize,
202    /// Group cleanup policy for bounded, cancellable identity probes.
203    pub probe_cleanup: CleanupPolicy,
204}
205
206/// Independent process/protocol evidence and observations bound to this execution.
207///
208/// Inspect process failure before protocol refusal. `facts` is `Ok(Some(_))` only
209/// after successful execution, successful message delivery and matching before/
210/// after tool observations. `Ok(None)` retains an unsuccessful execution's original
211/// evidence; `Err` retains an observation or invocation-binding refusal. This is
212/// not source validation, artifact custody or release admission.
213#[must_use = "inspect execution, messages and facts before admitting artifacts"]
214pub struct CargoExecution {
215    /// Original bounded Host execution result and cleanup evidence.
216    pub execution: Result<CompletedExecution, ToolError>,
217    /// Original decoder or message-sink refusal; output is drained after refusal.
218    pub messages: io::Result<()>,
219    /// Successful execution observations, or a post-execution observation refusal.
220    pub facts: io::Result<Option<CargoBuildFacts>>,
221}
222
223/// Observe tools, execute one rendered invocation, drain/decode it and recheck tools.
224///
225/// Source/configuration mutation guards and artifact lifetime remain caller-owned.
226/// `spawn` selects process/group custody and may override stdio. It must execute
227/// the supplied command without changing its recipe. Cancellation is also polled
228/// during identity probes; probes have complete 64 KiB output bounds and no finite
229/// default deadline. All raw build output reaches `output`, including failures.
230///
231/// # Errors
232/// Returns pre-execution validation/probe failures or the original spawn error.
233/// Once spawned, process, protocol and revalidation evidence is returned together.
234pub fn execute_cargo(
235    invocation: &CargoInvocation,
236    policy: CargoExecutionPolicy,
237    mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
238    mut cancelled: impl FnMut() -> bool + Send,
239    output: impl FnMut(OutputStream, &[u8]) -> io::Result<()>,
240    message: impl FnMut(schema::Message) -> io::Result<()>,
241) -> io::Result<CargoExecution> {
242    let mut command = invocation.command()?;
243    command
244        .stdin(Stdio::null())
245        .stdout(Stdio::piped())
246        .stderr(Stdio::piped());
247    if invocation
248        .environment
249        .get(OsStr::new("RUSTC"))
250        .is_none_or(|program| program.is_empty())
251    {
252        return Err(io::Error::new(
253            io::ErrorKind::InvalidInput,
254            "observed Cargo execution requires an explicit RUSTC environment entry",
255        ));
256    }
257    let observe = |cancelled: &mut (dyn FnMut() -> bool + Send)| {
258        invocation.observe(OsStr::new("rustc"), |_, probe| {
259            capture_group_command_with_cancellation(
260                probe,
261                OutputLimits {
262                    stdout: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
263                    stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
264                    timeout: None,
265                },
266                policy.probe_cleanup,
267                None,
268                &mut *cancelled,
269            )
270            .and_then(CompletedExecution::require_success)
271            .and_then(CompletedExecution::require_complete)
272            .map_err(io::Error::other)
273        })
274    };
275    let before = observe(&mut cancelled)?;
276    let mut child = spawn(&mut command)?;
277    let expected = invocation.command()?;
278    let unchanged = command.get_program() == expected.get_program()
279        && command.get_current_dir() == expected.get_current_dir()
280        && command.get_args().eq(expected.get_args())
281        && command.get_envs().eq(expected.get_envs());
282    let observed = communicate_cargo(
283        &mut child,
284        policy.output,
285        policy.message_bytes,
286        &mut cancelled,
287        output,
288        message,
289    );
290    let facts = if observed
291        .execution
292        .as_ref()
293        .is_ok_and(|execution| execution.status.success())
294        && observed.messages.is_ok()
295    {
296        if unchanged {
297            observe(&mut cancelled).and_then(|after| {
298                if before == after {
299                    Ok(Some(before))
300                } else {
301                    Err(io::Error::new(
302                        io::ErrorKind::InvalidData,
303                        "Cargo/compiler identity changed during compilation",
304                    ))
305                }
306            })
307        } else {
308            Err(io::Error::new(
309                io::ErrorKind::InvalidData,
310                "Cargo spawner changed the selected invocation",
311            ))
312        }
313    } else {
314        Ok(None)
315    };
316    Ok(CargoExecution {
317        execution: observed.execution,
318        messages: observed.messages,
319        facts,
320    })
321}