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::{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 = None;
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    match finished {
173        Some(true) => {}
174        Some(false) => return Err(protocol("invalid Cargo build completion").into()),
175        None => return Err(protocol("Cargo omitted successful build completion").into()),
176    }
177    Ok(NativeExecutable {
178        executable: executable.ok_or_else(|| protocol("Cargo omitted the selected executable"))?,
179        target_directory,
180    })
181}
182
183fn selection(
184    metadata: schema::Metadata,
185    spec: &NativeExecutableSpec<'_>,
186) -> io::Result<(String, PathBuf)> {
187    let mut matching = metadata
188        .packages
189        .into_iter()
190        .filter(|package| package.name.as_str() == spec.package);
191    let package = matching
192        .next()
193        .ok_or_else(|| protocol("Cargo omitted selected package"))?;
194    if matching.next().is_some() {
195        return Err(protocol("Cargo package name is ambiguous"));
196    }
197    if package
198        .targets
199        .iter()
200        .filter(|target| matches_target(target, spec))
201        .count()
202        != 1
203    {
204        return Err(protocol("Cargo target is missing or ambiguous"));
205    }
206    if package.id.repr.is_empty() {
207        return Err(protocol("Cargo omitted package identity"));
208    }
209    if !metadata.target_directory.is_absolute() {
210        return Err(protocol("Cargo omitted absolute target directory"));
211    }
212    Ok((
213        package.id.repr,
214        metadata.target_directory.into_std_path_buf(),
215    ))
216}
217
218fn matches_target(target: &schema::Target, spec: &NativeExecutableSpec<'_>) -> bool {
219    target.name == spec.target_name && target.kind.contains(&spec.target_kind.cargo_kind())
220}
221
222fn selected_message(
223    message: schema::Message,
224    package_id: &str,
225    spec: &NativeExecutableSpec<'_>,
226    executable: &mut Option<PathBuf>,
227    finished: &mut Option<bool>,
228    diagnostics: &mut impl Write,
229) -> io::Result<()> {
230    match message {
231        schema::Message::BuildFinished(result) => {
232            // Keep invalid completion sticky, but let execution/status failure
233            // remain primary before admitting the successful command's protocol.
234            *finished = Some(finished.is_none() && result.success);
235        }
236        schema::Message::CompilerMessage(message) => {
237            if let Some(rendered) = message.message.rendered {
238                diagnostics.write_all(rendered.as_bytes())?;
239            }
240        }
241        schema::Message::CompilerArtifact(artifact)
242            if artifact_matches(
243                &artifact,
244                package_id,
245                spec.target_name,
246                &spec.target_kind.cargo_kind(),
247            ) =>
248        {
249            let path = artifact
250                .executable
251                .filter(|path| path.is_absolute())
252                .ok_or_else(|| protocol("Cargo omitted absolute executable path"))?
253                .into_std_path_buf();
254            if executable.replace(path).is_some() {
255                return Err(protocol("Cargo reported multiple selected executables"));
256            }
257        }
258        _ => {}
259    }
260    Ok(())
261}
262
263fn execute(
264    command: &mut Command,
265    spawn: &mut impl FnMut(&mut Command) -> io::Result<OwnedChild>,
266    interrupted: &mut impl FnMut() -> bool,
267    output: impl FnMut(OutputStream, &[u8]) -> io::Result<()>,
268) -> Result<(), Box<dyn Error + Send + Sync>> {
269    command.stdin(Stdio::null()).stdout(Stdio::piped());
270    let mut child = spawn(command)?;
271    communicate_child_with_observer(
272        &mut child,
273        None,
274        OutputLimits {
275            stdout: OutputLimit::Truncate(0),
276            stderr: OutputLimit::Truncate(0),
277            timeout: None,
278        },
279        SuccessfulExit::Cleanup,
280        interrupted,
281        output,
282    )?
283    .require_success()?;
284    Ok(())
285}
286
287fn append(buffer: &mut Vec<u8>, bytes: &[u8], maximum: usize) -> io::Result<()> {
288    if bytes.len() > maximum.saturating_sub(buffer.len()) {
289        return Err(protocol("Cargo JSON message exceeds caller allowance"));
290    }
291    buffer.try_reserve(bytes.len()).map_err(io::Error::other)?;
292    buffer.extend_from_slice(bytes);
293    Ok(())
294}
295
296fn protocol(message: &'static str) -> io::Error {
297    io::Error::new(io::ErrorKind::InvalidData, message)
298}