use crate::runtime::RuntimeEnv;
use crate::sandbox::SandboxConfiguration;
use crate::script::{Script, ScriptContent};
use fs_err as fs;
use futures::TryStreamExt;
use indexmap::IndexMap;
use rattler_shell::shell::Shell;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt};
use tokio_util::bytes::BytesMut;
use tokio_util::codec::{Decoder, FramedRead};
use tokio_util::compat::FuturesAsyncReadCompatExt;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EnvironmentIsolation {
#[default]
Strict,
CondaBuild,
None,
}
impl fmt::Display for EnvironmentIsolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Strict => write!(f, "strict"),
Self::CondaBuild => write!(f, "conda-build"),
Self::None => write!(f, "none"),
}
}
}
impl std::str::FromStr for EnvironmentIsolation {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"strict" => Ok(Self::Strict),
"conda-build" => Ok(Self::CondaBuild),
"none" => Ok(Self::None),
_ => Err(format!(
"unknown environment isolation mode '{}', expected 'strict', 'conda-build', or 'none'",
s
)),
}
}
}
#[derive(Debug)]
pub struct ExecutionArgs {
pub script: ResolvedScriptContents,
pub interpreter: Option<String>,
pub env_vars: IndexMap<String, String>,
pub secrets: IndexMap<String, String>,
pub runtime: RuntimeEnv,
pub build_prefix: Option<PathBuf>,
pub run_prefix: PathBuf,
pub work_dir: PathBuf,
pub sandbox_config: Option<SandboxConfiguration>,
pub env_isolation: EnvironmentIsolation,
}
impl ExecutionArgs {
pub(crate) fn replacements(&self, template: &str) -> HashMap<String, String> {
let mut replacements = HashMap::new();
if let Some(build_prefix) = &self.build_prefix {
replacements.insert(
build_prefix.display().to_string(),
template.replace("((var))", "BUILD_PREFIX"),
);
};
replacements.insert(
self.run_prefix.display().to_string(),
template.replace("((var))", "PREFIX"),
);
replacements.insert(
self.work_dir.display().to_string(),
template.replace("((var))", "SRC_DIR"),
);
for (k, v) in replacements.clone() {
if k.contains('\\') {
replacements.insert(k.replace('\\', "/"), v.clone());
}
}
self.secrets.iter().for_each(|(_, v)| {
replacements.insert(v.to_string(), "********".to_string());
});
replacements
}
}
#[derive(Debug)]
pub enum ResolvedScriptContents {
Path(PathBuf, String),
Inline(String),
Commands(Vec<String>),
Missing,
}
impl ResolvedScriptContents {
pub fn script(&self) -> Cow<'_, str> {
match self {
ResolvedScriptContents::Path(_, script) => Cow::Borrowed(script),
ResolvedScriptContents::Inline(script) => Cow::Borrowed(script),
ResolvedScriptContents::Commands(commands) => Cow::Owned(commands.join("\n")),
ResolvedScriptContents::Missing => Cow::Borrowed(""),
}
}
pub fn path(&self) -> Option<&Path> {
match self {
ResolvedScriptContents::Path(path, _) => Some(path),
_ => None,
}
}
pub(crate) fn infer_interpreter(&self) -> Option<String> {
self.path()
.and_then(crate::script::determine_interpreter_from_path)
}
}
impl Script {
#[allow(clippy::too_many_arguments)]
pub async fn run_script<F>(
&self,
env_vars: HashMap<String, Option<String>>,
work_dir: &Path,
recipe_dir: &Path,
run_prefix: &Path,
build_prefix: Option<&PathBuf>,
jinja_renderer: Option<F>,
sandbox_config: Option<&SandboxConfiguration>,
env_isolation: EnvironmentIsolation,
) -> Result<(), crate::InterpreterError>
where
F: Fn(&str) -> Result<String, String>,
{
let env_vars = env_vars
.into_iter()
.filter_map(|(k, v)| v.map(|v| (k, v)))
.chain(self.env().clone())
.collect::<IndexMap<String, String>>();
let contents = self.resolve_content(
recipe_dir,
jinja_renderer,
crate::platform_script_extensions(),
)?;
let runtime = RuntimeEnv::current();
let secrets = self
.secrets()
.iter()
.filter_map(|k| {
let secret = k.to_string();
if let Some(value) = runtime.var(&secret) {
Some((secret, value.to_string()))
} else {
tracing::warn!("Secret {} not found in environment", secret);
None
}
})
.collect::<IndexMap<String, String>>();
let work_dir = if let Some(cwd) = self.cwd.as_ref() {
run_prefix.join(cwd)
} else {
work_dir.to_owned()
};
tracing::debug!("Running script in {}", work_dir.display());
let exec_args = ExecutionArgs {
script: contents,
interpreter: self.interpreter.clone(),
env_vars,
secrets,
build_prefix: build_prefix.map(|p| p.to_owned()),
run_prefix: run_prefix.to_owned(),
runtime,
work_dir,
sandbox_config: sandbox_config.cloned(),
env_isolation,
};
crate::execution::run_script(exec_args).await?;
Ok(())
}
fn find_file(&self, recipe_dir: &Path, extensions: &[&str], path: &Path) -> Option<PathBuf> {
let path = if path.is_absolute() {
path.to_path_buf()
} else {
recipe_dir.join(path)
};
if path.extension().is_none() {
extensions
.iter()
.map(|ext| path.with_extension(ext))
.find(|p| p.is_file())
} else if path.is_file() {
Some(path)
} else {
None
}
}
pub fn resolve_content<F>(
&self,
recipe_dir: &Path,
jinja_renderer: Option<F>,
extensions: &[&str],
) -> Result<ResolvedScriptContents, std::io::Error>
where
F: Fn(&str) -> Result<String, String>,
{
let script_content = match self.contents() {
ScriptContent::Default => {
let recipe_file = self.find_file(recipe_dir, extensions, Path::new("build"));
if let Some(recipe_file) = recipe_file {
match fs::read_to_string(&recipe_file) {
Err(e) => Err(e),
Ok(content) => Ok(ResolvedScriptContents::Path(recipe_file, content)),
}
} else {
Ok(ResolvedScriptContents::Missing)
}
}
ScriptContent::Path(path) => {
let recipe_file = self.find_file(recipe_dir, extensions, path);
if let Some(recipe_file) = recipe_file {
match fs::read_to_string(&recipe_file) {
Err(e) => Err(e),
Ok(content) => Ok(ResolvedScriptContents::Path(recipe_file, content)),
}
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("could not resolve recipe file {:?}", path.display()),
))
}
}
ScriptContent::CommandOrPath(path) => {
if path.contains('\n') {
Ok(ResolvedScriptContents::Inline(path.clone()))
} else {
let resolved_path = self.find_file(recipe_dir, extensions, Path::new(path));
if let Some(resolved_path) = resolved_path {
match fs::read_to_string(&resolved_path) {
Err(e) => Err(e),
Ok(content) => Ok(ResolvedScriptContents::Path(resolved_path, content)),
}
} else {
Ok(ResolvedScriptContents::Inline(path.clone()))
}
}
}
ScriptContent::Commands(commands) => {
Ok(ResolvedScriptContents::Commands(commands.clone()))
}
ScriptContent::Command(command) => {
Ok(ResolvedScriptContents::Inline(command.to_owned()))
}
};
if let Some(renderer) = jinja_renderer {
let render = |script: &str| -> Result<String, std::io::Error> {
renderer(script).map_err(|e| {
std::io::Error::other(format!(
"Failed to render jinja template in build `script`: {}",
e
))
})
};
match script_content? {
ResolvedScriptContents::Inline(script) => {
Ok(ResolvedScriptContents::Inline(render(&script)?))
}
ResolvedScriptContents::Commands(commands) => {
let rendered = commands
.iter()
.map(|c| render(c))
.collect::<Result<Vec<_>, _>>()?;
Ok(ResolvedScriptContents::Commands(rendered))
}
other => Ok(other),
}
} else {
script_content
}
}
}
pub(crate) fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
FramedRead::new(reader, CrLfNormalizer::default())
.into_async_read()
.compat()
}
#[derive(Default)]
pub(crate) struct CrLfNormalizer {
pub(crate) last_was_cr: bool,
}
impl Decoder for CrLfNormalizer {
type Item = BytesMut;
type Error = io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
let mut bytes = src.split_off(0);
let mut read_index = 0;
let mut write_index = 0;
while read_index < bytes.len() {
match bytes[read_index] {
b'\r' => {
bytes[write_index] = b'\n';
write_index += 1;
self.last_was_cr = true;
}
b'\n' if self.last_was_cr => {
self.last_was_cr = false
}
b => {
bytes[write_index] = b;
write_index += 1;
self.last_was_cr = false;
}
}
read_index += 1;
}
if write_index == 0 {
Ok(None)
} else {
bytes.truncate(write_index);
Ok(Some(bytes))
}
}
}
pub(crate) async fn generate_build_script(
args: &ExecutionArgs,
) -> Result<PathBuf, crate::InterpreterError> {
let runner = crate::native_runner::native_runner(args.runtime.platform());
let shell = runner.shell();
let script_extension = shell.extension();
let activation_script_path = args.work_dir.join(format!("build_env.{script_extension}"));
let build_script_path = args
.work_dir
.join(format!("conda_build.{script_extension}"));
let activation_script = crate::activation::activation_script(args, shell.clone())
.map_err(|err| std::io::Error::other(err.to_string()))?;
tokio::fs::write(
&activation_script_path,
crate::native_runner::write_shell_script(shell.clone(), &activation_script)?,
)
.await?;
let explicit_or_inferred = args
.interpreter
.as_deref()
.map(str::to_string)
.or_else(|| args.script.infer_interpreter());
let interpreter_name = explicit_or_inferred
.clone()
.unwrap_or_else(|| runner.default_interpreter().to_string());
let interpreter = crate::interpreter::SelectedInterpreter::from_recipe_name(&interpreter_name)
.ok_or_else(|| crate::InterpreterError::UnsupportedInterpreter(interpreter_name.clone()))?;
let needs_specialized_interpreter = explicit_or_inferred
.as_deref()
.is_some_and(|name| name != runner.default_interpreter());
let script_text = match &args.script {
ResolvedScriptContents::Commands(commands) => interpreter.join_commands(commands),
ResolvedScriptContents::Inline(script) => script.clone(),
ResolvedScriptContents::Path(_, script) => script.clone(),
ResolvedScriptContents::Missing => String::new(),
};
let body = if needs_specialized_interpreter {
let script_path = match &args.script {
ResolvedScriptContents::Path(path, _) => path.clone(),
_ => {
let path = args
.work_dir
.join(format!("conda_build_script.{}", interpreter.extension()));
tokio::fs::write(&path, interpreter.script_contents(&script_text)).await?;
path
}
};
let executable = interpreter.resolve_executable(
args.build_prefix.as_deref(),
&args.run_prefix,
&args.runtime,
)?;
let mut command = vec![executable.to_string_lossy().into_owned()];
command.extend(interpreter.args(&script_path));
let quoted = command
.iter()
.map(|arg| crate::native_runner::quote_arg(&shell, arg))
.collect::<Vec<_>>();
let command_refs = quoted.iter().map(String::as_str).collect::<Vec<_>>();
let mut body = String::new();
shell
.run_command(&mut body, command_refs)
.map_err(std::io::Error::other)?;
body
} else {
script_text
};
let build_script = format!("{}\n{}", runner.preamble(&activation_script_path), body);
tokio::fs::write(
&build_script_path,
crate::native_runner::write_shell_script(shell, &build_script)?,
)
.await?;
#[cfg(unix)]
{
if build_script_path.extension().and_then(|e| e.to_str()) == Some("sh") {
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
let permissions = Permissions::from_mode(0o755);
tokio::fs::set_permissions(&build_script_path, permissions).await?;
}
}
Ok(build_script_path)
}
pub(crate) async fn run_script(exec_args: ExecutionArgs) -> Result<(), crate::InterpreterError> {
let runner = crate::native_runner::native_runner(exec_args.runtime.platform());
let build_script_path = generate_build_script(&exec_args).await?;
let build_script_path_str = build_script_path.to_string_lossy().to_string();
let cmd_args = runner.command_to_run_script(&build_script_path_str);
let output = crate::execution::run_process_with_replacements(
&cmd_args,
&exec_args.work_dir,
&exec_args.replacements(runner.replacements_template()),
&exec_args.env_vars,
&exec_args.secrets,
exec_args.env_isolation,
if runner.supports_sandbox() {
exec_args.sandbox_config.as_ref()
} else {
None
},
&exec_args.runtime,
)
.await?;
if !output.status.success() {
let status_code = output.status.code().unwrap_or(1);
let debug_info = runner.debug_info(
&exec_args.work_dir,
&exec_args.run_prefix,
exec_args.build_prefix.as_deref(),
);
tracing::error!("Script failed with status {}", status_code);
tracing::error!("{}", debug_info);
return Err(crate::InterpreterError::ExecutionFailed(
std::io::Error::other(format!(
"Script failed with status {}{}",
status_code, debug_info
)),
));
}
Ok(())
}
pub async fn create_build_script(exec_args: ExecutionArgs) -> Result<(), std::io::Error> {
let build_script_path = generate_build_script(&exec_args)
.await
.map_err(|err| match err {
crate::InterpreterError::ExecutionFailed(err) => err,
crate::InterpreterError::InterpreterNotFound(interpreter) => std::io::Error::other(
format!("interpreter '{interpreter}' was not found in the build environment"),
),
crate::InterpreterError::InvalidInterpreter {
interpreter,
reason,
} => std::io::Error::other(format!(
"interpreter '{interpreter}' was found but is not valid: {reason}"
)),
crate::InterpreterError::UnsupportedInterpreter(interpreter) => {
let suggestion = crate::interpreter::closest_interpreter(&interpreter)
.map(|s| format!(". Did you mean `{s}`?"))
.unwrap_or_default();
std::io::Error::other(format!(
"unsupported interpreter '{interpreter}'{suggestion}"
))
}
})?;
tracing::info!("Build script created at {}", build_script_path.display());
Ok(())
}
fn find_rattler_sandbox(runtime: &RuntimeEnv) -> Option<PathBuf> {
which::which_in_global("rattler-sandbox", Some(runtime.path()))
.ok()?
.next()
}
const PASSTHROUGH_ENV_VARS: &[&str] = &[
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"SSH_AUTH_SOCK",
"DISPLAY",
"http_proxy",
"https_proxy",
"HTTP_PROXY",
"HTTPS_PROXY",
"no_proxy",
"NO_PROXY",
];
#[cfg(target_os = "windows")]
const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"TEMP",
"TMP",
"PATHEXT",
];
#[cfg(target_os = "macos")]
const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[
"TMPDIR",
"__CF_USER_TEXT_ENCODING",
];
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const PLATFORM_PASSTHROUGH_ENV_VARS: &[&str] = &[];
fn configure_subprocess_env(
command: &mut tokio::process::Command,
env_vars: &IndexMap<String, String>,
secrets: &IndexMap<String, String>,
env_isolation: EnvironmentIsolation,
runtime: &RuntimeEnv,
) {
match env_isolation {
EnvironmentIsolation::Strict | EnvironmentIsolation::CondaBuild => {
command.env_clear();
for var in PASSTHROUGH_ENV_VARS
.iter()
.chain(PLATFORM_PASSTHROUGH_ENV_VARS)
{
if let Some(value) = runtime.var(var) {
command.env(var, value);
}
}
command.envs(env_vars);
command.envs(secrets.iter());
}
EnvironmentIsolation::None => {
command.envs(env_vars);
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_process_with_replacements(
args: &[&str],
cwd: &Path,
replacements: &HashMap<String, String>,
env_vars: &IndexMap<String, String>,
secrets: &IndexMap<String, String>,
env_isolation: EnvironmentIsolation,
sandbox_config: Option<&SandboxConfiguration>,
runtime: &RuntimeEnv,
) -> Result<std::process::Output, std::io::Error> {
let log_file_path = cwd.join("conda_build.log");
let mut log_file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_file_path)
.await?;
let mut command = if let Some(sandbox_config) = sandbox_config {
tracing::info!("{}", sandbox_config);
if let Some(sandbox_exe) = find_rattler_sandbox(runtime) {
let mut cmd = tokio::process::Command::new(sandbox_exe);
let sandbox_args = sandbox_config.with_cwd(cwd).to_args();
cmd.args(&sandbox_args);
cmd.arg(args[0]);
cmd.args(&args[1..]);
cmd
} else {
tracing::error!("rattler-sandbox executable not found in PATH");
tracing::error!("Please install it by running: pixi global install rattler-sandbox");
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"rattler-sandbox executable not found. Please install it with: pixi global install rattler-sandbox",
));
}
} else {
tokio::process::Command::new(args[0])
};
configure_subprocess_env(&mut command, env_vars, secrets, env_isolation, runtime);
command
.current_dir(cwd)
.env("PWD", cwd)
.args(&args[1..])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn()?;
let stdout = child.stdout.take().expect("Failed to take stdout");
let stderr = child.stderr.take().expect("Failed to take stderr");
let stdout_wrapped = normalize_crlf(stdout);
let stderr_wrapped = normalize_crlf(stderr);
let mut stdout_lines = tokio::io::BufReader::new(stdout_wrapped).lines();
let mut stderr_lines = tokio::io::BufReader::new(stderr_wrapped).lines();
let mut stdout_log = String::new();
let mut stderr_log = String::new();
let mut closed = (false, false);
loop {
let (line, is_stderr) = tokio::select! {
line = stdout_lines.next_line() => (line, false),
line = stderr_lines.next_line() => (line, true),
else => break,
};
match line {
Ok(Some(line)) => {
let filtered_line = replacements
.iter()
.fold(line, |acc, (from, to)| acc.replace(from, to));
if is_stderr {
stderr_log.push_str(&filtered_line);
stderr_log.push('\n');
} else {
stdout_log.push_str(&filtered_line);
stdout_log.push('\n');
}
if let Err(e) = log_file.write_all(filtered_line.as_bytes()).await {
tracing::warn!("Failed to write to build log: {:?}", e);
}
if let Err(e) = log_file.write_all(b"\n").await {
tracing::warn!("Failed to write newline to build log: {:?}", e);
}
tracing::info!("{}", filtered_line);
}
Ok(None) if !is_stderr => closed.0 = true,
Ok(None) if is_stderr => closed.1 = true,
Ok(None) => unreachable!(),
Err(e) => {
tracing::warn!("Error reading output: {:?}", e);
break;
}
};
if closed == (true, true) {
break;
}
}
let status = child.wait().await?;
if let Err(e) = log_file.flush().await {
tracing::warn!("Failed to flush build log: {:?}", e);
}
Ok(std::process::Output {
status,
stdout: stdout_log.into_bytes(),
stderr: stderr_log.into_bytes(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rattler_conda_types::Platform;
use tokio_util::bytes::BytesMut;
#[test]
fn test_conda_build_marker_written_into_build_env_script() {
use rattler_shell::shell;
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs_err::create_dir_all(&prefix).unwrap();
let args = ExecutionArgs {
script: ResolvedScriptContents::Inline(String::new()),
interpreter: None,
env_vars: IndexMap::new(),
secrets: IndexMap::new(),
runtime: RuntimeEnv::for_test(Platform::current()),
build_prefix: None,
run_prefix: prefix,
work_dir: tmp.path().to_path_buf(),
sandbox_config: None,
env_isolation: EnvironmentIsolation::None,
};
let script = crate::activation::activation_script(&args, shell::Bash::default()).unwrap();
assert!(
script.contains("CONDA_BUILD") && script.contains("1"),
"build_env.sh must set CONDA_BUILD=1 for nested-shell re-entrancy, got:\n{script}"
);
}
#[test]
fn test_conda_build_not_leaked_to_subprocess_in_none_mode() {
let env_vars = IndexMap::new();
let secrets = IndexMap::new();
let mut command = tokio::process::Command::new("true");
configure_subprocess_env(
&mut command,
&env_vars,
&secrets,
EnvironmentIsolation::None,
&RuntimeEnv::for_test(Platform::current()),
);
assert!(
!command.as_std().get_envs().any(|(k, _)| k == "CONDA_BUILD"),
"CONDA_BUILD must not be set on the outer subprocess"
);
}
#[test]
fn test_commands_resolved_as_list() {
use crate::script::{Script, ScriptContent};
let commands = vec!["echo Hello".to_string(), "echo World".to_string()];
let script = Script {
content: ScriptContent::Commands(commands.clone()),
interpreter: None,
env: IndexMap::new(),
secrets: Vec::new(),
cwd: None,
content_explicit: false,
};
let resolved = script
.resolve_content(
std::path::Path::new("."),
None::<fn(&str) -> Result<String, String>>,
&["bat"],
)
.unwrap();
match resolved {
ResolvedScriptContents::Commands(c) => assert_eq!(c, commands),
other => panic!("expected Commands variant, got {other:?}"),
}
}
#[test]
fn test_command_list_rendered_per_command() {
use crate::script::{Script, ScriptContent};
let script = Script {
content: ScriptContent::Commands(vec![
"echo MARK one".to_string(),
"echo MARK two".to_string(),
]),
..Script::default()
};
let renderer = |s: &str| -> Result<String, String> { Ok(s.replace("MARK", "rendered")) };
let resolved = script
.resolve_content(std::path::Path::new("."), Some(renderer), &["sh"])
.unwrap();
match resolved {
ResolvedScriptContents::Commands(c) => {
assert_eq!(c, vec!["echo rendered one", "echo rendered two"]);
}
other => panic!("expected Commands variant, got {other:?}"),
}
}
#[tokio::test]
async fn test_command_list_errorlevel_in_generated_cmd_wrapper() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let args = ExecutionArgs {
script: ResolvedScriptContents::Commands(vec![
"echo Hello".to_string(),
"echo World".to_string(),
]),
interpreter: None,
env_vars: IndexMap::new(),
secrets: IndexMap::new(),
runtime: RuntimeEnv::for_test(Platform::Win64),
build_prefix: None,
run_prefix: prefix,
work_dir: tmp.path().to_path_buf(),
sandbox_config: None,
env_isolation: EnvironmentIsolation::None,
};
crate::execution::generate_build_script(&args)
.await
.unwrap();
let wrapper = fs::read_to_string(tmp.path().join("conda_build.bat")).unwrap();
assert!(
wrapper.contains("if %errorlevel% neq 0 exit /b %errorlevel%"),
"cmd wrapper must propagate errors between commands, got:\n{wrapper}"
);
}
#[test]
fn test_crlf_normalizer_no_crlf() {
let mut normalizer = CrLfNormalizer::default();
let mut buffer = BytesMut::from("test string with no CR or LF");
let result = normalizer.decode(&mut buffer).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), "test string with no CR or LF");
let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_crlf_normalizer_with_crlf() {
let mut normalizer = CrLfNormalizer::default();
let mut buffer = BytesMut::from("line1\r\nline2\r\nline3");
let result = normalizer.decode(&mut buffer).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), "line1\nline2\nline3");
let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_crlf_normalizer_with_cr_only() {
let mut normalizer = CrLfNormalizer::default();
let mut buffer = BytesMut::from("line1\rline2\rline3");
let result = normalizer.decode(&mut buffer).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), "line1\nline2\nline3");
let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_crlf_normalizer_with_cr_at_end() {
let mut normalizer = CrLfNormalizer::default();
let mut buffer = BytesMut::from("line1\r");
let result = normalizer.decode(&mut buffer).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), "line1\n");
assert!(normalizer.last_was_cr);
let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_crlf_normalizer_with_split_crlf() {
let mut normalizer = CrLfNormalizer::default();
let mut buffer1 = BytesMut::from("line1\r");
let result1 = normalizer.decode(&mut buffer1).unwrap();
assert!(result1.is_some());
assert_eq!(result1.unwrap(), "line1\n");
assert!(normalizer.last_was_cr);
let mut buffer2 = BytesMut::from("\nline2");
let result2 = normalizer.decode(&mut buffer2).unwrap();
assert!(result2.is_some());
assert_eq!(result2.unwrap(), "line2");
let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_crlf_normalizer_with_multiple_cr_at_end() {
let mut normalizer = CrLfNormalizer::default();
let mut buffer = BytesMut::from("line1\r\r\r");
let result = normalizer.decode(&mut buffer).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), "line1\n\n\n");
assert!(normalizer.last_was_cr);
let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_crlf_normalizer_with_empty_buffer() {
let mut normalizer = CrLfNormalizer::default();
let mut buffer = BytesMut::new();
let result = normalizer.decode(&mut buffer).unwrap();
assert!(result.is_none());
let eof_result = normalizer.decode_eof(&mut buffer).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_crlf_normalizer_with_pending_cr_and_empty_buffer() {
let mut normalizer = CrLfNormalizer { last_was_cr: true };
let mut buffer = BytesMut::new();
let result = normalizer.decode(&mut buffer).unwrap();
assert!(result.is_none());
let eof_result = normalizer.decode_eof(&mut buffer).unwrap();
assert!(eof_result.is_none());
}
#[test]
fn test_infer_interpreter_from_resolved_contents() {
use std::path::PathBuf;
let resolved_path =
ResolvedScriptContents::Path(PathBuf::from("build.py"), "print('hello')".to_string());
assert_eq!(
resolved_path.infer_interpreter(),
Some("python".to_string())
);
let resolved_inline = ResolvedScriptContents::Inline("echo 'hello'".to_string());
assert_eq!(resolved_inline.infer_interpreter(), None);
let resolved_missing = ResolvedScriptContents::Missing;
assert_eq!(resolved_missing.infer_interpreter(), None);
}
#[test]
fn test_script_extension_resolution_respects_order() {
use std::path::PathBuf;
use crate::script::{Script, ScriptContent};
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("test-script.sh"), "#!/bin/bash\necho hello").unwrap();
fs::write(dir.path().join("test-script.bat"), "@echo off\necho hello").unwrap();
let resolve = |content: ScriptContent, exts: &[&str]| -> PathBuf {
let script = Script {
content,
..Script::default()
};
match script
.resolve_content(dir.path(), None::<fn(&str) -> Result<String, String>>, exts)
.unwrap()
{
ResolvedScriptContents::Path(path, _) => path,
other => panic!("expected Path variant, got {:?}", other),
}
};
let path_content = || ScriptContent::Path(PathBuf::from("test-script"));
assert_eq!(
resolve(path_content(), &["sh", "bat"]).extension().unwrap(),
"sh"
);
assert_eq!(
resolve(path_content(), &["bat", "sh"]).extension().unwrap(),
"bat"
);
let cop_content = || ScriptContent::CommandOrPath("test-script".into());
assert_eq!(resolve(cop_content(), &["sh"]).extension().unwrap(), "sh");
assert_eq!(resolve(cop_content(), &["bat"]).extension().unwrap(), "bat");
let ext = resolve(path_content(), crate::platform_script_extensions())
.extension()
.unwrap()
.to_owned();
assert_eq!(ext, if cfg!(windows) { "bat" } else { "sh" });
}
use rattler_shell::activation::prefix_path_entries;
fn create_fake_executable(prefix: &Path, name: &str) -> PathBuf {
let exe_name = format!("{}{}", name, std::env::consts::EXE_SUFFIX);
let bin_dir = prefix_path_entries(prefix, &Platform::current())
.into_iter()
.next()
.expect("prefix has executable path entries");
fs::create_dir_all(&bin_dir).unwrap();
let exe = bin_dir.join(exe_name);
fs::write(&exe, "").unwrap();
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
fs::set_permissions(&exe, Permissions::from_mode(0o755)).unwrap();
}
exe
}
fn execution_args(
work_dir: PathBuf,
run_prefix: PathBuf,
script: ResolvedScriptContents,
interpreter: Option<&str>,
) -> ExecutionArgs {
ExecutionArgs {
script,
interpreter: interpreter.map(str::to_string),
env_vars: IndexMap::new(),
secrets: IndexMap::new(),
runtime: RuntimeEnv::current(),
build_prefix: None,
run_prefix,
work_dir,
sandbox_config: None,
env_isolation: EnvironmentIsolation::None,
}
}
#[test]
fn test_strict_env_clear_and_passthrough_whitelist() {
let runtime = RuntimeEnv::for_test(Platform::current())
.with_var("RB_TEST_RANDOM_VAR", "should-not-leak")
.with_var("SSL_CERT_FILE", "/host/cacert.pem");
let mut env_vars = IndexMap::new();
env_vars.insert("EXPLICIT_VAR".to_string(), "explicit".to_string());
let mut secrets = IndexMap::new();
secrets.insert("SECRET_VAR".to_string(), "secret".to_string());
let collect_envs = |isolation: EnvironmentIsolation| {
let mut command = tokio::process::Command::new("true");
configure_subprocess_env(&mut command, &env_vars, &secrets, isolation, &runtime);
command
.as_std()
.get_envs()
.filter_map(|(k, v)| {
v.map(|v| {
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
})
.collect::<HashMap<String, String>>()
};
let strict = collect_envs(EnvironmentIsolation::Strict);
assert!(
!strict.contains_key("RB_TEST_RANDOM_VAR"),
"non-whitelisted host var must be absent in Strict mode"
);
assert_eq!(
strict.get("SSL_CERT_FILE").map(String::as_str),
Some("/host/cacert.pem"),
"whitelisted host var must be passed through"
);
assert_eq!(
strict.get("EXPLICIT_VAR").map(String::as_str),
Some("explicit")
);
assert_eq!(strict.get("SECRET_VAR").map(String::as_str), Some("secret"));
let conda_build = collect_envs(EnvironmentIsolation::CondaBuild);
assert!(
!conda_build.contains_key("RB_TEST_RANDOM_VAR"),
"non-whitelisted host var must be absent in CondaBuild mode"
);
assert_eq!(
conda_build.get("SSL_CERT_FILE").map(String::as_str),
Some("/host/cacert.pem")
);
}
#[tokio::test]
async fn test_powershell_prologue_written_into_script_file() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
create_fake_executable(&prefix, "pwsh");
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline("Write-Output 'hi'".to_string()),
Some("powershell"),
);
generate_build_script(&args).await.unwrap();
let script_file = tmp.path().join("conda_build_script.ps1");
let contents = fs::read_to_string(&script_file).unwrap();
assert!(
contents.contains("$ErrorActionPreference = 'Stop'"),
"missing ErrorActionPreference, got:\n{contents}"
);
assert!(
contents.contains("$PSNativeCommandUseErrorActionPreference"),
"missing PSNativeCommandUseErrorActionPreference, got:\n{contents}"
);
assert!(
contents.contains("Write-Output 'hi'"),
"user body must be appended after the prologue"
);
}
#[tokio::test]
async fn test_create_build_script_missing_interpreter_error() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline("echo hi".to_string()),
Some("brush"),
);
let err = create_build_script(args).await.unwrap_err();
assert!(
err.to_string()
.contains("was not found in the build environment"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn test_create_build_script_unsupported_interpreter_error() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let unsupported_error = |interpreter: &str| {
let args = execution_args(
tmp.path().to_path_buf(),
tmp.path().join("prefix"),
ResolvedScriptContents::Inline("noop".to_string()),
Some(interpreter),
);
async { create_build_script(args).await.unwrap_err().to_string() }
};
let message = unsupported_error("not-a-real-interp").await;
assert!(
message.contains("unsupported interpreter 'not-a-real-interp'"),
"unexpected error: {message}"
);
assert!(
!message.contains("Did you mean"),
"no suggestion expected for an unrelated name: {message}"
);
let message = unsupported_error("brus").await;
assert!(
message.contains("Did you mean `brush`?"),
"unexpected error: {message}"
);
}
#[tokio::test]
async fn test_generate_build_script_interpreter_typo_error() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
fs::create_dir_all(&prefix).unwrap();
let args = execution_args(
tmp.path().to_path_buf(),
prefix,
ResolvedScriptContents::Inline("echo \"Hello from brush!\"".to_string()),
Some("brus"),
);
let err = generate_build_script(&args).await.unwrap_err();
assert!(
matches!(err, crate::InterpreterError::UnsupportedInterpreter(ref name) if name == "brus"),
"expected UnsupportedInterpreter, got {err:?}"
);
}
#[test]
fn test_environment_isolation_round_trip() {
use std::str::FromStr;
for (text, value) in [
("strict", EnvironmentIsolation::Strict),
("conda-build", EnvironmentIsolation::CondaBuild),
("none", EnvironmentIsolation::None),
] {
assert_eq!(EnvironmentIsolation::from_str(text).unwrap(), value);
assert_eq!(value.to_string(), text);
}
let err = EnvironmentIsolation::from_str("bogus").unwrap_err();
assert!(
err.contains("unknown environment isolation mode 'bogus'"),
"unexpected error: {err}"
);
}
}