use std::{
fs,
io::{Read, Seek, SeekFrom},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use anyhow::{Context, bail, ensure};
use ostool::{build::config::Cargo, run::qemu::QemuConfig};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
use super::build as case_builder;
use crate::context::AppContext;
const CASE_WORK_ROOT_NAME: &str = "qemu-cases";
const CASE_CACHE_DIR_NAME: &str = "cache";
const CASE_RUNS_DIR_NAME: &str = "runs";
const CASE_ROOTFS_CACHE_DIR_NAME: &str = "rootfs";
const CASE_STAGING_DIR_NAME: &str = "staging-root";
const CASE_BUILD_DIR_NAME: &str = "build";
const CASE_OVERLAY_DIR_NAME: &str = "overlay";
const CASE_COMMAND_WRAPPER_DIR_NAME: &str = "guest-bin";
const CASE_CROSS_BIN_DIR_NAME: &str = "cross-bin";
const CASE_CMAKE_TOOLCHAIN_FILE_NAME: &str = "cmake-toolchain.cmake";
const CASE_APK_CACHE_DIR_NAME: &str = "apk-cache";
const CASE_SH_DIR_NAME: &str = "sh";
const CASE_ROOTFS_COPY_NAME: &str = "case-rootfs.img";
const PYTHON_PIPELINE_CACHE_VERSION: &str = "python-apk-v1";
const RUST_PIPELINE_CACHE_VERSION: &str = "rust-cross-v1";
const QEMU_SNAPSHOT_ARG: &str = "-snapshot";
static CASE_RUN_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TestQemuCase {
pub(crate) name: String,
pub(crate) display_name: String,
pub(crate) case_dir: PathBuf,
pub(crate) qemu_config_path: PathBuf,
pub(crate) test_commands: Vec<String>,
pub(crate) subcases: Vec<TestQemuSubcase>,
}
impl TestQemuCase {
pub(crate) fn is_grouped(&self) -> bool {
!self.test_commands.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TestQemuSubcaseKind {
C,
Rust,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TestQemuSubcase {
pub(crate) name: String,
pub(crate) case_dir: PathBuf,
pub(crate) kind: TestQemuSubcaseKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GroupedCaseRunnerConfig {
pub(crate) runner_name: String,
pub(crate) runner_path: String,
pub(crate) begin_marker: String,
pub(crate) passed_marker: String,
pub(crate) failed_marker: String,
pub(crate) all_passed_marker: String,
pub(crate) all_failed_marker: String,
pub(crate) success_regex: String,
pub(crate) fail_regex: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CaseScriptEnvConfig {
pub(crate) staging_root: String,
pub(crate) case_dir: String,
pub(crate) case_c_dir: String,
pub(crate) case_work_dir: String,
pub(crate) case_build_dir: String,
pub(crate) case_overlay_dir: String,
}
pub(crate) type GuestPackageEnvPrepareFn = fn(&Path) -> anyhow::Result<Vec<(String, String)>>;
#[derive(Debug, Clone)]
pub(crate) struct CaseAssetConfig {
pub(crate) grouped_runner: GroupedCaseRunnerConfig,
pub(crate) script_env: CaseScriptEnvConfig,
pub(crate) cache_env_vars: Vec<String>,
pub(crate) prepare_staging_root: fn(&Path) -> anyhow::Result<()>,
pub(crate) prepare_guest_package_env: Option<GuestPackageEnvPrepareFn>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PreparedCaseAssets {
pub(crate) rootfs_path: PathBuf,
pub(crate) extra_qemu_args: Vec<String>,
pub(crate) rootfs_copy_to_remove: Option<PathBuf>,
pub(crate) run_dir_to_remove: Option<PathBuf>,
pub(crate) pipeline: CasePipeline,
pub(crate) cache_hit: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PreparedCaseAssetParts {
pub(crate) extra_qemu_args: Vec<String>,
pub(crate) rootfs_path: PathBuf,
pub(crate) rootfs_copy_to_remove: Option<PathBuf>,
pub(crate) run_dir_to_remove: Option<PathBuf>,
pub(crate) pipeline: CasePipeline,
pub(crate) cache_hit: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CasePipeline {
Plain,
Grouped,
C,
Sh,
Python,
Rust,
}
impl CasePipeline {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Plain => "plain",
Self::Grouped => "grouped",
Self::C => "c",
Self::Sh => "sh",
Self::Python => "python",
Self::Rust => "rust",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CaseAssetLayout {
pub(crate) work_dir: PathBuf,
pub(crate) run_dir: PathBuf,
pub(crate) cache_dir: PathBuf,
pub(crate) rootfs_cache_dir: PathBuf,
pub(crate) staging_root: PathBuf,
pub(crate) build_dir: PathBuf,
pub(crate) overlay_dir: PathBuf,
pub(crate) command_wrapper_dir: PathBuf,
pub(crate) cross_bin_dir: PathBuf,
pub(crate) cmake_toolchain_file: PathBuf,
pub(crate) apk_cache_dir: PathBuf,
pub(crate) case_rootfs_copy: PathBuf,
}
pub(crate) fn resolve_target_dir(workspace_root: &Path, target: &str) -> anyhow::Result<PathBuf> {
Ok(workspace_root.join("target").join(target))
}
pub(crate) async fn prepare_case_assets(
workspace_root: &Path,
arch: &str,
target: &str,
case: &TestQemuCase,
rootfs_path: PathBuf,
config: CaseAssetConfig,
) -> anyhow::Result<PreparedCaseAssets> {
let workspace_root = workspace_root.to_path_buf();
let arch = arch.to_string();
let target = target.to_string();
let case = case.clone();
let config = config.clone();
let parts = tokio::task::spawn_blocking(move || {
prepare_case_assets_sync(
&workspace_root,
&arch,
&target,
&case,
&rootfs_path,
&config,
)
})
.await
.context("qemu test case asset task failed")??;
Ok(PreparedCaseAssets {
rootfs_path: parts.rootfs_path,
extra_qemu_args: parts.extra_qemu_args,
rootfs_copy_to_remove: parts.rootfs_copy_to_remove,
run_dir_to_remove: parts.run_dir_to_remove,
pipeline: parts.pipeline,
cache_hit: parts.cache_hit,
})
}
pub(crate) fn case_sh_source_dir(case: &TestQemuCase) -> PathBuf {
case.case_dir.join(CASE_SH_DIR_NAME)
}
pub(crate) fn case_python_source_dir(case: &TestQemuCase) -> PathBuf {
case_builder::case_python_source_dir(case)
}
pub(crate) fn case_asset_layout(
workspace_root: &Path,
target: &str,
case_name: &str,
) -> anyhow::Result<CaseAssetLayout> {
let target_dir = resolve_target_dir(workspace_root, target)?;
let work_dir = target_dir.join(CASE_WORK_ROOT_NAME).join(case_name);
let run_dir = work_dir.join(CASE_RUNS_DIR_NAME).join(next_case_run_id());
let cache_dir = work_dir.join(CASE_CACHE_DIR_NAME);
Ok(CaseAssetLayout {
staging_root: run_dir.join(CASE_STAGING_DIR_NAME),
build_dir: run_dir.join(CASE_BUILD_DIR_NAME),
overlay_dir: run_dir.join(CASE_OVERLAY_DIR_NAME),
command_wrapper_dir: run_dir.join(CASE_COMMAND_WRAPPER_DIR_NAME),
cross_bin_dir: run_dir.join(CASE_CROSS_BIN_DIR_NAME),
cmake_toolchain_file: run_dir.join(CASE_CMAKE_TOOLCHAIN_FILE_NAME),
apk_cache_dir: cache_dir.join(CASE_APK_CACHE_DIR_NAME),
rootfs_cache_dir: cache_dir.join(CASE_ROOTFS_CACHE_DIR_NAME),
case_rootfs_copy: run_dir.join(CASE_ROOTFS_COPY_NAME),
cache_dir,
run_dir,
work_dir,
})
}
pub(crate) fn copy_shared_rootfs_for_case(
shared_rootfs: &Path,
layout: &CaseAssetLayout,
) -> anyhow::Result<()> {
copy_file_fast(shared_rootfs, &layout.case_rootfs_copy)?;
Ok(())
}
pub(crate) fn remove_case_rootfs_copy(path: Option<&Path>) {
let Some(path) = path else { return };
if path.exists()
&& let Err(e) = fs::remove_file(path)
{
eprintln!(
"warning: failed to remove case rootfs copy `{}`: {e}",
path.display()
);
}
}
pub(crate) fn remove_case_run_dir(path: Option<&Path>) {
let Some(path) = path else { return };
if path.exists()
&& let Err(e) = fs::remove_dir_all(path)
{
eprintln!(
"warning: failed to remove case run directory `{}`: {e}",
path.display()
);
}
}
pub(crate) fn prepare_case_assets_sync(
workspace_root: &Path,
arch: &str,
target: &str,
case: &TestQemuCase,
shared_rootfs: &Path,
config: &CaseAssetConfig,
) -> anyhow::Result<PreparedCaseAssetParts> {
let pipeline = resolve_case_pipeline(case)?;
let needs_injection = pipeline != CasePipeline::Plain;
let layout = if needs_injection {
Some(case_asset_layout(
workspace_root,
target,
&case.display_name,
)?)
} else {
None
};
let (case_rootfs, rootfs_copy_to_remove, run_dir_to_remove, cache_hit) = if needs_injection {
let layout = layout.as_ref().expect("layout created above for injection");
let rootfs_cache_img =
rootfs_cache_image_path(layout, arch, target, pipeline, case, shared_rootfs, config)?;
fs::create_dir_all(&layout.run_dir)
.with_context(|| format!("failed to create {}", layout.run_dir.display()))?;
let cache_hit = if is_valid_rootfs_cache_image(&rootfs_cache_img) {
copy_file_fast(&rootfs_cache_img, &layout.case_rootfs_copy)?;
true
} else {
copy_shared_rootfs_for_case(shared_rootfs, layout)?;
let copy = &layout.case_rootfs_copy;
match pipeline {
CasePipeline::Grouped => case_builder::prepare_grouped_case_assets_sync(
arch, case, copy, layout, config,
)?,
CasePipeline::C => {
case_builder::prepare_c_case_assets_sync(arch, case, copy, layout, config)?
}
CasePipeline::Sh => prepare_sh_case_assets_sync(case, copy, layout)?,
CasePipeline::Python => {
case_builder::prepare_python_case_assets_sync(arch, case, copy, layout, config)?
}
CasePipeline::Rust => {
case_builder::prepare_rust_case_assets_sync(arch, case, copy, layout, config)?
}
CasePipeline::Plain => unreachable!("plain cases do not prepare injection assets"),
}
save_rootfs_cache_image(&layout.case_rootfs_copy, &rootfs_cache_img)?;
false
};
let copy = layout.case_rootfs_copy.clone();
(
copy.clone(),
Some(copy),
Some(layout.run_dir.clone()),
cache_hit,
)
} else {
(shared_rootfs.to_path_buf(), None, None, false)
};
let extra_qemu_args = vec![QEMU_SNAPSHOT_ARG.to_string()];
Ok(PreparedCaseAssetParts {
extra_qemu_args,
rootfs_path: case_rootfs,
rootfs_copy_to_remove,
run_dir_to_remove,
pipeline,
cache_hit,
})
}
pub(crate) fn resolve_case_pipeline(case: &TestQemuCase) -> anyhow::Result<CasePipeline> {
let mut pipelines = Vec::new();
if case.is_grouped() {
pipelines.push(CasePipeline::Grouped);
}
if case_builder::case_c_source_dir(case).is_dir() {
pipelines.push(CasePipeline::C);
}
if case_sh_source_dir(case).is_dir() {
pipelines.push(CasePipeline::Sh);
}
if case_python_source_dir(case).is_dir() {
pipelines.push(CasePipeline::Python);
}
if case_builder::case_rust_source_dir(case).is_dir() {
pipelines.push(CasePipeline::Rust);
}
if pipelines.len() > 1 {
bail!(
"qemu case `{}` defines multiple asset pipelines: {}",
case.name,
pipelines
.iter()
.map(|pipeline| pipeline.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(pipelines.into_iter().next().unwrap_or(CasePipeline::Plain))
}
fn next_case_run_id() -> String {
let sequence = CASE_RUN_ID.fetch_add(1, Ordering::Relaxed);
format!("{}-{sequence}", std::process::id())
}
fn rootfs_cache_image_path(
layout: &CaseAssetLayout,
arch: &str,
target: &str,
pipeline: CasePipeline,
case: &TestQemuCase,
shared_rootfs: &Path,
config: &CaseAssetConfig,
) -> anyhow::Result<PathBuf> {
let key = case_asset_cache_key(arch, target, pipeline, case, shared_rootfs, config)?;
Ok(layout.rootfs_cache_dir.join(format!("{key}.img")))
}
fn rootfs_cache_write_enabled() -> bool {
if std::env::var_os("AXBUILD_DISABLE_ROOTFS_CACHE").is_some() {
return false;
}
std::env::var_os("CI").is_none()
}
fn is_no_space_left(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.and_then(|e| e.raw_os_error())
== Some(28)
})
}
fn save_rootfs_cache_image(src: &Path, dst: &Path) -> anyhow::Result<()> {
if !rootfs_cache_write_enabled() {
return Ok(());
}
let parent = dst
.parent()
.ok_or_else(|| anyhow::anyhow!("rootfs cache path has no parent: {}", dst.display()))?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create rootfs cache dir {}", parent.display()))?;
let temp = parent.join(format!(
".{}.{}.tmp",
dst.file_name()
.and_then(|name| name.to_str())
.unwrap_or("rootfs-cache"),
next_case_run_id()
));
if let Err(err) = copy_file_fast(src, &temp) {
let _ = fs::remove_file(&temp);
if is_no_space_left(&err) {
return Ok(());
}
return Err(err);
}
match fs::rename(&temp, dst) {
Ok(()) => Ok(()),
Err(err) if dst.is_file() => {
let _ = fs::remove_file(&temp);
if is_valid_rootfs_cache_image(dst) {
Ok(())
} else {
Err(err).with_context(|| {
format!("failed to install rootfs cache image {}", dst.display())
})
}
}
Err(err) => {
let _ = fs::remove_file(&temp);
Err(err)
.with_context(|| format!("failed to install rootfs cache image {}", dst.display()))
}
}
}
fn is_valid_rootfs_cache_image(path: &Path) -> bool {
const MIN_SIZE: u64 = 1024 * 1024;
path.is_file()
&& path
.metadata()
.map(|m| m.len() >= MIN_SIZE)
.unwrap_or(false)
}
fn copy_file_fast(src: &Path, dst: &Path) -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
{
let status = std::process::Command::new("cp")
.arg("--reflink=auto")
.arg(src)
.arg(dst)
.status();
if let Ok(status) = status {
if status.success() {
return Ok(());
}
let _ = fs::remove_file(dst);
}
}
fs::copy(src, dst)
.with_context(|| format!("failed to copy {} to {}", src.display(), dst.display()))?;
Ok(())
}
fn case_asset_cache_key(
arch: &str,
target: &str,
pipeline: CasePipeline,
case: &TestQemuCase,
shared_rootfs: &Path,
config: &CaseAssetConfig,
) -> anyhow::Result<String> {
let mut hasher = Sha256::new();
hash_token(&mut hasher, "v3");
hash_token(&mut hasher, arch);
hash_token(&mut hasher, target);
hash_token(&mut hasher, case.display_name.as_str());
hash_token(&mut hasher, pipeline.as_str());
for var in &config.cache_env_vars {
hash_token(&mut hasher, var);
hash_token(&mut hasher, std::env::var(var).unwrap_or_default().as_str());
}
if pipeline == CasePipeline::C {
hash_token(&mut hasher, include_str!("cmake-toolchain.cmake.in"));
}
if pipeline == CasePipeline::Python {
hash_token(&mut hasher, PYTHON_PIPELINE_CACHE_VERSION);
}
if pipeline == CasePipeline::Rust {
hash_token(&mut hasher, RUST_PIPELINE_CACHE_VERSION);
}
hash_rootfs_fingerprint(&mut hasher, shared_rootfs)?;
hash_tree(&mut hasher, &case.case_dir)?;
if !case.qemu_config_path.starts_with(&case.case_dir) && case.qemu_config_path.is_file() {
hash_file(&mut hasher, &case.qemu_config_path)?;
}
Ok(format!("{:x}", hasher.finalize()))
}
fn hash_tree(hasher: &mut Sha256, root: &Path) -> anyhow::Result<()> {
let mut files = WalkDir::new(root)
.follow_links(false)
.into_iter()
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("failed to walk {}", root.display()))?;
files.sort_by_key(|entry| entry.path().to_path_buf());
for entry in files {
let path = entry.path();
if path == root || !entry.file_type().is_file() {
continue;
}
let rel = path.strip_prefix(root).unwrap_or(path);
hash_token(hasher, rel.to_string_lossy().as_ref());
hash_file(hasher, path)?;
}
Ok(())
}
fn hash_rootfs_fingerprint(hasher: &mut Sha256, path: &Path) -> anyhow::Result<()> {
let metadata =
fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
let len = metadata.len();
hash_token(hasher, &len.to_string());
let mut file =
fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
hash_file_window(hasher, &mut file, 0, len)?;
if len > 0 {
hash_file_window(hasher, &mut file, len / 2, len)?;
hash_file_window(hasher, &mut file, len.saturating_sub(1024 * 1024), len)?;
}
Ok(())
}
fn hash_file_window(
hasher: &mut Sha256,
file: &mut fs::File,
offset: u64,
file_len: u64,
) -> anyhow::Result<()> {
let read_len = (file_len.saturating_sub(offset)).min(1024 * 1024);
hash_token(hasher, &format!("{offset}:{read_len}"));
file.seek(SeekFrom::Start(offset))
.with_context(|| format!("failed to seek rootfs fingerprint window at offset {offset}"))?;
let mut remaining = read_len;
let mut buf = [0_u8; 8192];
while remaining > 0 {
let limit = remaining.min(buf.len() as u64) as usize;
let read = file
.read(&mut buf[..limit])
.context("failed to read rootfs fingerprint window")?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
remaining -= read as u64;
}
Ok(())
}
fn hash_file(hasher: &mut Sha256, path: &Path) -> anyhow::Result<()> {
let mut file =
fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
let mut buf = [0_u8; 8192];
loop {
let read = file
.read(&mut buf)
.with_context(|| format!("failed to read {}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(())
}
fn hash_token(hasher: &mut Sha256, value: &str) {
hasher.update(value.len().to_le_bytes());
hasher.update(value.as_bytes());
}
pub(crate) fn apply_grouped_qemu_config(
qemu: &mut QemuConfig,
case: &TestQemuCase,
config: &GroupedCaseRunnerConfig,
) {
if !case.is_grouped() {
return;
}
qemu.shell_init_cmd = Some(config.runner_path.clone());
qemu.success_regex = vec![config.success_regex.clone()];
if !qemu
.fail_regex
.iter()
.any(|regex| regex == &config.fail_regex)
{
qemu.fail_regex.push(config.fail_regex.clone());
}
}
pub(crate) async fn run_qemu_with_prepared_case_assets(
app: &mut AppContext,
cargo: &Cargo,
qemu: QemuConfig,
qemu_config_path: &Path,
prepared_assets: PreparedCaseAssets,
prepare_elapsed: Duration,
) -> anyhow::Result<()> {
println!(
" prepare assets: {:.2?} (pipeline={}, cache={})",
prepare_elapsed,
prepared_assets.pipeline.as_str(),
if prepared_assets.cache_hit {
"hit"
} else {
"miss"
}
);
println!(
" qemu config: {} (timeout={})",
qemu_config_path.display(),
super::qemu::qemu_timeout_summary(&qemu)
);
println!(" rootfs: {}", prepared_assets.rootfs_path.display());
let qemu_started = std::time::Instant::now();
let result = app.run_qemu(cargo, qemu).await;
println!(" qemu run: {:.2?}", qemu_started.elapsed());
remove_case_rootfs_copy(prepared_assets.rootfs_copy_to_remove.as_deref());
remove_case_run_dir(prepared_assets.run_dir_to_remove.as_deref());
result
}
pub(crate) fn write_grouped_case_runner_script(
overlay_dir: &Path,
test_commands: &[String],
config: &GroupedCaseRunnerConfig,
) -> anyhow::Result<()> {
ensure!(
!test_commands.is_empty(),
"grouped qemu case has no test commands"
);
let dest_dir = overlay_dir.join("usr/bin");
fs::create_dir_all(&dest_dir)
.with_context(|| format!("failed to create {}", dest_dir.display()))?;
let runner_path = dest_dir.join(&config.runner_name);
let mut body = String::new();
body.push_str("failed=0\n");
for command in test_commands {
let quoted = shell_single_quote(command);
let begin = shell_single_quote(&format!("{}: {command}", config.begin_marker));
let passed = shell_single_quote(&format!("{}: {command}", config.passed_marker));
let failed = shell_single_quote(&format!("{}: {command}", config.failed_marker));
body.push_str(&format!(
"printf '%s\\n' {begin}\nif sh -c {quoted}; then\n\tprintf '%s\\n' \
{passed}\nelse\n\tstatus=$?\n\tprintf '%s status=%s\\n' {failed} \
\"$status\"\n\tfailed=1\nfi\n"
));
}
let all_passed = shell_single_quote(&config.all_passed_marker);
let all_failed = shell_single_quote(&config.all_failed_marker);
body.push_str(&format!(
"if [ \"$failed\" -eq 0 ]; then\n\tprintf '%s\\n' {all_passed}\n\texit 0\nfi\nprintf \
'%s\\n' {all_failed}\nexit 1\n"
));
write_executable_script(&runner_path, &body)
}
pub(crate) fn prepare_sh_case_assets_sync(
case: &TestQemuCase,
case_rootfs: &Path,
layout: &CaseAssetLayout,
) -> anyhow::Result<()> {
let sh_dir = case_sh_source_dir(case);
ensure!(
sh_dir.is_dir(),
"sh directory not found at `{}`",
sh_dir.display()
);
reset_dir(&layout.overlay_dir)?;
let dest_dir = layout.overlay_dir.join("usr/bin");
fs::create_dir_all(&dest_dir)
.with_context(|| format!("failed to create {}", dest_dir.display()))?;
let mut entries = fs::read_dir(&sh_dir)
.with_context(|| format!("failed to read {}", sh_dir.display()))?
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("failed to read {}", sh_dir.display()))?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let dest = dest_dir.join(entry.file_name());
fs::copy(&path, &dest)
.with_context(|| format!("failed to copy {} to {}", path.display(), dest.display()))?;
make_executable(&dest)?;
}
crate::rootfs::inject::inject_overlay(case_rootfs, &layout.overlay_dir)
}
pub(crate) fn reset_dir(path: &Path) -> anyhow::Result<()> {
if path.exists() {
fs::remove_dir_all(path).with_context(|| format!("failed to remove {}", path.display()))?;
}
fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))
}
fn write_executable_script(path: &Path, body: &str) -> anyhow::Result<()> {
fs::write(path, format!("#!/bin/sh\nset -u\n{body}"))
.with_context(|| format!("failed to write {}", path.display()))?;
make_executable(path)
}
fn make_executable(_path: &Path) -> anyhow::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(_path)
.with_context(|| format!("failed to stat {}", _path.display()))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(_path, perms)
.with_context(|| format!("failed to chmod {}", _path.display()))?;
}
Ok(())
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
#[cfg(test)]
mod tests {
use std::{
env,
ffi::{OsStr, OsString},
fs,
sync::{LazyLock, Mutex},
};
use tempfile::tempdir;
use super::*;
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct TempEnvVar {
key: &'static str,
original: Option<OsString>,
}
impl TempEnvVar {
fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
let original = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, original }
}
fn unset(key: &'static str) -> Self {
let original = env::var_os(key);
unsafe {
env::remove_var(key);
}
Self { key, original }
}
}
impl Drop for TempEnvVar {
fn drop(&mut self) {
match self.original.as_ref() {
Some(value) => unsafe {
env::set_var(self.key, value);
},
None => unsafe {
env::remove_var(self.key);
},
}
}
}
fn fake_config() -> CaseAssetConfig {
CaseAssetConfig {
grouped_runner: GroupedCaseRunnerConfig {
runner_name: "suite-run-case-tests".to_string(),
runner_path: "/usr/bin/suite-run-case-tests".to_string(),
begin_marker: "SUITE_GROUPED_TEST_BEGIN".to_string(),
passed_marker: "SUITE_GROUPED_TEST_PASSED".to_string(),
failed_marker: "SUITE_GROUPED_TEST_FAILED".to_string(),
all_passed_marker: "SUITE_GROUPED_TESTS_PASSED".to_string(),
all_failed_marker: "SUITE_GROUPED_TESTS_FAILED".to_string(),
success_regex: r"(?m)^SUITE_GROUPED_TESTS_PASSED\s*$".to_string(),
fail_regex: r"(?m)^SUITE_GROUPED_TEST_FAILED:".to_string(),
},
script_env: CaseScriptEnvConfig {
staging_root: "SUITE_STAGING_ROOT".to_string(),
case_dir: "SUITE_CASE_DIR".to_string(),
case_c_dir: "SUITE_CASE_C_DIR".to_string(),
case_work_dir: "SUITE_CASE_WORK_DIR".to_string(),
case_build_dir: "SUITE_CASE_BUILD_DIR".to_string(),
case_overlay_dir: "SUITE_CASE_OVERLAY_DIR".to_string(),
},
cache_env_vars: Vec::new(),
prepare_staging_root: |_| Ok(()),
prepare_guest_package_env: None,
}
}
fn fake_case(root: &Path, name: &str) -> TestQemuCase {
let case_dir = root.join("test-suite/example/default").join(name);
fs::create_dir_all(&case_dir).unwrap();
TestQemuCase {
name: name.to_string(),
display_name: name.to_string(),
case_dir: case_dir.clone(),
qemu_config_path: case_dir.join("qemu-aarch64.toml"),
test_commands: Vec::new(),
subcases: Vec::new(),
}
}
#[test]
fn resolve_target_dir_uses_workspace_target_directory() {
let root = tempdir().unwrap();
let dir = resolve_target_dir(root.path(), "x86_64-unknown-none").unwrap();
assert_eq!(dir, root.path().join("target/x86_64-unknown-none"));
}
#[tokio::test]
async fn prepare_case_assets_plain_case_uses_shared_rootfs_with_snapshot() {
let root = tempdir().unwrap();
let target_dir = root.path().join("target/x86_64-unknown-none");
let rootfs_dir = root.path().join("tmp/axbuild/rootfs");
fs::create_dir_all(&target_dir).unwrap();
fs::create_dir_all(&rootfs_dir).unwrap();
let shared_img = rootfs_dir.join("rootfs-x86_64-alpine.img");
fs::write(&shared_img, b"rootfs").unwrap();
let case = fake_case(root.path(), "smoke");
let assets = prepare_case_assets(
root.path(),
"x86_64",
"x86_64-unknown-none",
&case,
shared_img.clone(),
fake_config(),
)
.await
.unwrap();
assert_eq!(assets.rootfs_path, shared_img);
assert!(assets.rootfs_copy_to_remove.is_none());
assert!(assets.extra_qemu_args.contains(&"-snapshot".to_string()));
assert_eq!(fs::read(&shared_img).unwrap(), b"rootfs");
}
#[test]
fn grouped_runner_script_runs_all_commands_and_reports_summary() {
let root = tempdir().unwrap();
let overlay = root.path().join("overlay");
let commands = vec![
"/usr/bin/alpha".to_string(),
"/usr/bin/beta --flag".to_string(),
];
let config = fake_config();
write_grouped_case_runner_script(&overlay, &commands, &config.grouped_runner).unwrap();
let runner = overlay.join("usr/bin/suite-run-case-tests");
let content = fs::read_to_string(&runner).unwrap();
assert!(content.contains("SUITE_GROUPED_TEST_BEGIN: /usr/bin/alpha"));
assert!(content.contains("SUITE_GROUPED_TEST_FAILED: /usr/bin/beta --flag"));
assert!(content.contains("SUITE_GROUPED_TESTS_PASSED"));
}
#[test]
fn save_rootfs_cache_image_is_noop_in_ci() {
let _lock = ENV_LOCK.lock().unwrap();
let _ci = TempEnvVar::set("CI", "1");
let _disable = TempEnvVar::unset("AXBUILD_DISABLE_ROOTFS_CACHE");
let root = tempdir().unwrap();
let src = root.path().join("src.img");
let dst = root.path().join("cache/rootfs.img");
fs::write(&src, vec![0_u8; 1024 * 1024]).unwrap();
save_rootfs_cache_image(&src, &dst).unwrap();
assert!(!dst.exists());
}
#[test]
fn save_rootfs_cache_image_writes_when_enabled() {
let _lock = ENV_LOCK.lock().unwrap();
let _ci = TempEnvVar::unset("CI");
let _disable = TempEnvVar::unset("AXBUILD_DISABLE_ROOTFS_CACHE");
let root = tempdir().unwrap();
let src = root.path().join("src.img");
let dst = root.path().join("cache/rootfs.img");
fs::write(&src, vec![1_u8; 1024 * 1024]).unwrap();
save_rootfs_cache_image(&src, &dst).unwrap();
assert!(dst.is_file());
assert_eq!(fs::read(&dst).unwrap().len(), 1024 * 1024);
}
}