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