1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum EnvironmentIsolation {
32 #[default]
37 Strict,
38 CondaBuild,
42 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#[derive(Debug)]
75pub struct ExecutionArgs {
76 pub script: ResolvedScriptContents,
78 pub interpreter: Option<String>,
80 pub env_vars: IndexMap<String, String>,
82 pub secrets: IndexMap<String, String>,
84
85 pub runtime: RuntimeEnv,
88
89 pub build_prefix: Option<PathBuf>,
91 pub run_prefix: PathBuf,
93
94 pub work_dir: PathBuf,
96
97 pub sandbox_config: Option<SandboxConfiguration>,
99
100 pub env_isolation: EnvironmentIsolation,
102}
103
104impl ExecutionArgs {
105 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 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#[derive(Debug)]
143pub enum ResolvedScriptContents {
144 Path(PathBuf, String),
146 Inline(String),
148 Commands(Vec<String>),
151 Missing,
153}
154
155impl ResolvedScriptContents {
156 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 pub fn path(&self) -> Option<&Path> {
169 match self {
170 ResolvedScriptContents::Path(path, _) => Some(path),
171 _ => None,
172 }
173 }
174
175 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 #[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 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 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 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 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 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 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
380pub(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#[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 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
430pub(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 let explicit_or_inferred = args
462 .interpreter
463 .as_deref()
464 .map(str::to_string)
465 .or_else(|| args.script.infer_interpreter());
466
467 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 let needs_specialized_interpreter = explicit_or_inferred
483 .as_deref()
484 .is_some_and(|name| name != runner.default_interpreter());
485
486 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 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 let executable = interpreter.resolve_executable(
514 args.build_prefix.as_deref(),
515 &args.run_prefix,
516 &args.runtime,
517 )?;
518
519 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 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
558pub(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
601pub 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
630fn find_rattler_sandbox(runtime: &RuntimeEnv) -> Option<PathBuf> {
632 which::which_in_global("rattler-sandbox", Some(runtime.path()))
633 .ok()?
634 .next()
635}
636
637const PASSTHROUGH_ENV_VARS: &[&str] = &[
641 "SSL_CERT_FILE",
643 "SSL_CERT_DIR",
644 "REQUESTS_CA_BUNDLE",
646 "SSH_AUTH_SOCK",
648 "DISPLAY",
650 "http_proxy",
652 "https_proxy",
653 "HTTP_PROXY",
654 "HTTPS_PROXY",
655 "no_proxy",
656 "NO_PROXY",
657];
658
659#[cfg(target_os = "windows")]
662const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[
663 "SYSTEMROOT",
665 "WINDIR",
666 "COMSPEC",
668 "TEMP",
670 "TMP",
671 "PATHEXT",
673];
674
675#[cfg(target_os = "macos")]
678const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[
679 "TMPDIR",
681 "__CF_USER_TEXT_ENCODING",
683];
684
685#[cfg(not(any(target_os = "windows", target_os = "macos")))]
688const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[];
689
690fn 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#[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 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 if let Some(sandbox_exe) = find_rattler_sandbox(runtime) {
745 let mut cmd = tokio::process::Command::new(sandbox_exe);
746
747 let sandbox_args = sandbox_config.with_cwd(cwd).to_args();
749 cmd.args(&sandbox_args);
750
751 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 .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 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 if closed == (true, true) {
836 break;
837 }
838 }
839
840 let status = child.wait().await?;
841
842 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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}