Skip to main content

ic_build/cargo/native/
mod.rs

1//! Selected native executables from Cargo's own package and artifact messages.
2
3use super::{
4    messages::{MessageDecoder, artifact_matches},
5    observation::schema,
6    selection::CargoSelection,
7};
8use ic_host_process::{
9    child::OwnedChild,
10    tool::{
11        OutputLimit, OutputLimits, OutputStream, SuccessfulExit, communicate_child_with_observer,
12    },
13};
14use std::{
15    error::Error,
16    ffi::OsString,
17    io::{self, Write},
18    path::PathBuf,
19    process::{Command, Stdio},
20};
21
22#[cfg(test)]
23mod tests;
24
25/// Exact native target and caller-selected build arguments and decoding allowance.
26pub struct NativeExecutableSpec<'a> {
27    /// Unique package name in the selected Cargo metadata graph.
28    pub package: &'a str,
29    /// Cargo target name, without guessing an output filename.
30    pub target_name: &'a str,
31    /// `bin` or `example`, selecting Cargo's corresponding build target.
32    pub target_kind: &'a str,
33    /// Additional build arguments, such as profile and features. Package, target,
34    /// manifest, target-directory and configuration overrides are refused; select
35    /// shared configuration and paths in the command factory instead.
36    /// Cargo's JSON message format is owned by this operation.
37    pub build_args: &'a [OsString],
38    /// Maximum metadata bytes or bytes in one Cargo JSON message, including LF.
39    /// Total streamed diagnostic output and build duration have no default limit.
40    pub max_message_bytes: usize,
41}
42
43/// Cargo-reported paths; mutable build-directory custody remains caller-owned.
44#[derive(Debug)]
45pub struct NativeExecutable {
46    /// Absolute executable reported for the exact selected package and target.
47    pub executable: PathBuf,
48    /// Absolute target root reported by the same Cargo command's metadata.
49    pub target_directory: PathBuf,
50}
51
52/// Build a selected native target and return Cargo's reported executable.
53///
54/// The factory supplies the same Cargo prefix, working directory, environment
55/// and network/tool policy for metadata and build. Commands add `--locked`, exact
56/// package/target selection and Cargo's JSON diagnostics format. Stdout is a
57/// bounded-per-message protocol; rendered compiler diagnostics and piped stderr
58/// stream to `diagnostics`. Other stderr configurations remain intact. Stdin is
59/// null. `spawn` selects direct-child or owned-group custody and cleanup policy.
60/// `admit` runs before each command factory. `interrupted` is polled during both
61/// metadata and build capture; cancellation retains Host's typed cleanup evidence.
62/// The caller owns signal handlers and cancellation meaning.
63///
64/// Callers exclude source/config/tool mutation and retain artifact custody. No
65/// cache, installation, release admission, retry or elapsed-time default is added.
66/// Interrupted or rejected capture uses the caller-selected child cleanup.
67///
68/// # Errors
69/// Refuses invalid selection before command effects, missing/ambiguous metadata
70/// or artifacts, malformed/oversized messages, sink failures and command failures.
71/// Original Host execution/status/cleanup causes remain downcastable.
72pub fn build_native_executable(
73    spec: &NativeExecutableSpec<'_>,
74    mut cargo: impl FnMut() -> Command,
75    mut diagnostics: impl Write,
76    mut admit: impl FnMut() -> io::Result<()>,
77    mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
78    mut interrupted: impl FnMut() -> bool,
79) -> Result<NativeExecutable, Box<dyn Error + Send + Sync>> {
80    let selector = match spec.target_kind {
81        "bin" => "--bin",
82        "example" => "--example",
83        _ => return Err(protocol("native target kind must be bin or example").into()),
84    };
85    if spec.package.is_empty() || spec.target_name.is_empty() || spec.max_message_bytes == 0 {
86        return Err(protocol("empty native target selection or message allowance").into());
87    }
88    let arguments = CargoSelection::parse(spec.build_args.to_vec());
89    if !arguments.flags.is_empty() {
90        return Err(protocol("native build arguments override the selected recipe").into());
91    }
92    arguments
93        .profile
94        .as_ref()
95        .map_err(|message| protocol(message))?;
96    admit()?;
97    let mut command = cargo();
98    command.args(["metadata", "--locked", "--no-deps", "--format-version", "1"]);
99    command.args(&arguments.metadata_arguments);
100    let mut bytes = Vec::new();
101    execute(
102        &mut command,
103        &mut spawn,
104        &mut interrupted,
105        |stream, chunk| match stream {
106            OutputStream::Stdout => append(&mut bytes, chunk, spec.max_message_bytes),
107            OutputStream::Stderr => diagnostics.write_all(chunk),
108        },
109    )?;
110    let (package_id, target_directory) = selection(serde_json::from_slice(&bytes)?, spec)?;
111    admit()?;
112    let mut command = cargo();
113    command.args([
114        "build",
115        "--locked",
116        "--message-format=json",
117        "-p",
118        spec.package,
119        selector,
120        spec.target_name,
121    ]);
122    command.args(spec.build_args);
123    let mut decoder = MessageDecoder::new(spec.max_message_bytes, false);
124    let mut executable = None;
125    execute(
126        &mut command,
127        &mut spawn,
128        &mut interrupted,
129        |stream, chunk| {
130            if stream == OutputStream::Stderr {
131                return diagnostics.write_all(chunk);
132            }
133            decoder.feed(chunk, |decoded| {
134                selected_message(
135                    decoded,
136                    &package_id,
137                    spec,
138                    &mut executable,
139                    &mut diagnostics,
140                )
141            })
142        },
143    )?;
144    decoder.finish(|decoded| {
145        selected_message(
146            decoded,
147            &package_id,
148            spec,
149            &mut executable,
150            &mut diagnostics,
151        )
152    })?;
153    Ok(NativeExecutable {
154        executable: executable.ok_or_else(|| protocol("Cargo omitted the selected executable"))?,
155        target_directory,
156    })
157}
158
159fn selection(
160    metadata: schema::Metadata,
161    spec: &NativeExecutableSpec<'_>,
162) -> io::Result<(String, PathBuf)> {
163    let mut matching = metadata
164        .packages
165        .into_iter()
166        .filter(|package| package.name.as_str() == spec.package);
167    let package = matching
168        .next()
169        .ok_or_else(|| protocol("Cargo omitted selected package"))?;
170    if matching.next().is_some() {
171        return Err(protocol("Cargo package name is ambiguous"));
172    }
173    if package
174        .targets
175        .iter()
176        .filter(|target| matches_target(target, spec))
177        .count()
178        != 1
179    {
180        return Err(protocol("Cargo target is missing or ambiguous"));
181    }
182    if package.id.repr.is_empty() {
183        return Err(protocol("Cargo omitted package identity"));
184    }
185    if !metadata.target_directory.is_absolute() {
186        return Err(protocol("Cargo omitted absolute target directory"));
187    }
188    Ok((
189        package.id.repr,
190        metadata.target_directory.into_std_path_buf(),
191    ))
192}
193
194fn matches_target(target: &schema::Target, spec: &NativeExecutableSpec<'_>) -> bool {
195    target.name == spec.target_name && target.kind.contains(&spec.target_kind.into())
196}
197
198fn selected_message(
199    message: schema::Message,
200    package_id: &str,
201    spec: &NativeExecutableSpec<'_>,
202    executable: &mut Option<PathBuf>,
203    diagnostics: &mut impl Write,
204) -> io::Result<()> {
205    match message {
206        schema::Message::CompilerMessage(message) => {
207            if let Some(rendered) = message.message.rendered {
208                diagnostics.write_all(rendered.as_bytes())?;
209            }
210        }
211        schema::Message::CompilerArtifact(artifact)
212            if artifact_matches(
213                &artifact,
214                package_id,
215                spec.target_name,
216                &spec.target_kind.into(),
217            ) =>
218        {
219            let path = artifact
220                .executable
221                .filter(|path| path.is_absolute())
222                .ok_or_else(|| protocol("Cargo omitted absolute executable path"))?
223                .into_std_path_buf();
224            if executable.replace(path).is_some() {
225                return Err(protocol("Cargo reported multiple selected executables"));
226            }
227        }
228        _ => {}
229    }
230    Ok(())
231}
232
233fn execute(
234    command: &mut Command,
235    spawn: &mut impl FnMut(&mut Command) -> io::Result<OwnedChild>,
236    interrupted: &mut impl FnMut() -> bool,
237    output: impl FnMut(OutputStream, &[u8]) -> io::Result<()>,
238) -> Result<(), Box<dyn Error + Send + Sync>> {
239    command.stdin(Stdio::null()).stdout(Stdio::piped());
240    let mut child = spawn(command)?;
241    communicate_child_with_observer(
242        &mut child,
243        None,
244        OutputLimits {
245            stdout: OutputLimit::Truncate(0),
246            stderr: OutputLimit::Truncate(0),
247            timeout: None,
248        },
249        SuccessfulExit::Cleanup,
250        interrupted,
251        output,
252    )?
253    .require_success()?;
254    Ok(())
255}
256
257fn append(buffer: &mut Vec<u8>, bytes: &[u8], maximum: usize) -> io::Result<()> {
258    if bytes.len() > maximum.saturating_sub(buffer.len()) {
259        return Err(protocol("Cargo JSON message exceeds caller allowance"));
260    }
261    buffer.try_reserve(bytes.len()).map_err(io::Error::other)?;
262    buffer.extend_from_slice(bytes);
263    Ok(())
264}
265
266fn protocol(message: &'static str) -> io::Error {
267    io::Error::new(io::ErrorKind::InvalidData, message)
268}