use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use arcbox_api::{SetupPhase, SetupState};
use arcbox_core::{BootAssetProvider, Config};
use tracing::info;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct PreparedAssets {
agent: PreparedAgent,
}
impl PreparedAssets {
pub(super) fn agent(self) -> PreparedAgent {
self.agent
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PreparedAgent {
Bundle,
BootCache,
Unavailable,
}
pub(super) async fn prepare(
config: &Config,
setup_state: &Arc<SetupState>,
) -> Result<PreparedAssets> {
let data_dir = &config.data_dir;
let seed_data_dir = data_dir.clone();
let bundle_seed = tokio::task::spawn_blocking(move || seed_from_bundle(&seed_data_dir))
.await
.context("bundle seed task panicked")?
.context("bundle seed failed")?;
ensure_boot_assets(config, setup_state).await?;
let fallback_data_dir = data_dir.clone();
let agent = tokio::task::spawn_blocking(move || {
ensure_agent_binary_fallback(&fallback_data_dir, bundle_seed)
})
.await
.context("agent fallback task panicked")?
.context("agent fallback failed")?;
Ok(PreparedAssets { agent })
}
pub fn find_bundle_contents() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let mut dir = exe.parent()?;
loop {
let contents = dir;
if contents.join("Resources").is_dir() && contents.join("Info.plist").exists() {
if contents.join("MacOS").join("ArcBox").exists() {
return Some(contents.to_path_buf());
}
}
dir = contents.parent()?;
}
}
pub(super) fn seed_from_bundle(data_dir: &Path) -> Result<BundleSeed> {
let Some(contents) = find_bundle_contents() else {
return Ok(BundleSeed::default());
};
tracing::info!("App bundle detected: {}", contents.display());
let mut seed = BundleSeed::default();
let version = arcbox_core::boot_asset_version();
let boot_dst = data_dir.join(format!("boot/{version}"));
let boot_src = contents.join(format!("Resources/assets/{version}"));
if boot_src.join("manifest.json").exists() {
seed_dir_files(
&boot_src,
&boot_dst,
&["manifest.json", "kernel", "rootfs.erofs"],
"boot assets",
);
}
let runtime_src = contents.join("Resources/runtime");
let runtime_dst = data_dir.join("runtime");
if runtime_src.is_dir() {
seed_dir_recursive(&runtime_src, &runtime_dst, "runtime binaries");
}
let agent_src = contents.join("Resources/bin/arcbox-agent");
if agent_src.exists() {
seed.agent = seed_agent_from_bundle(&agent_src, data_dir)
.context("Failed to seed arcbox-agent from bundle")?;
}
let vm_agent_src = contents.join("Resources/bin/vm-agent");
if vm_agent_src.exists() {
seed_dir_files(
&contents.join("Resources/bin"),
&data_dir.join("bin"),
&["vm-agent"],
"sandbox vm-agent",
);
}
Ok(seed)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct BundleSeed {
agent: AgentSeed,
}
impl BundleSeed {
pub(super) fn needs_agent_fallback(self) -> bool {
self.agent == AgentSeed::Missing
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum AgentSeed {
#[default]
Missing,
Bundle,
}
fn seed_agent_from_bundle(agent_src: &Path, data_dir: &Path) -> Result<AgentSeed> {
let agent_dst = data_dir.join("bin/arcbox-agent");
match copy_if_changed(agent_src, &agent_dst) {
Ok(true) => tracing::info!("Seeded arcbox-agent from bundle"),
Ok(false) => tracing::debug!("arcbox-agent already up to date"),
Err(e) => return Err(e).context("failed to copy bundle arcbox-agent"),
}
Ok(AgentSeed::Bundle)
}
fn copy_if_changed(src: &Path, dst: &Path) -> std::io::Result<bool> {
let src_meta = std::fs::metadata(src)?;
let src_mtime = filetime::FileTime::from_last_modification_time(&src_meta);
if let Ok(dst_meta) = std::fs::metadata(dst) {
if dst_meta.len() == src_meta.len()
&& filetime::FileTime::from_last_modification_time(&dst_meta) == src_mtime
{
return Ok(false);
}
}
copy_atomic(src, dst, src_mtime)?;
Ok(true)
}
fn copy_atomic(src: &Path, dst: &Path, src_mtime: filetime::FileTime) -> std::io::Result<()> {
let parent = dst.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let tmp = temp_path_for(dst);
let result = (|| -> std::io::Result<()> {
std::fs::copy(src, &tmp)?;
filetime::set_file_mtime(&tmp, src_mtime)?;
std::fs::rename(&tmp, dst)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
fn temp_path_for(dst: &Path) -> PathBuf {
let parent = dst.parent().unwrap_or_else(|| Path::new("."));
let file_name = dst
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("copy");
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), nonce))
}
fn seed_dir_files(src: &Path, dst: &Path, files: &[&str], label: &str) {
let result = (|| -> std::io::Result<u32> {
let mut count = 0u32;
std::fs::create_dir_all(dst)?;
for name in files {
let s = src.join(name);
if s.exists() && copy_if_changed(&s, &dst.join(name))? {
count += 1;
}
}
Ok(count)
})();
match result {
Ok(0) => tracing::debug!("{label}: already up to date"),
Ok(n) => tracing::info!("Seeded {n} {label} from bundle"),
Err(e) => tracing::warn!("Failed to seed {label} from bundle: {e}"),
}
}
fn seed_dir_recursive(src: &Path, dst: &Path, label: &str) {
let result = (|| -> std::io::Result<u32> {
let mut count = 0u32;
for entry in walkdir(src) {
let (rel, is_dir) = entry?;
let d = dst.join(&rel);
if is_dir {
std::fs::create_dir_all(&d)?;
} else if copy_if_changed(&src.join(&rel), &d)? {
count += 1;
}
}
Ok(count)
})();
match result {
Ok(0) => tracing::debug!("{label}: already up to date"),
Ok(n) => tracing::info!("Seeded {n} {label} from bundle"),
Err(e) => tracing::warn!("Failed to seed {label} from bundle: {e}"),
}
}
fn walkdir(root: &Path) -> impl Iterator<Item = std::io::Result<(PathBuf, bool)>> {
let mut stack = vec![PathBuf::new()];
let root = root.to_path_buf();
std::iter::from_fn(move || {
while let Some(rel) = stack.pop() {
let abs = root.join(&rel);
let Ok(meta) = std::fs::metadata(&abs) else {
continue;
};
if meta.is_dir() {
match std::fs::read_dir(&abs) {
Ok(entries) => {
for entry in entries.flatten() {
if let Ok(name) = entry.file_name().into_string() {
stack.push(rel.join(name));
}
}
}
Err(e) => return Some(Err(e)),
}
if !rel.as_os_str().is_empty() {
return Some(Ok((rel, true)));
}
} else {
return Some(Ok((rel, false)));
}
}
None
})
}
pub(super) async fn ensure_boot_assets(
config: &Config,
setup_state: &Arc<SetupState>,
) -> Result<()> {
let provider = BootAssetProvider::with_config(config.boot_asset_config())
.context("Failed to create boot asset provider")?;
if provider.is_cached() {
tracing::debug!("Boot assets already cached");
return Ok(());
}
setup_state.set_phase(SetupPhase::DownloadingAssets, "Downloading boot assets...");
provider
.get_assets_with_progress(Some(Box::new(|p| {
tracing::info!(
name = %p.name,
current = p.current,
total = p.total,
"Boot asset progress: {:?}",
p.phase
);
})))
.await
.context("Failed to download boot assets")?;
info!("Boot assets downloaded");
Ok(())
}
pub(super) fn ensure_agent_binary_fallback(
data_dir: &Path,
bundle_seed: BundleSeed,
) -> Result<PreparedAgent> {
if !bundle_seed.needs_agent_fallback() {
return Ok(PreparedAgent::Bundle);
}
if ensure_agent_binary(data_dir)? {
Ok(PreparedAgent::BootCache)
} else {
Ok(PreparedAgent::Unavailable)
}
}
pub(super) fn ensure_agent_binary(data_dir: &Path) -> Result<bool> {
let agent_dest = data_dir.join("bin/arcbox-agent");
let version = arcbox_core::boot_asset_version();
let agent_src = data_dir.join(format!("boot/{version}/arcbox-agent"));
if !agent_src.exists() {
tracing::debug!(
"Agent binary not found in boot cache at {}",
agent_src.display()
);
return Ok(false);
}
if copy_if_changed(&agent_src, &agent_dest).context("Failed to install agent binary")? {
info!("Agent binary installed from boot cache");
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::{
AgentSeed, BundleSeed, PreparedAgent, copy_if_changed, ensure_agent_binary_fallback,
seed_agent_from_bundle,
};
use std::fs;
#[test]
fn copies_when_missing_then_skips_when_identical() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let dst = dir.path().join("nested/dst");
fs::write(&src, b"v1").unwrap();
assert!(copy_if_changed(&src, &dst).unwrap());
assert_eq!(fs::read(&dst).unwrap(), b"v1");
assert!(!copy_if_changed(&src, &dst).unwrap());
}
#[test]
fn recopies_when_source_content_changes() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let dst = dir.path().join("dst");
fs::write(&src, b"v1").unwrap();
assert!(copy_if_changed(&src, &dst).unwrap());
fs::write(&src, b"v2-larger").unwrap();
let newer = filetime::FileTime::from_unix_time(
filetime::FileTime::from_last_modification_time(&fs::metadata(&src).unwrap())
.unix_seconds()
+ 5,
0,
);
filetime::set_file_mtime(&src, newer).unwrap();
assert!(copy_if_changed(&src, &dst).unwrap());
assert_eq!(fs::read(&dst).unwrap(), b"v2-larger");
}
#[cfg(unix)]
#[test]
fn preserves_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("bin");
let dst = dir.path().join("bin-copy");
fs::write(&src, b"#!/bin/sh\n").unwrap();
fs::set_permissions(&src, fs::Permissions::from_mode(0o755)).unwrap();
copy_if_changed(&src, &dst).unwrap();
let mode = fs::metadata(&dst).unwrap().permissions().mode();
assert_ne!(mode & 0o111, 0, "executable bit should be preserved");
}
#[test]
fn boot_cache_installs_agent_when_bundle_agent_is_missing() {
let dir = tempfile::tempdir().unwrap();
let cached_agent = dir.path().join(format!(
"boot/{}/arcbox-agent",
arcbox_core::boot_asset_version()
));
fs::create_dir_all(cached_agent.parent().unwrap()).unwrap();
fs::write(&cached_agent, b"boot-cache-agent").unwrap();
let agent = ensure_agent_binary_fallback(dir.path(), BundleSeed::default()).unwrap();
assert_eq!(agent, PreparedAgent::BootCache);
let staged_agent = dir.path().join("bin/arcbox-agent");
assert_eq!(fs::read(staged_agent).unwrap(), b"boot-cache-agent");
}
#[test]
fn boot_cache_does_not_overwrite_bundle_agent() {
let dir = tempfile::tempdir().unwrap();
let bundle_agent = dir.path().join("bundle-agent");
fs::write(&bundle_agent, b"bundle-agent").unwrap();
let agent = seed_agent_from_bundle(&bundle_agent, dir.path()).unwrap();
assert_eq!(agent, AgentSeed::Bundle);
let cached_agent = dir.path().join(format!(
"boot/{}/arcbox-agent",
arcbox_core::boot_asset_version()
));
fs::create_dir_all(cached_agent.parent().unwrap()).unwrap();
fs::write(&cached_agent, b"older-boot-cache-agent").unwrap();
let agent = ensure_agent_binary_fallback(
dir.path(),
BundleSeed {
agent: AgentSeed::Bundle,
},
)
.unwrap();
assert_eq!(agent, PreparedAgent::Bundle);
let staged_agent = dir.path().join("bin/arcbox-agent");
assert_eq!(fs::read(staged_agent).unwrap(), b"bundle-agent");
}
}