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. This owns direct-child completion, not process-group or signal policy.
60/// `admit` runs before each command factory, so caller interruption can refuse
61/// another dispatch after metadata. In-flight commands still run to completion.
62///
63/// Callers exclude source/config/tool mutation and retain artifact custody. No
64/// cache, installation, release admission, retry or elapsed-time default is added.
65/// Interrupted or rejected capture uses Host's direct-child cleanup evidence.
66///
67/// # Errors
68/// Refuses invalid selection before command effects, missing/ambiguous metadata
69/// or artifacts, malformed/oversized messages, sink failures and command failures.
70/// Original Host execution/status/cleanup causes remain downcastable.
71pub fn build_native_executable(
72    spec: &NativeExecutableSpec<'_>,
73    mut cargo: impl FnMut() -> Command,
74    mut diagnostics: impl Write,
75    mut admit: impl FnMut() -> io::Result<()>,
76) -> Result<NativeExecutable, Box<dyn Error + Send + Sync>> {
77    let selector = match spec.target_kind {
78        "bin" => "--bin",
79        "example" => "--example",
80        _ => return Err(protocol("native target kind must be bin or example").into()),
81    };
82    if spec.package.is_empty() || spec.target_name.is_empty() || spec.max_message_bytes == 0 {
83        return Err(protocol("empty native target selection or message allowance").into());
84    }
85    let arguments = CargoSelection::parse(spec.build_args.to_vec());
86    if !arguments.flags.is_empty() {
87        return Err(protocol("native build arguments override the selected recipe").into());
88    }
89    arguments
90        .profile
91        .as_ref()
92        .map_err(|message| protocol(message))?;
93    admit()?;
94    let mut command = cargo();
95    command.args(["metadata", "--locked", "--no-deps", "--format-version", "1"]);
96    command.args(&arguments.metadata_arguments);
97    let mut bytes = Vec::new();
98    execute(&mut command, |stream, chunk| match stream {
99        OutputStream::Stdout => append(&mut bytes, chunk, spec.max_message_bytes),
100        OutputStream::Stderr => diagnostics.write_all(chunk),
101    })?;
102    let (package_id, target_directory) = selection(serde_json::from_slice(&bytes)?, spec)?;
103    admit()?;
104    let mut command = cargo();
105    command.args([
106        "build",
107        "--locked",
108        "--message-format=json",
109        "-p",
110        spec.package,
111        selector,
112        spec.target_name,
113    ]);
114    command.args(spec.build_args);
115    let mut decoder = MessageDecoder::new(spec.max_message_bytes, false);
116    let mut executable = None;
117    execute(&mut command, |stream, chunk| {
118        if stream == OutputStream::Stderr {
119            return diagnostics.write_all(chunk);
120        }
121        decoder.feed(chunk, |decoded| {
122            selected_message(
123                decoded,
124                &package_id,
125                spec,
126                &mut executable,
127                &mut diagnostics,
128            )
129        })
130    })?;
131    decoder.finish(|decoded| {
132        selected_message(
133            decoded,
134            &package_id,
135            spec,
136            &mut executable,
137            &mut diagnostics,
138        )
139    })?;
140    Ok(NativeExecutable {
141        executable: executable.ok_or_else(|| protocol("Cargo omitted the selected executable"))?,
142        target_directory,
143    })
144}
145
146fn selection(
147    metadata: schema::Metadata,
148    spec: &NativeExecutableSpec<'_>,
149) -> io::Result<(String, PathBuf)> {
150    let mut matching = metadata
151        .packages
152        .into_iter()
153        .filter(|package| package.name.as_str() == spec.package);
154    let package = matching
155        .next()
156        .ok_or_else(|| protocol("Cargo omitted selected package"))?;
157    if matching.next().is_some() {
158        return Err(protocol("Cargo package name is ambiguous"));
159    }
160    if package
161        .targets
162        .iter()
163        .filter(|target| matches_target(target, spec))
164        .count()
165        != 1
166    {
167        return Err(protocol("Cargo target is missing or ambiguous"));
168    }
169    if package.id.repr.is_empty() {
170        return Err(protocol("Cargo omitted package identity"));
171    }
172    if !metadata.target_directory.is_absolute() {
173        return Err(protocol("Cargo omitted absolute target directory"));
174    }
175    Ok((
176        package.id.repr,
177        metadata.target_directory.into_std_path_buf(),
178    ))
179}
180
181fn matches_target(target: &schema::Target, spec: &NativeExecutableSpec<'_>) -> bool {
182    target.name == spec.target_name && target.kind.contains(&spec.target_kind.into())
183}
184
185fn selected_message(
186    message: schema::Message,
187    package_id: &str,
188    spec: &NativeExecutableSpec<'_>,
189    executable: &mut Option<PathBuf>,
190    diagnostics: &mut impl Write,
191) -> io::Result<()> {
192    match message {
193        schema::Message::CompilerMessage(message) => {
194            if let Some(rendered) = message.message.rendered {
195                diagnostics.write_all(rendered.as_bytes())?;
196            }
197        }
198        schema::Message::CompilerArtifact(artifact)
199            if artifact_matches(
200                &artifact,
201                package_id,
202                spec.target_name,
203                &spec.target_kind.into(),
204            ) =>
205        {
206            let path = artifact
207                .executable
208                .filter(|path| path.is_absolute())
209                .ok_or_else(|| protocol("Cargo omitted absolute executable path"))?
210                .into_std_path_buf();
211            if executable.replace(path).is_some() {
212                return Err(protocol("Cargo reported multiple selected executables"));
213            }
214        }
215        _ => {}
216    }
217    Ok(())
218}
219
220fn execute(
221    command: &mut Command,
222    output: impl FnMut(OutputStream, &[u8]) -> io::Result<()>,
223) -> Result<(), Box<dyn Error + Send + Sync>> {
224    command.stdin(Stdio::null()).stdout(Stdio::piped());
225    let mut child = OwnedChild::spawn_direct(command)?;
226    communicate_child_with_observer(
227        &mut child,
228        None,
229        OutputLimits {
230            stdout: OutputLimit::Truncate(0),
231            stderr: OutputLimit::Truncate(0),
232            timeout: None,
233        },
234        SuccessfulExit::Cleanup,
235        || false,
236        output,
237    )?
238    .require_success()?;
239    Ok(())
240}
241
242fn append(buffer: &mut Vec<u8>, bytes: &[u8], maximum: usize) -> io::Result<()> {
243    if bytes.len() > maximum.saturating_sub(buffer.len()) {
244        return Err(protocol("Cargo JSON message exceeds caller allowance"));
245    }
246    buffer.try_reserve(bytes.len()).map_err(io::Error::other)?;
247    buffer.extend_from_slice(bytes);
248    Ok(())
249}
250
251fn protocol(message: &'static str) -> io::Error {
252    io::Error::new(io::ErrorKind::InvalidData, message)
253}