Skip to main content

rattler_build_script/
execution.rs

1//! Script execution types and utilities.
2//!
3//! This module resolves script contents, generates build scripts with
4//! [`generate_build_script`], executes them with [`run_script`], and provides
5//! subprocess output handling via [`run_process_with_replacements`].
6
7use crate::runtime::RuntimeEnv;
8use crate::sandbox::SandboxConfiguration;
9use crate::script::{Script, ScriptContent};
10use fs_err as fs;
11use futures::TryStreamExt;
12use indexmap::IndexMap;
13use rattler_shell::shell::Shell;
14use serde::{Deserialize, Serialize};
15use std::borrow::Cow;
16use std::collections::HashMap;
17use std::fmt;
18use std::io;
19use std::path::{Path, PathBuf};
20use std::process::Stdio;
21use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt};
22use tokio_util::bytes::BytesMut;
23use tokio_util::codec::{Decoder, FramedRead};
24use tokio_util::compat::FuturesAsyncReadCompatExt;
25
26/// Controls how the build subprocess environment is constructed.
27///
28/// This determines which host environment variables are visible to build scripts.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum EnvironmentIsolation {
32    /// Clean environment with only explicitly set build variables and a minimal
33    /// passthrough whitelist (SSL certs, SSH agent, proxies). Locale is
34    /// normalized to `C.UTF-8`, HOME to the work directory, and USER to
35    /// `"rattler"`. Maximum reproducibility.
36    #[default]
37    Strict,
38    /// Match conda-build behavior: forward `CFLAGS`, `CXXFLAGS`, `LDFLAGS`,
39    /// `MAKEFLAGS`, `LANG`, `LC_ALL`, and `HOME` from the host. Does not
40    /// normalize USER, SHELL, EDITOR, or TERM.
41    CondaBuild,
42    /// Inherit the entire host environment. Build variables are set on top.
43    /// Least reproducible but useful for debugging.
44    None,
45}
46
47impl fmt::Display for EnvironmentIsolation {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::Strict => write!(f, "strict"),
51            Self::CondaBuild => write!(f, "conda-build"),
52            Self::None => write!(f, "none"),
53        }
54    }
55}
56
57impl std::str::FromStr for EnvironmentIsolation {
58    type Err = String;
59
60    fn from_str(s: &str) -> Result<Self, Self::Err> {
61        match s {
62            "strict" => Ok(Self::Strict),
63            "conda-build" => Ok(Self::CondaBuild),
64            "none" => Ok(Self::None),
65            _ => Err(format!(
66                "unknown environment isolation mode '{}', expected 'strict', 'conda-build', or 'none'",
67                s
68            )),
69        }
70    }
71}
72
73/// Arguments for executing a script in a given interpreter.
74#[derive(Debug)]
75pub struct ExecutionArgs {
76    /// Contents of the script to execute
77    pub script: ResolvedScriptContents,
78    /// Explicit interpreter requested for the script, if any.
79    pub interpreter: Option<String>,
80    /// Environment variables to set before executing the script
81    pub env_vars: IndexMap<String, String>,
82    /// Secrets to set as env vars and replace in the output
83    pub secrets: IndexMap<String, String>,
84
85    /// The environment rattler-build is running in: process environment
86    /// variables (including `PATH`) and the platform scripts execute on.
87    pub runtime: RuntimeEnv,
88
89    /// The build prefix that should contain the interpreter to use
90    pub build_prefix: Option<PathBuf>,
91    /// The prefix to use for the script execution
92    pub run_prefix: PathBuf,
93
94    /// The working directory (`cwd`) in which the script should execute
95    pub work_dir: PathBuf,
96
97    /// The sandbox configuration to use for the script execution
98    pub sandbox_config: Option<SandboxConfiguration>,
99
100    /// The environment isolation mode
101    pub env_isolation: EnvironmentIsolation,
102}
103
104impl ExecutionArgs {
105    /// Returns strings that should be replaced. The template argument can be used to specify
106    /// a nice "variable" syntax, e.g. "$((var))" for bash or "%((var))%" for cmd.exe. The `var` part
107    /// will be replaced with the actual variable name.
108    pub(crate) fn replacements(&self, template: &str) -> HashMap<String, String> {
109        let mut replacements = HashMap::new();
110        if let Some(build_prefix) = &self.build_prefix {
111            replacements.insert(
112                build_prefix.display().to_string(),
113                template.replace("((var))", "BUILD_PREFIX"),
114            );
115        };
116        replacements.insert(
117            self.run_prefix.display().to_string(),
118            template.replace("((var))", "PREFIX"),
119        );
120
121        replacements.insert(
122            self.work_dir.display().to_string(),
123            template.replace("((var))", "SRC_DIR"),
124        );
125
126        // if the paths contain `\` then also replace the forward slash variants
127        for (k, v) in replacements.clone() {
128            if k.contains('\\') {
129                replacements.insert(k.replace('\\', "/"), v.clone());
130            }
131        }
132
133        self.secrets.iter().for_each(|(_, v)| {
134            replacements.insert(v.to_string(), "********".to_string());
135        });
136
137        replacements
138    }
139}
140
141/// The resolved contents of a script.
142#[derive(Debug)]
143pub enum ResolvedScriptContents {
144    /// The script contents as loaded from a file (path, contents)
145    Path(PathBuf, String),
146    /// The script contents from an inline YAML string
147    Inline(String),
148    /// A list of inline commands; the interpreter assembles them at generation
149    /// time (so kept as a list, not joined here).
150    Commands(Vec<String>),
151    /// There are no script contents
152    Missing,
153}
154
155impl ResolvedScriptContents {
156    /// The script contents as text (a command list is plainly newline-joined;
157    /// interpreter-specific assembly happens at generation time).
158    pub fn script(&self) -> Cow<'_, str> {
159        match self {
160            ResolvedScriptContents::Path(_, script) => Cow::Borrowed(script),
161            ResolvedScriptContents::Inline(script) => Cow::Borrowed(script),
162            ResolvedScriptContents::Commands(commands) => Cow::Owned(commands.join("\n")),
163            ResolvedScriptContents::Missing => Cow::Borrowed(""),
164        }
165    }
166
167    /// Get the path to the script file (if it was loaded from a file)
168    pub fn path(&self) -> Option<&Path> {
169        match self {
170            ResolvedScriptContents::Path(path, _) => Some(path),
171            _ => None,
172        }
173    }
174
175    /// Determine interpreter based on file extension from the path
176    pub(crate) fn infer_interpreter(&self) -> Option<String> {
177        self.path()
178            .and_then(crate::script::determine_interpreter_from_path)
179    }
180}
181
182impl Script {
183    /// Run the script with the given parameters
184    ///
185    /// This is a high-level convenience method that handles the full script execution flow:
186    /// - Resolves script content (from file or inline)
187    /// - Sets up environment variables and secrets
188    /// - Configures the working directory
189    /// - Renders Jinja templates if a renderer is provided
190    /// - Executes the script in the appropriate interpreter
191    #[allow(clippy::too_many_arguments)]
192    pub async fn run_script<F>(
193        &self,
194        env_vars: HashMap<String, Option<String>>,
195        work_dir: &Path,
196        recipe_dir: &Path,
197        run_prefix: &Path,
198        build_prefix: Option<&PathBuf>,
199        jinja_renderer: Option<F>,
200        sandbox_config: Option<&SandboxConfiguration>,
201        env_isolation: EnvironmentIsolation,
202    ) -> Result<(), crate::InterpreterError>
203    where
204        F: Fn(&str) -> Result<String, String>,
205    {
206        let env_vars = env_vars
207            .into_iter()
208            .filter_map(|(k, v)| v.map(|v| (k, v)))
209            .chain(self.env().clone())
210            .collect::<IndexMap<String, String>>();
211
212        let contents = self.resolve_content(
213            recipe_dir,
214            jinja_renderer,
215            crate::platform_script_extensions(),
216        )?;
217
218        let runtime = RuntimeEnv::current();
219
220        let secrets = self
221            .secrets()
222            .iter()
223            .filter_map(|k| {
224                let secret = k.to_string();
225
226                if let Some(value) = runtime.var(&secret) {
227                    Some((secret, value.to_string()))
228                } else {
229                    tracing::warn!("Secret {} not found in environment", secret);
230                    None
231                }
232            })
233            .collect::<IndexMap<String, String>>();
234
235        let work_dir = if let Some(cwd) = self.cwd.as_ref() {
236            run_prefix.join(cwd)
237        } else {
238            work_dir.to_owned()
239        };
240
241        tracing::debug!("Running script in {}", work_dir.display());
242
243        let exec_args = ExecutionArgs {
244            script: contents,
245            interpreter: self.interpreter.clone(),
246            env_vars,
247            secrets,
248            build_prefix: build_prefix.map(|p| p.to_owned()),
249            run_prefix: run_prefix.to_owned(),
250            runtime,
251            work_dir,
252            sandbox_config: sandbox_config.cloned(),
253            env_isolation,
254        };
255
256        crate::execution::run_script(exec_args).await?;
257
258        Ok(())
259    }
260
261    fn find_file(&self, recipe_dir: &Path, extensions: &[&str], path: &Path) -> Option<PathBuf> {
262        let path = if path.is_absolute() {
263            path.to_path_buf()
264        } else {
265            recipe_dir.join(path)
266        };
267
268        if path.extension().is_none() {
269            extensions
270                .iter()
271                .map(|ext| path.with_extension(ext))
272                .find(|p| p.is_file())
273        } else if path.is_file() {
274            Some(path)
275        } else {
276            None
277        }
278    }
279
280    /// Resolve the script content to actual script text
281    ///
282    /// If `jinja_renderer` is provided, it will be used to render inline scripts.
283    /// The renderer function takes a template string and returns the rendered result.
284    pub fn resolve_content<F>(
285        &self,
286        recipe_dir: &Path,
287        jinja_renderer: Option<F>,
288        extensions: &[&str],
289    ) -> Result<ResolvedScriptContents, std::io::Error>
290    where
291        F: Fn(&str) -> Result<String, String>,
292    {
293        let script_content = match self.contents() {
294            // No script was specified, so we try to read the default script. If the file cannot be
295            // found we return an empty string.
296            ScriptContent::Default => {
297                let recipe_file = self.find_file(recipe_dir, extensions, Path::new("build"));
298                if let Some(recipe_file) = recipe_file {
299                    match fs::read_to_string(&recipe_file) {
300                        Err(e) => Err(e),
301                        Ok(content) => Ok(ResolvedScriptContents::Path(recipe_file, content)),
302                    }
303                } else {
304                    Ok(ResolvedScriptContents::Missing)
305                }
306            }
307
308            // The scripts path was explicitly specified. If the file cannot be found we error out.
309            ScriptContent::Path(path) => {
310                let recipe_file = self.find_file(recipe_dir, extensions, path);
311                if let Some(recipe_file) = recipe_file {
312                    match fs::read_to_string(&recipe_file) {
313                        Err(e) => Err(e),
314                        Ok(content) => Ok(ResolvedScriptContents::Path(recipe_file, content)),
315                    }
316                } else {
317                    Err(std::io::Error::new(
318                        std::io::ErrorKind::NotFound,
319                        format!("could not resolve recipe file {:?}", path.display()),
320                    ))
321                }
322            }
323            // The scripts content was specified but it is still ambiguous whether it is a path or the
324            // contents of the string. Try to read the file as a script but fall back to using the string
325            // as the contents itself if the file is missing.
326            ScriptContent::CommandOrPath(path) => {
327                if path.contains('\n') {
328                    Ok(ResolvedScriptContents::Inline(path.clone()))
329                } else {
330                    let resolved_path = self.find_file(recipe_dir, extensions, Path::new(path));
331                    if let Some(resolved_path) = resolved_path {
332                        match fs::read_to_string(&resolved_path) {
333                            Err(e) => Err(e),
334                            Ok(content) => Ok(ResolvedScriptContents::Path(resolved_path, content)),
335                        }
336                    } else {
337                        Ok(ResolvedScriptContents::Inline(path.clone()))
338                    }
339                }
340            }
341            // Keep the list; the interpreter assembles it in `generate_build_script`.
342            ScriptContent::Commands(commands) => {
343                Ok(ResolvedScriptContents::Commands(commands.clone()))
344            }
345            ScriptContent::Command(command) => {
346                Ok(ResolvedScriptContents::Inline(command.to_owned()))
347            }
348        };
349
350        // Render jinja for inline content, each command individually; file-backed
351        // scripts are not rendered.
352        if let Some(renderer) = jinja_renderer {
353            let render = |script: &str| -> Result<String, std::io::Error> {
354                renderer(script).map_err(|e| {
355                    std::io::Error::other(format!(
356                        "Failed to render jinja template in build `script`: {}",
357                        e
358                    ))
359                })
360            };
361            match script_content? {
362                ResolvedScriptContents::Inline(script) => {
363                    Ok(ResolvedScriptContents::Inline(render(&script)?))
364                }
365                ResolvedScriptContents::Commands(commands) => {
366                    let rendered = commands
367                        .iter()
368                        .map(|c| render(c))
369                        .collect::<Result<Vec<_>, _>>()?;
370                    Ok(ResolvedScriptContents::Commands(rendered))
371                }
372                other => Ok(other),
373            }
374        } else {
375            script_content
376        }
377    }
378}
379
380/// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
381pub(crate) fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
382    FramedRead::new(reader, CrLfNormalizer::default())
383        .into_async_read()
384        .compat()
385}
386
387/// Codec that normalizes CR and CRLF to LF
388#[derive(Default)]
389pub(crate) struct CrLfNormalizer {
390    pub(crate) last_was_cr: bool,
391}
392
393impl Decoder for CrLfNormalizer {
394    type Item = BytesMut;
395    type Error = io::Error;
396
397    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
398        let mut bytes = src.split_off(0);
399        let mut read_index = 0;
400        let mut write_index = 0;
401        while read_index < bytes.len() {
402            match bytes[read_index] {
403                b'\r' => {
404                    bytes[write_index] = b'\n';
405                    write_index += 1;
406                    self.last_was_cr = true;
407                }
408                b'\n' if self.last_was_cr => {
409                    // Skip writing the newline if the last byte was a carriage return.
410                    self.last_was_cr = false
411                }
412                b => {
413                    bytes[write_index] = b;
414                    write_index += 1;
415                    self.last_was_cr = false;
416                }
417            }
418            read_index += 1;
419        }
420
421        if write_index == 0 {
422            Ok(None)
423        } else {
424            bytes.truncate(write_index);
425            Ok(Some(bytes))
426        }
427    }
428}
429
430/// Returns the path to the generated native build wrapper script.
431///
432/// If `args.interpreter` is set, or if the resolved script path has a known
433/// interpreter extension, and that interpreter differs from the wrapper shell,
434/// inline script text is written to a separate file and the native wrapper
435/// invokes the resolved interpreter with that file. Otherwise — no interpreter,
436/// or one that is the wrapper shell itself (`cmd` on Windows, `bash` on Unix) —
437/// the script contents are appended directly to the native wrapper.
438pub(crate) async fn generate_build_script(
439    args: &ExecutionArgs,
440) -> Result<PathBuf, crate::InterpreterError> {
441    let runner = crate::native_runner::native_runner(args.runtime.platform());
442    let shell = runner.shell();
443
444    let script_extension = shell.extension();
445    let activation_script_path = args.work_dir.join(format!("build_env.{script_extension}"));
446    let build_script_path = args
447        .work_dir
448        .join(format!("conda_build.{script_extension}"));
449
450    let activation_script = crate::activation::activation_script(args, shell.clone())
451        .map_err(|err| std::io::Error::other(err.to_string()))?;
452    tokio::fs::write(
453        &activation_script_path,
454        crate::native_runner::write_shell_script(shell.clone(), &activation_script)?,
455    )
456    .await?;
457
458    // Interpreter inference is intentionally done after content resolution:
459    // only file-backed scripts can infer from their resolved path. Inline
460    // scripts use the explicit recipe interpreter or remain native shell code.
461    let explicit_or_inferred = args
462        .interpreter
463        .as_deref()
464        .map(str::to_string)
465        .or_else(|| args.script.infer_interpreter());
466
467    // The interpreter that runs the content (and assembles a command list).
468    // With none specified, default to the wrapper shell from the runner.
469    let interpreter_name = explicit_or_inferred
470        .clone()
471        .unwrap_or_else(|| runner.default_interpreter().to_string());
472    let interpreter = crate::interpreter::SelectedInterpreter::from_recipe_name(&interpreter_name)
473        .ok_or_else(|| crate::InterpreterError::UnsupportedInterpreter(interpreter_name.clone()))?;
474
475    // Whether the content needs a specialized interpreter invocation. An
476    // interpreter that matches the wrapper shell itself (`cmd` on Windows,
477    // `bash` on Unix) is *not* specialized: the wrapper is already executed by
478    // that shell, so its body is inlined directly rather than resolving the
479    // interpreter executable from the build environment and re-invoking it.
480    // This matters most for `cmd`, which is a system shell rather than a
481    // conda-provided executable and would otherwise fail to resolve.
482    let needs_specialized_interpreter = explicit_or_inferred
483        .as_deref()
484        .is_some_and(|name| name != runner.default_interpreter());
485
486    // Assemble the rendered content; the interpreter joins a command list.
487    let script_text = match &args.script {
488        ResolvedScriptContents::Commands(commands) => interpreter.join_commands(commands),
489        ResolvedScriptContents::Inline(script) => script.clone(),
490        ResolvedScriptContents::Path(_, script) => script.clone(),
491        ResolvedScriptContents::Missing => String::new(),
492    };
493
494    let body = if needs_specialized_interpreter {
495        // Specialized interpreter: invoke a script file (the original path, or
496        // one written next to the wrapper).
497        let script_path = match &args.script {
498            ResolvedScriptContents::Path(path, _) => path.clone(),
499            _ => {
500                let path = args
501                    .work_dir
502                    .join(format!("conda_build_script.{}", interpreter.extension()));
503                tokio::fs::write(&path, interpreter.script_contents(&script_text)).await?;
504                path
505            }
506        };
507
508        // Resolve the executable from the activated environment (build prefix,
509        // then host prefix, then the system PATH depending on the interpreter).
510        // The selected interpreter preserves the recipe value (e.g. `nushell`)
511        // for user-facing errors even when the executable has a different name
512        // (e.g. `nu`).
513        let executable = interpreter.resolve_executable(
514            args.build_prefix.as_deref(),
515            &args.run_prefix,
516            &args.runtime,
517        )?;
518
519        // Quote the resolved path and arguments so a prefix or script path
520        // containing spaces survives the native shell.
521        let mut command = vec![executable.to_string_lossy().into_owned()];
522        command.extend(interpreter.args(&script_path));
523        let quoted = command
524            .iter()
525            .map(|arg| crate::native_runner::quote_arg(&shell, arg))
526            .collect::<Vec<_>>();
527        let command_refs = quoted.iter().map(String::as_str).collect::<Vec<_>>();
528        let mut body = String::new();
529        shell
530            .run_command(&mut body, command_refs)
531            .map_err(std::io::Error::other)?;
532        body
533    } else {
534        // No interpreter, or one that matches the wrapper shell: the content is
535        // the native wrapper body, run directly by the wrapper shell.
536        script_text
537    };
538
539    let build_script = format!("{}\n{}", runner.preamble(&activation_script_path), body);
540    tokio::fs::write(
541        &build_script_path,
542        crate::native_runner::write_shell_script(shell, &build_script)?,
543    )
544    .await?;
545
546    #[cfg(unix)]
547    {
548        if build_script_path.extension().and_then(|e| e.to_str()) == Some("sh") {
549            use std::{fs::Permissions, os::unix::fs::PermissionsExt};
550            let permissions = Permissions::from_mode(0o755);
551            tokio::fs::set_permissions(&build_script_path, permissions).await?;
552        }
553    }
554
555    Ok(build_script_path)
556}
557
558/// Runs a script with the given execution arguments.
559pub(crate) async fn run_script(exec_args: ExecutionArgs) -> Result<(), crate::InterpreterError> {
560    let runner = crate::native_runner::native_runner(exec_args.runtime.platform());
561    let build_script_path = generate_build_script(&exec_args).await?;
562    let build_script_path_str = build_script_path.to_string_lossy().to_string();
563    let cmd_args = runner.command_to_run_script(&build_script_path_str);
564
565    let output = crate::execution::run_process_with_replacements(
566        &cmd_args,
567        &exec_args.work_dir,
568        &exec_args.replacements(runner.replacements_template()),
569        &exec_args.env_vars,
570        &exec_args.secrets,
571        exec_args.env_isolation,
572        if runner.supports_sandbox() {
573            exec_args.sandbox_config.as_ref()
574        } else {
575            None
576        },
577        &exec_args.runtime,
578    )
579    .await?;
580
581    if !output.status.success() {
582        let status_code = output.status.code().unwrap_or(1);
583        let debug_info = runner.debug_info(
584            &exec_args.work_dir,
585            &exec_args.run_prefix,
586            exec_args.build_prefix.as_deref(),
587        );
588        tracing::error!("Script failed with status {}", status_code);
589        tracing::error!("{}", debug_info);
590        return Err(crate::InterpreterError::ExecutionFailed(
591            std::io::Error::other(format!(
592                "Script failed with status {}{}",
593                status_code, debug_info
594            )),
595        ));
596    }
597
598    Ok(())
599}
600
601/// Creates build script files without executing them.
602pub async fn create_build_script(exec_args: ExecutionArgs) -> Result<(), std::io::Error> {
603    let build_script_path = generate_build_script(&exec_args)
604        .await
605        .map_err(|err| match err {
606            crate::InterpreterError::ExecutionFailed(err) => err,
607            crate::InterpreterError::InterpreterNotFound(interpreter) => std::io::Error::other(
608                format!("interpreter '{interpreter}' was not found in the build environment"),
609            ),
610            crate::InterpreterError::InvalidInterpreter {
611                interpreter,
612                reason,
613            } => std::io::Error::other(format!(
614                "interpreter '{interpreter}' was found but is not valid: {reason}"
615            )),
616            crate::InterpreterError::UnsupportedInterpreter(interpreter) => {
617                let suggestion = crate::interpreter::closest_interpreter(&interpreter)
618                    .map(|s| format!(". Did you mean `{s}`?"))
619                    .unwrap_or_default();
620                std::io::Error::other(format!(
621                    "unsupported interpreter '{interpreter}'{suggestion}"
622                ))
623            }
624        })?;
625
626    tracing::info!("Build script created at {}", build_script_path.display());
627    Ok(())
628}
629
630/// Finds the rattler-sandbox executable on the runtime `PATH`.
631fn find_rattler_sandbox(runtime: &RuntimeEnv) -> Option<PathBuf> {
632    which::which_in_global("rattler-sandbox", Some(runtime.path()))
633        .ok()?
634        .next()
635}
636
637/// Environment variables that are passed through from the host environment
638/// into the build subprocess. These are variables that cannot be computed
639/// by rattler-build but are needed for builds to function correctly.
640const PASSTHROUGH_ENV_VARS: &[&str] = &[
641    // TLS certificates (needed for https in build scripts)
642    "SSL_CERT_FILE",
643    "SSL_CERT_DIR",
644    // Python requests CA bundle (needed for pip/requests in corporate environments)
645    "REQUESTS_CA_BUNDLE",
646    // SSH agent (needed for private git repo access)
647    "SSH_AUTH_SOCK",
648    // Display server (needed for GUI-related builds on Linux)
649    "DISPLAY",
650    // Proxy configuration (needed in corporate/CI environments)
651    "http_proxy",
652    "https_proxy",
653    "HTTP_PROXY",
654    "HTTPS_PROXY",
655    "no_proxy",
656    "NO_PROXY",
657];
658
659/// Platform-critical environment variables that must always be passed through
660/// to avoid breaking fundamental OS functionality.
661#[cfg(target_os = "windows")]
662const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[
663    // Required for Winsock/networking and DLL loading
664    "SYSTEMROOT",
665    "WINDIR",
666    // Command interpreter
667    "COMSPEC",
668    // Temp directories
669    "TEMP",
670    "TMP",
671    // Executable extension resolution
672    "PATHEXT",
673];
674
675/// Platform-critical environment variables that must always be passed through
676/// to avoid breaking fundamental OS functionality.
677#[cfg(target_os = "macos")]
678const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[
679    // macOS uses per-session temp directories
680    "TMPDIR",
681    // CoreFoundation text encoding
682    "__CF_USER_TEXT_ENCODING",
683];
684
685/// Platform-critical environment variables that must always be passed through
686/// to avoid breaking fundamental OS functionality.
687#[cfg(not(any(target_os = "windows", target_os = "macos")))]
688const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[];
689
690/// Configures the subprocess environment for the given isolation mode.
691fn configure_subprocess_env(
692    command: &mut tokio::process::Command,
693    env_vars: &IndexMap<String, String>,
694    secrets: &IndexMap<String, String>,
695    env_isolation: EnvironmentIsolation,
696    runtime: &RuntimeEnv,
697) {
698    match env_isolation {
699        EnvironmentIsolation::Strict | EnvironmentIsolation::CondaBuild => {
700            command.env_clear();
701
702            for var in PASSTHROUGH_ENV_VARS
703                .iter()
704                .chain(PLATFORM_PASSTHROUGH_ENV_VARS)
705            {
706                if let Some(value) = runtime.var(var) {
707                    command.env(var, value);
708                }
709            }
710
711            command.envs(env_vars);
712            command.envs(secrets.iter());
713        }
714        EnvironmentIsolation::None => {
715            command.envs(env_vars);
716        }
717    }
718}
719
720/// Spawns a process and replaces the given strings in the output with the given replacements.
721/// This is used to replace the host prefix with $PREFIX and the build prefix with $BUILD_PREFIX
722#[allow(clippy::too_many_arguments)]
723pub(crate) async fn run_process_with_replacements(
724    args: &[&str],
725    cwd: &Path,
726    replacements: &HashMap<String, String>,
727    env_vars: &IndexMap<String, String>,
728    secrets: &IndexMap<String, String>,
729    env_isolation: EnvironmentIsolation,
730    sandbox_config: Option<&SandboxConfiguration>,
731    runtime: &RuntimeEnv,
732) -> Result<std::process::Output, std::io::Error> {
733    // Create or open the build log file
734    let log_file_path = cwd.join("conda_build.log");
735    let mut log_file = tokio::fs::OpenOptions::new()
736        .create(true)
737        .append(true)
738        .open(&log_file_path)
739        .await?;
740    let mut command = if let Some(sandbox_config) = sandbox_config {
741        tracing::info!("{}", sandbox_config);
742
743        // Try to find rattler-sandbox executable
744        if let Some(sandbox_exe) = find_rattler_sandbox(runtime) {
745            let mut cmd = tokio::process::Command::new(sandbox_exe);
746
747            // Add sandbox configuration arguments
748            let sandbox_args = sandbox_config.with_cwd(cwd).to_args();
749            cmd.args(&sandbox_args);
750
751            // Add the actual command to execute (as positional arguments)
752            cmd.arg(args[0]);
753            cmd.args(&args[1..]);
754
755            cmd
756        } else {
757            tracing::error!("rattler-sandbox executable not found in PATH");
758            tracing::error!("Please install it by running: pixi global install rattler-sandbox");
759            return Err(std::io::Error::new(
760                std::io::ErrorKind::NotFound,
761                "rattler-sandbox executable not found. Please install it with: pixi global install rattler-sandbox",
762            ));
763        }
764    } else {
765        tokio::process::Command::new(args[0])
766    };
767
768    configure_subprocess_env(&mut command, env_vars, secrets, env_isolation, runtime);
769
770    command
771        .current_dir(cwd)
772        // when using `pixi global install bash` the current work dir
773        // causes some strange issues that are fixed when setting the `PWD`
774        .env("PWD", cwd)
775        .args(&args[1..])
776        .stdin(Stdio::null())
777        .stdout(Stdio::piped())
778        .stderr(Stdio::piped());
779
780    let mut child = command.spawn()?;
781
782    let stdout = child.stdout.take().expect("Failed to take stdout");
783    let stderr = child.stderr.take().expect("Failed to take stderr");
784
785    let stdout_wrapped = normalize_crlf(stdout);
786    let stderr_wrapped = normalize_crlf(stderr);
787
788    let mut stdout_lines = tokio::io::BufReader::new(stdout_wrapped).lines();
789    let mut stderr_lines = tokio::io::BufReader::new(stderr_wrapped).lines();
790
791    let mut stdout_log = String::new();
792    let mut stderr_log = String::new();
793    let mut closed = (false, false);
794
795    loop {
796        let (line, is_stderr) = tokio::select! {
797            line = stdout_lines.next_line() => (line, false),
798            line = stderr_lines.next_line() => (line, true),
799            else => break,
800        };
801
802        match line {
803            Ok(Some(line)) => {
804                let filtered_line = replacements
805                    .iter()
806                    .fold(line, |acc, (from, to)| acc.replace(from, to));
807
808                if is_stderr {
809                    stderr_log.push_str(&filtered_line);
810                    stderr_log.push('\n');
811                } else {
812                    stdout_log.push_str(&filtered_line);
813                    stdout_log.push('\n');
814                }
815
816                // Write to log file
817                if let Err(e) = log_file.write_all(filtered_line.as_bytes()).await {
818                    tracing::warn!("Failed to write to build log: {:?}", e);
819                }
820                if let Err(e) = log_file.write_all(b"\n").await {
821                    tracing::warn!("Failed to write newline to build log: {:?}", e);
822                }
823
824                tracing::info!("{}", filtered_line);
825            }
826            Ok(None) if !is_stderr => closed.0 = true,
827            Ok(None) if is_stderr => closed.1 = true,
828            Ok(None) => unreachable!(),
829            Err(e) => {
830                tracing::warn!("Error reading output: {:?}", e);
831                break;
832            }
833        };
834        // make sure we close the loop when both stdout and stderr are closed
835        if closed == (true, true) {
836            break;
837        }
838    }
839
840    let status = child.wait().await?;
841
842    // Flush and close the log file
843    if let Err(e) = log_file.flush().await {
844        tracing::warn!("Failed to flush build log: {:?}", e);
845    }
846
847    Ok(std::process::Output {
848        status,
849        stdout: stdout_log.into_bytes(),
850        stderr: stderr_log.into_bytes(),
851    })
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use rattler_conda_types::Platform;
858    use tokio_util::bytes::BytesMut;
859
860    /// `CONDA_BUILD=1` must live inside the sourced activation script so that
861    /// nested shells inherit it while the outer subprocess starts without it.
862    #[test]
863    fn test_conda_build_marker_written_into_build_env_script() {
864        use rattler_shell::shell;
865
866        let tmp = tempfile::tempdir().unwrap();
867        let prefix = tmp.path().join("prefix");
868        fs_err::create_dir_all(&prefix).unwrap();
869
870        let args = ExecutionArgs {
871            script: ResolvedScriptContents::Inline(String::new()),
872            interpreter: None,
873            env_vars: IndexMap::new(),
874            secrets: IndexMap::new(),
875            runtime: RuntimeEnv::for_test(Platform::current()),
876            build_prefix: None,
877            run_prefix: prefix,
878            work_dir: tmp.path().to_path_buf(),
879            sandbox_config: None,
880            env_isolation: EnvironmentIsolation::None,
881        };
882
883        let script = crate::activation::activation_script(&args, shell::Bash::default()).unwrap();
884        assert!(
885            script.contains("CONDA_BUILD") && script.contains("1"),
886            "build_env.sh must set CONDA_BUILD=1 for nested-shell re-entrancy, got:\n{script}"
887        );
888    }
889
890    /// The outer subprocess must start without `CONDA_BUILD` set, otherwise
891    /// the preamble skips sourcing the activation script.
892    #[test]
893    fn test_conda_build_not_leaked_to_subprocess_in_none_mode() {
894        let env_vars = IndexMap::new();
895        let secrets = IndexMap::new();
896
897        let mut command = tokio::process::Command::new("true");
898        configure_subprocess_env(
899            &mut command,
900            &env_vars,
901            &secrets,
902            EnvironmentIsolation::None,
903            &RuntimeEnv::for_test(Platform::current()),
904        );
905
906        assert!(
907            !command.as_std().get_envs().any(|(k, _)| k == "CONDA_BUILD"),
908            "CONDA_BUILD must not be set on the outer subprocess"
909        );
910    }
911
912    /// `resolve_content` keeps a command list as `Commands`, unjoined and
913    /// without shell-specific error handling (the interpreter's job).
914    #[test]
915    fn test_commands_resolved_as_list() {
916        use crate::script::{Script, ScriptContent};
917        let commands = vec!["echo Hello".to_string(), "echo World".to_string()];
918        let script = Script {
919            content: ScriptContent::Commands(commands.clone()),
920            interpreter: None,
921            env: IndexMap::new(),
922            secrets: Vec::new(),
923            cwd: None,
924            content_explicit: false,
925        };
926
927        let resolved = script
928            .resolve_content(
929                std::path::Path::new("."),
930                None::<fn(&str) -> Result<String, String>>,
931                &["bat"],
932            )
933            .unwrap();
934
935        match resolved {
936            ResolvedScriptContents::Commands(c) => assert_eq!(c, commands),
937            other => panic!("expected Commands variant, got {other:?}"),
938        }
939    }
940
941    /// A command list is jinja-rendered per command in `resolve_content`.
942    #[test]
943    fn test_command_list_rendered_per_command() {
944        use crate::script::{Script, ScriptContent};
945        let script = Script {
946            content: ScriptContent::Commands(vec![
947                "echo MARK one".to_string(),
948                "echo MARK two".to_string(),
949            ]),
950            ..Script::default()
951        };
952        let renderer = |s: &str| -> Result<String, String> { Ok(s.replace("MARK", "rendered")) };
953
954        let resolved = script
955            .resolve_content(std::path::Path::new("."), Some(renderer), &["sh"])
956            .unwrap();
957
958        match resolved {
959            ResolvedScriptContents::Commands(c) => {
960                assert_eq!(c, vec!["echo rendered one", "echo rendered two"]);
961            }
962            other => panic!("expected Commands variant, got {other:?}"),
963        }
964    }
965
966    /// Unified path: a command list with no interpreter, on a Windows runtime,
967    /// is assembled by `cmd` (errorlevel) into the generated `.bat`, on any host.
968    #[tokio::test]
969    async fn test_command_list_errorlevel_in_generated_cmd_wrapper() {
970        let tmp = tempfile::tempdir().unwrap();
971        let prefix = tmp.path().join("prefix");
972        fs::create_dir_all(&prefix).unwrap();
973
974        let args = ExecutionArgs {
975            script: ResolvedScriptContents::Commands(vec![
976                "echo Hello".to_string(),
977                "echo World".to_string(),
978            ]),
979            interpreter: None,
980            env_vars: IndexMap::new(),
981            secrets: IndexMap::new(),
982            runtime: RuntimeEnv::for_test(Platform::Win64),
983            build_prefix: None,
984            run_prefix: prefix,
985            work_dir: tmp.path().to_path_buf(),
986            sandbox_config: None,
987            env_isolation: EnvironmentIsolation::None,
988        };
989
990        crate::execution::generate_build_script(&args)
991            .await
992            .unwrap();
993
994        let wrapper = fs::read_to_string(tmp.path().join("conda_build.bat")).unwrap();
995        assert!(
996            wrapper.contains("if %errorlevel% neq 0 exit /b %errorlevel%"),
997            "cmd wrapper must propagate errors between commands, got:\n{wrapper}"
998        );
999    }
1000
1001    #[test]
1002    fn test_crlf_normalizer_no_crlf() {
1003        let mut normalizer = CrLfNormalizer::default();
1004        let mut buffer = BytesMut::from("test string with no CR or LF");
1005
1006        let result = normalizer.decode(&mut buffer).unwrap();
1007        assert!(result.is_some());
1008        assert_eq!(result.unwrap(), "test string with no CR or LF");
1009
1010        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
1011        assert!(eof_result.is_none());
1012    }
1013
1014    #[test]
1015    fn test_crlf_normalizer_with_crlf() {
1016        let mut normalizer = CrLfNormalizer::default();
1017        let mut buffer = BytesMut::from("line1\r\nline2\r\nline3");
1018
1019        let result = normalizer.decode(&mut buffer).unwrap();
1020        assert!(result.is_some());
1021        assert_eq!(result.unwrap(), "line1\nline2\nline3");
1022
1023        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
1024        assert!(eof_result.is_none());
1025    }
1026
1027    #[test]
1028    fn test_crlf_normalizer_with_cr_only() {
1029        let mut normalizer = CrLfNormalizer::default();
1030        let mut buffer = BytesMut::from("line1\rline2\rline3");
1031
1032        let result = normalizer.decode(&mut buffer).unwrap();
1033        assert!(result.is_some());
1034        assert_eq!(result.unwrap(), "line1\nline2\nline3");
1035
1036        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
1037        assert!(eof_result.is_none());
1038    }
1039
1040    #[test]
1041    fn test_crlf_normalizer_with_cr_at_end() {
1042        let mut normalizer = CrLfNormalizer::default();
1043        let mut buffer = BytesMut::from("line1\r");
1044
1045        let result = normalizer.decode(&mut buffer).unwrap();
1046        assert!(result.is_some());
1047        assert_eq!(result.unwrap(), "line1\n");
1048        assert!(normalizer.last_was_cr);
1049
1050        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
1051        assert!(eof_result.is_none());
1052    }
1053
1054    #[test]
1055    fn test_crlf_normalizer_with_split_crlf() {
1056        let mut normalizer = CrLfNormalizer::default();
1057
1058        // decoder gets the \r until final part of the buffer so that it doesnt try to solve it as none
1059        let mut buffer1 = BytesMut::from("line1\r");
1060        let result1 = normalizer.decode(&mut buffer1).unwrap();
1061        assert!(result1.is_some());
1062        assert_eq!(result1.unwrap(), "line1\n");
1063        assert!(normalizer.last_was_cr);
1064
1065        let mut buffer2 = BytesMut::from("\nline2");
1066        let result2 = normalizer.decode(&mut buffer2).unwrap();
1067        assert!(result2.is_some());
1068        assert_eq!(result2.unwrap(), "line2");
1069
1070        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
1071        assert!(eof_result.is_none());
1072    }
1073
1074    #[test]
1075    fn test_crlf_normalizer_with_multiple_cr_at_end() {
1076        let mut normalizer = CrLfNormalizer::default();
1077        let mut buffer = BytesMut::from("line1\r\r\r");
1078
1079        let result = normalizer.decode(&mut buffer).unwrap();
1080        assert!(result.is_some());
1081        assert_eq!(result.unwrap(), "line1\n\n\n");
1082        assert!(normalizer.last_was_cr);
1083
1084        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
1085        assert!(eof_result.is_none());
1086    }
1087
1088    #[test]
1089    fn test_crlf_normalizer_with_empty_buffer() {
1090        let mut normalizer = CrLfNormalizer::default();
1091        let mut buffer = BytesMut::new();
1092
1093        let result = normalizer.decode(&mut buffer).unwrap();
1094        assert!(result.is_none());
1095
1096        let eof_result = normalizer.decode_eof(&mut buffer).unwrap();
1097        assert!(eof_result.is_none());
1098    }
1099
1100    #[test]
1101    fn test_crlf_normalizer_with_pending_cr_and_empty_buffer() {
1102        let mut normalizer = CrLfNormalizer { last_was_cr: true };
1103        let mut buffer = BytesMut::new();
1104
1105        let result = normalizer.decode(&mut buffer).unwrap();
1106        assert!(result.is_none());
1107
1108        let eof_result = normalizer.decode_eof(&mut buffer).unwrap();
1109        assert!(eof_result.is_none());
1110    }
1111
1112    #[test]
1113    fn test_infer_interpreter_from_resolved_contents() {
1114        use std::path::PathBuf;
1115
1116        let resolved_path =
1117            ResolvedScriptContents::Path(PathBuf::from("build.py"), "print('hello')".to_string());
1118        assert_eq!(
1119            resolved_path.infer_interpreter(),
1120            Some("python".to_string())
1121        );
1122
1123        let resolved_inline = ResolvedScriptContents::Inline("echo 'hello'".to_string());
1124        assert_eq!(resolved_inline.infer_interpreter(), None);
1125
1126        let resolved_missing = ResolvedScriptContents::Missing;
1127        assert_eq!(resolved_missing.infer_interpreter(), None);
1128    }
1129
1130    /// Regression test for <https://github.com/prefix-dev/rattler-build/issues/2199>
1131    ///
1132    /// Extension order in `resolve_content` determines which file is picked
1133    /// when both `.sh` and `.bat` exist. `platform_script_extensions()` must
1134    /// select the platform-appropriate one.
1135    #[test]
1136    fn test_script_extension_resolution_respects_order() {
1137        use std::path::PathBuf;
1138
1139        use crate::script::{Script, ScriptContent};
1140
1141        let dir = tempfile::tempdir().unwrap();
1142        fs::write(dir.path().join("test-script.sh"), "#!/bin/bash\necho hello").unwrap();
1143        fs::write(dir.path().join("test-script.bat"), "@echo off\necho hello").unwrap();
1144
1145        let resolve = |content: ScriptContent, exts: &[&str]| -> PathBuf {
1146            let script = Script {
1147                content,
1148                ..Script::default()
1149            };
1150            match script
1151                .resolve_content(dir.path(), None::<fn(&str) -> Result<String, String>>, exts)
1152                .unwrap()
1153            {
1154                ResolvedScriptContents::Path(path, _) => path,
1155                other => panic!("expected Path variant, got {:?}", other),
1156            }
1157        };
1158
1159        // Extension list order controls which file wins
1160        let path_content = || ScriptContent::Path(PathBuf::from("test-script"));
1161        assert_eq!(
1162            resolve(path_content(), &["sh", "bat"]).extension().unwrap(),
1163            "sh"
1164        );
1165        assert_eq!(
1166            resolve(path_content(), &["bat", "sh"]).extension().unwrap(),
1167            "bat"
1168        );
1169
1170        // CommandOrPath variant behaves the same
1171        let cop_content = || ScriptContent::CommandOrPath("test-script".into());
1172        assert_eq!(resolve(cop_content(), &["sh"]).extension().unwrap(), "sh");
1173        assert_eq!(resolve(cop_content(), &["bat"]).extension().unwrap(), "bat");
1174
1175        // platform_script_extensions() picks the right one for the current platform
1176        let ext = resolve(path_content(), crate::platform_script_extensions())
1177            .extension()
1178            .unwrap()
1179            .to_owned();
1180        assert_eq!(ext, if cfg!(windows) { "bat" } else { "sh" });
1181    }
1182
1183    use rattler_shell::activation::prefix_path_entries;
1184
1185    /// Mirrors the interpreter-module test helper: places a 0-byte executable in
1186    /// the prefix's bin directory and returns its path.
1187    fn create_fake_executable(prefix: &Path, name: &str) -> PathBuf {
1188        let exe_name = format!("{}{}", name, std::env::consts::EXE_SUFFIX);
1189        let bin_dir = prefix_path_entries(prefix, &Platform::current())
1190            .into_iter()
1191            .next()
1192            .expect("prefix has executable path entries");
1193        fs::create_dir_all(&bin_dir).unwrap();
1194        let exe = bin_dir.join(exe_name);
1195        fs::write(&exe, "").unwrap();
1196        #[cfg(unix)]
1197        {
1198            use std::{fs::Permissions, os::unix::fs::PermissionsExt};
1199            fs::set_permissions(&exe, Permissions::from_mode(0o755)).unwrap();
1200        }
1201        exe
1202    }
1203
1204    fn execution_args(
1205        work_dir: PathBuf,
1206        run_prefix: PathBuf,
1207        script: ResolvedScriptContents,
1208        interpreter: Option<&str>,
1209    ) -> ExecutionArgs {
1210        ExecutionArgs {
1211            script,
1212            interpreter: interpreter.map(str::to_string),
1213            env_vars: IndexMap::new(),
1214            secrets: IndexMap::new(),
1215            runtime: RuntimeEnv::current(),
1216            build_prefix: None,
1217            run_prefix,
1218            work_dir,
1219            sandbox_config: None,
1220            env_isolation: EnvironmentIsolation::None,
1221        }
1222    }
1223
1224    /// In Strict mode the subprocess env is cleared, only the passthrough
1225    /// whitelist is forwarded from the host, and explicit env_vars + secrets are
1226    /// applied on top. The host vars are injected through `RuntimeEnv`, so the
1227    /// test does not touch the real process environment.
1228    #[test]
1229    fn test_strict_env_clear_and_passthrough_whitelist() {
1230        let runtime = RuntimeEnv::for_test(Platform::current())
1231            .with_var("RB_TEST_RANDOM_VAR", "should-not-leak")
1232            .with_var("SSL_CERT_FILE", "/host/cacert.pem");
1233
1234        let mut env_vars = IndexMap::new();
1235        env_vars.insert("EXPLICIT_VAR".to_string(), "explicit".to_string());
1236        let mut secrets = IndexMap::new();
1237        secrets.insert("SECRET_VAR".to_string(), "secret".to_string());
1238
1239        let collect_envs = |isolation: EnvironmentIsolation| {
1240            let mut command = tokio::process::Command::new("true");
1241            configure_subprocess_env(&mut command, &env_vars, &secrets, isolation, &runtime);
1242            command
1243                .as_std()
1244                .get_envs()
1245                .filter_map(|(k, v)| {
1246                    v.map(|v| {
1247                        (
1248                            k.to_string_lossy().into_owned(),
1249                            v.to_string_lossy().into_owned(),
1250                        )
1251                    })
1252                })
1253                .collect::<HashMap<String, String>>()
1254        };
1255
1256        let strict = collect_envs(EnvironmentIsolation::Strict);
1257        assert!(
1258            !strict.contains_key("RB_TEST_RANDOM_VAR"),
1259            "non-whitelisted host var must be absent in Strict mode"
1260        );
1261        assert_eq!(
1262            strict.get("SSL_CERT_FILE").map(String::as_str),
1263            Some("/host/cacert.pem"),
1264            "whitelisted host var must be passed through"
1265        );
1266        assert_eq!(
1267            strict.get("EXPLICIT_VAR").map(String::as_str),
1268            Some("explicit")
1269        );
1270        assert_eq!(strict.get("SECRET_VAR").map(String::as_str), Some("secret"));
1271
1272        // CondaBuild also clears the env and applies the same whitelist.
1273        let conda_build = collect_envs(EnvironmentIsolation::CondaBuild);
1274        assert!(
1275            !conda_build.contains_key("RB_TEST_RANDOM_VAR"),
1276            "non-whitelisted host var must be absent in CondaBuild mode"
1277        );
1278        assert_eq!(
1279            conda_build.get("SSL_CERT_FILE").map(String::as_str),
1280            Some("/host/cacert.pem")
1281        );
1282    }
1283
1284    /// The PowerShell prologue is written verbatim into the generated script
1285    /// file for an inline body.
1286    #[tokio::test]
1287    async fn test_powershell_prologue_written_into_script_file() {
1288        let tmp = tempfile::tempdir().unwrap();
1289        let prefix = tmp.path().join("prefix");
1290        fs::create_dir_all(&prefix).unwrap();
1291        // The fake pwsh is 0 bytes; `is_pwsh_new_enough` will fail to parse a
1292        // version and only warn, so resolution still succeeds.
1293        create_fake_executable(&prefix, "pwsh");
1294
1295        let args = execution_args(
1296            tmp.path().to_path_buf(),
1297            prefix,
1298            ResolvedScriptContents::Inline("Write-Output 'hi'".to_string()),
1299            Some("powershell"),
1300        );
1301
1302        generate_build_script(&args).await.unwrap();
1303
1304        let script_file = tmp.path().join("conda_build_script.ps1");
1305        let contents = fs::read_to_string(&script_file).unwrap();
1306        assert!(
1307            contents.contains("$ErrorActionPreference = 'Stop'"),
1308            "missing ErrorActionPreference, got:\n{contents}"
1309        );
1310        assert!(
1311            contents.contains("$PSNativeCommandUseErrorActionPreference"),
1312            "missing PSNativeCommandUseErrorActionPreference, got:\n{contents}"
1313        );
1314        assert!(
1315            contents.contains("Write-Output 'hi'"),
1316            "user body must be appended after the prologue"
1317        );
1318    }
1319
1320    /// `create_build_script` maps `InterpreterNotFound` to an io::Error whose
1321    /// message mentions the build environment. `brush` is build-prefix-only, so
1322    /// it errors when absent (unlike `python`, which falls back to `PATH`).
1323    #[tokio::test]
1324    async fn test_create_build_script_missing_interpreter_error() {
1325        let tmp = tempfile::tempdir().unwrap();
1326        let prefix = tmp.path().join("prefix");
1327        fs::create_dir_all(&prefix).unwrap();
1328
1329        let args = execution_args(
1330            tmp.path().to_path_buf(),
1331            prefix,
1332            ResolvedScriptContents::Inline("echo hi".to_string()),
1333            Some("brush"),
1334        );
1335
1336        let err = create_build_script(args).await.unwrap_err();
1337        assert!(
1338            err.to_string()
1339                .contains("was not found in the build environment"),
1340            "unexpected error: {err}"
1341        );
1342    }
1343
1344    /// An unsupported interpreter name surfaces as an `unsupported interpreter`
1345    /// io::Error, with a "did you mean" suggestion for near-misses only.
1346    #[tokio::test]
1347    async fn test_create_build_script_unsupported_interpreter_error() {
1348        let tmp = tempfile::tempdir().unwrap();
1349        let prefix = tmp.path().join("prefix");
1350        fs::create_dir_all(&prefix).unwrap();
1351
1352        let unsupported_error = |interpreter: &str| {
1353            let args = execution_args(
1354                tmp.path().to_path_buf(),
1355                tmp.path().join("prefix"),
1356                ResolvedScriptContents::Inline("noop".to_string()),
1357                Some(interpreter),
1358            );
1359            async { create_build_script(args).await.unwrap_err().to_string() }
1360        };
1361
1362        let message = unsupported_error("not-a-real-interp").await;
1363        assert!(
1364            message.contains("unsupported interpreter 'not-a-real-interp'"),
1365            "unexpected error: {message}"
1366        );
1367        assert!(
1368            !message.contains("Did you mean"),
1369            "no suggestion expected for an unrelated name: {message}"
1370        );
1371
1372        let message = unsupported_error("brus").await;
1373        assert!(
1374            message.contains("Did you mean `brush`?"),
1375            "unexpected error: {message}"
1376        );
1377    }
1378
1379    /// A typo in the recipe `interpreter` (issue #2530) surfaces as
1380    /// `UnsupportedInterpreter` instead of a generic execution failure.
1381    #[tokio::test]
1382    async fn test_generate_build_script_interpreter_typo_error() {
1383        let tmp = tempfile::tempdir().unwrap();
1384        let prefix = tmp.path().join("prefix");
1385        fs::create_dir_all(&prefix).unwrap();
1386
1387        let args = execution_args(
1388            tmp.path().to_path_buf(),
1389            prefix,
1390            ResolvedScriptContents::Inline("echo \"Hello from brush!\"".to_string()),
1391            Some("brus"),
1392        );
1393
1394        let err = generate_build_script(&args).await.unwrap_err();
1395        assert!(
1396            matches!(err, crate::InterpreterError::UnsupportedInterpreter(ref name) if name == "brus"),
1397            "expected UnsupportedInterpreter, got {err:?}"
1398        );
1399    }
1400
1401    /// `EnvironmentIsolation` round-trips between `FromStr` and `Display`, and an
1402    /// unknown value errors with the documented message.
1403    #[test]
1404    fn test_environment_isolation_round_trip() {
1405        use std::str::FromStr;
1406
1407        for (text, value) in [
1408            ("strict", EnvironmentIsolation::Strict),
1409            ("conda-build", EnvironmentIsolation::CondaBuild),
1410            ("none", EnvironmentIsolation::None),
1411        ] {
1412            assert_eq!(EnvironmentIsolation::from_str(text).unwrap(), value);
1413            assert_eq!(value.to_string(), text);
1414        }
1415
1416        let err = EnvironmentIsolation::from_str("bogus").unwrap_err();
1417        assert!(
1418            err.contains("unknown environment isolation mode 'bogus'"),
1419            "unexpected error: {err}"
1420        );
1421    }
1422}