use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetPolicy {
Deny,
AllowLoud,
}
#[derive(Debug, Clone)]
pub struct ContainerBuildRequest {
pub tool: String,
pub platform: crate::ToolPlatform,
pub plan: crate::recipe::InstallPlan,
pub src_dir: PathBuf,
pub prefix: PathBuf,
pub scratch_dir: PathBuf,
pub dep_toolchains: Vec<PathBuf>,
pub resources_dir: Option<PathBuf>,
pub env: HashMap<String, String>,
pub path_prefix: Vec<String>,
pub net: NetPolicy,
}
#[derive(Debug, Clone)]
pub struct ContainerBuildReport {
pub log_tail: String,
}
pub trait ContainerBuildExecutor: Send + Sync + 'static {
fn execute<'a>(
&'a self,
req: &'a ContainerBuildRequest,
) -> Pin<Box<dyn Future<Output = crate::Result<ContainerBuildReport>> + Send + 'a>>;
}
static EXECUTOR: OnceLock<RwLock<Option<Arc<dyn ContainerBuildExecutor>>>> = OnceLock::new();
fn executor_slot() -> &'static RwLock<Option<Arc<dyn ContainerBuildExecutor>>> {
EXECUTOR.get_or_init(|| RwLock::new(None))
}
pub fn set_container_executor(exec: Arc<dyn ContainerBuildExecutor>) {
let mut slot = executor_slot()
.write()
.unwrap_or_else(PoisonError::into_inner);
*slot = Some(exec);
}
#[must_use]
pub fn container_executor() -> Option<Arc<dyn ContainerBuildExecutor>> {
executor_slot()
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recipe::InstallPlan;
use crate::ToolPlatform;
struct MockExecutor {
tag: &'static str,
}
impl ContainerBuildExecutor for MockExecutor {
fn execute<'a>(
&'a self,
req: &'a ContainerBuildRequest,
) -> Pin<Box<dyn Future<Output = crate::Result<ContainerBuildReport>> + Send + 'a>>
{
Box::pin(async move {
Ok(ContainerBuildReport {
log_tail: format!("{}:{}", self.tag, req.tool),
})
})
}
}
fn request(tool: &str) -> ContainerBuildRequest {
ContainerBuildRequest {
tool: tool.to_string(),
platform: ToolPlatform::MacOS,
plan: InstallPlan {
steps: vec![],
resources: vec![],
patches: vec![],
env: HashMap::new(),
deparallelize: false,
},
src_dir: PathBuf::from("/tmp/src"),
prefix: PathBuf::from("/tmp/prefix"),
scratch_dir: PathBuf::from("/tmp/prefix/.build"),
dep_toolchains: vec![],
resources_dir: None,
env: HashMap::new(),
path_prefix: vec![],
net: NetPolicy::Deny,
}
}
#[test]
fn no_host_brew_or_choco_subprocess_in_crate_sources() {
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let spawn = "Command::new(";
let q = '"';
let needles = [
format!("{spawn}{q}brew{q}"),
format!("{spawn}{q}brew.sh{q}"),
format!("{spawn}{q}choco{q}"),
format!("{spawn}{q}choco.exe{q}"),
format!("{spawn}brew_bin"),
format!("{spawn}&brew_bin"),
];
let mut stack = vec![src_root.clone()];
let mut scanned = 0usize;
let mut offenders: Vec<String> = Vec::new();
while let Some(dir) = stack.pop() {
let entries = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("read_dir({}) failed: {e}", dir.display()));
for entry in entries {
let path = entry.expect("directory entry").path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {} failed: {e}", path.display()));
scanned += 1;
for needle in &needles {
if text.contains(needle.as_str()) {
offenders.push(format!("{}: contains `{needle}`", path.display()));
}
}
}
}
}
assert!(
scanned >= 10,
"expected to scan this crate's sources but found only {scanned} .rs files under {}",
src_root.display()
);
assert!(
offenders.is_empty(),
"host brew/choco subprocess pattern(s) found — containerized builds must go \
through the ContainerBuildExecutor, never a host package manager:\n{}",
offenders.join("\n")
);
}
#[tokio::test]
async fn global_slot_none_then_registered_then_replaced() {
assert!(
container_executor().is_none(),
"no executor must be registered before set_container_executor"
);
set_container_executor(Arc::new(MockExecutor { tag: "first" }));
let exec = container_executor().expect("executor should be registered");
let report = exec.execute(&request("git")).await.unwrap();
assert_eq!(report.log_tail, "first:git");
set_container_executor(Arc::new(MockExecutor { tag: "second" }));
let exec = container_executor().expect("executor should still be registered");
let report = exec.execute(&request("jq")).await.unwrap();
assert_eq!(
report.log_tail, "second:jq",
"the most recently registered executor must serve requests"
);
}
}