use crate::error::{CoreError, Result};
use arcbox_boot::asset_manager::{AssetManager, AssetManagerConfig};
use arcbox_boot::download::{PrepareProgress, ProgressCallback as InnerProgressCallback};
use arcbox_constants::env::BOOT_ASSET_VERSION as BOOT_ASSET_VERSION_ENV;
use sha2::Digest;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
pub use arcbox_boot::download::{PreparePhase, PrepareProgress as DownloadProgress};
pub use arcbox_boot::manifest::Manifest as BootAssetManifest;
const LOCK_TOML: &str = include_str!("../../../assets.lock");
#[derive(Debug, serde::Deserialize)]
struct AssetsLock {
boot: BootSection,
}
#[derive(Debug, serde::Deserialize)]
struct BootSection {
version: String,
cdn: Option<String>,
manifest_sha256: Option<String>,
}
static LOCK: LazyLock<AssetsLock> =
LazyLock::new(|| toml::from_str(LOCK_TOML).expect("invalid assets.lock"));
const DEFAULT_CDN_BASE_URL: &str = "https://boot.arcboxcdn.com";
#[must_use]
pub fn boot_asset_version() -> &'static str {
&LOCK.boot.version
}
#[must_use]
pub fn boot_asset_cdn() -> &'static str {
LOCK.boot.cdn.as_deref().unwrap_or(DEFAULT_CDN_BASE_URL)
}
#[derive(Debug, Clone)]
pub struct BootAssetConfig {
pub cdn_base_url: String,
pub version: String,
pub arch: String,
pub cache_dir: PathBuf,
pub custom_kernel: Option<PathBuf>,
}
impl Default for BootAssetConfig {
fn default() -> Self {
let version = std::env::var(BOOT_ASSET_VERSION_ENV)
.unwrap_or_else(|_| boot_asset_version().to_string());
let arch = if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"x86_64"
}
.to_string();
Self {
cdn_base_url: boot_asset_cdn().to_string(),
version,
arch,
cache_dir: dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".arcbox")
.join("boot"),
custom_kernel: None,
}
}
}
impl BootAssetConfig {
#[must_use]
pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
Self {
cache_dir,
..Default::default()
}
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = version.into();
self
}
#[must_use]
pub fn version_cache_dir(&self) -> PathBuf {
self.cache_dir.join(&self.version)
}
}
#[derive(Debug, Clone)]
pub struct BootAssets {
pub kernel: PathBuf,
pub rootfs_image: PathBuf,
pub cmdline: String,
pub version: String,
pub manifest: BootAssetManifest,
}
impl BootAssets {
#[must_use]
pub fn default_cmdline() -> String {
"console=hvc0 root=/dev/vda ro rootfstype=erofs earlycon swiotlb=noforce".to_string()
}
}
pub type ProgressCallback = Box<dyn Fn(PrepareProgress) + Send + Sync>;
pub struct BootAssetProvider {
manager: AssetManager,
config: BootAssetConfig,
}
impl BootAssetProvider {
pub fn new(cache_dir: PathBuf) -> Result<Self> {
let config = BootAssetConfig::with_cache_dir(cache_dir);
Self::with_config(config)
}
pub fn with_config(config: BootAssetConfig) -> Result<Self> {
let inner_config = Self::build_inner_config(&config);
let manager = AssetManager::new(inner_config)
.map_err(|e| CoreError::config(format!("invalid boot asset config: {e}")))?;
Ok(Self { manager, config })
}
pub fn with_kernel(mut self, kernel: PathBuf) -> Result<Self> {
if kernel.as_os_str().is_empty() {
return Ok(self);
}
self.config.custom_kernel = Some(kernel);
self.rebuild_manager()?;
Ok(self)
}
#[must_use]
pub const fn config(&self) -> &BootAssetConfig {
&self.config
}
pub async fn get_assets(&self) -> Result<BootAssets> {
self.get_assets_with_progress(None).await
}
pub async fn get_assets_with_progress(
&self,
progress: Option<ProgressCallback>,
) -> Result<BootAssets> {
let cb: Option<InnerProgressCallback> = progress.map(|p| -> InnerProgressCallback { p });
let prepared = self
.manager
.prepare(cb)
.await
.map_err(|e| CoreError::config(format!("boot asset error: {e}")))?;
if let Some(expected) = LOCK
.boot
.manifest_sha256
.as_deref()
.filter(|s| !s.is_empty())
{
let manifest_path = self.config.version_cache_dir().join("manifest.json");
let bytes = std::fs::read(&manifest_path)
.map_err(|e| CoreError::config(format!("read manifest: {e}")))?;
let actual = format!("{:x}", sha2::Sha256::digest(&bytes));
if actual != expected {
return Err(CoreError::config(format!(
"manifest SHA256 mismatch: expected {expected}, got {actual}"
)));
}
}
Ok(BootAssets {
kernel: prepared.kernel,
rootfs_image: prepared.rootfs,
cmdline: prepared.kernel_cmdline,
version: prepared.version,
manifest: prepared.manifest,
})
}
pub async fn prepare_binaries(
&self,
dest_dir: &Path,
progress: Option<ProgressCallback>,
) -> Result<()> {
let cb: Option<InnerProgressCallback> = progress.map(|p| -> InnerProgressCallback { p });
self.manager
.prepare_binaries(dest_dir, cb)
.await
.map_err(|e| CoreError::config(format!("binary prepare error: {e}")))
}
#[must_use]
pub fn is_cached(&self) -> bool {
let dir = self.config.version_cache_dir();
dir.join("manifest.json").exists()
&& dir.join("kernel").exists()
&& dir.join("rootfs.erofs").exists()
}
pub async fn prefetch_with_progress(&self, progress: Option<ProgressCallback>) -> Result<()> {
let _ = self.get_assets_with_progress(progress).await?;
Ok(())
}
pub async fn clear_cache(&self) -> Result<()> {
let dir = self.config.version_cache_dir();
if dir.exists() {
tokio::fs::remove_dir_all(&dir)
.await
.map_err(|e| CoreError::config(format!("failed to clear cache: {e}")))?;
}
Ok(())
}
pub async fn read_cached_manifest_required(&self) -> Result<BootAssetManifest> {
let path = self.config.version_cache_dir().join("manifest.json");
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| CoreError::config(format!("failed to read manifest: {e}")))?;
serde_json::from_slice(&bytes)
.map_err(|e| CoreError::config(format!("failed to parse manifest: {e}")))
}
pub async fn list_cached_versions(&self) -> Result<Vec<String>> {
let cache_dir = &self.config.cache_dir;
if !cache_dir.exists() {
return Ok(Vec::new());
}
let mut versions = Vec::new();
let mut entries = tokio::fs::read_dir(cache_dir)
.await
.map_err(|e| CoreError::config(format!("failed to read cache dir: {e}")))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| CoreError::config(format!("failed to read cache entry: {e}")))?
{
let path = entry.path();
if path.is_dir() && path.join("manifest.json").exists() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
versions.push(name.to_string());
}
}
}
versions.sort();
Ok(versions)
}
pub async fn fetch_latest_version(&self) -> Result<Option<String>> {
let url = format!("{}/latest.json", self.config.cdn_base_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| CoreError::config(format!("failed to fetch latest version: {e}")))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| CoreError::config(format!("failed to parse latest.json: {e}")))?;
Ok(body
.get("version")
.and_then(serde_json::Value::as_str)
.map(String::from))
}
fn build_inner_config(config: &BootAssetConfig) -> AssetManagerConfig {
AssetManagerConfig {
cdn_base_url: config.cdn_base_url.clone(),
version: config.version.clone(),
arch: config.arch.clone(),
cache_dir: config.cache_dir.clone(),
custom_kernel: config.custom_kernel.clone(),
}
}
fn rebuild_manager(&mut self) -> Result<()> {
let inner_config = Self::build_inner_config(&self.config);
self.manager = AssetManager::new(inner_config)
.map_err(|e| CoreError::config(format!("invalid boot asset config: {e}")))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_default_config() {
let config = BootAssetConfig::default();
assert!(!config.cdn_base_url.is_empty());
assert!(!config.version.is_empty());
assert!(!config.arch.is_empty());
}
#[test]
fn test_default_config_uses_boot_asset_version() {
let _guard = ENV_LOCK.lock().unwrap();
let original = std::env::var(BOOT_ASSET_VERSION_ENV).ok();
unsafe { std::env::remove_var(BOOT_ASSET_VERSION_ENV) };
let config = BootAssetConfig::default();
assert_eq!(config.version, boot_asset_version());
restore_env(original);
}
#[test]
fn test_default_config_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
let original = std::env::var(BOOT_ASSET_VERSION_ENV).ok();
unsafe { std::env::set_var(BOOT_ASSET_VERSION_ENV, "9.9.9") };
let config = BootAssetConfig::default();
assert_eq!(config.version, "9.9.9");
restore_env(original);
}
#[test]
fn test_version_cache_dir() {
let config = BootAssetConfig {
version: "1.0.0".to_string(),
cache_dir: PathBuf::from("/tmp/boot"),
..Default::default()
};
assert_eq!(config.version_cache_dir(), PathBuf::from("/tmp/boot/1.0.0"));
}
#[test]
fn test_is_cached_requires_all_assets() {
let temp = tempfile::tempdir().unwrap();
let cache_dir = temp.path().to_path_buf();
let version = "1.0.0".to_string();
let version_dir = cache_dir.join(&version);
std::fs::create_dir_all(&version_dir).unwrap();
let config = BootAssetConfig {
version,
cache_dir,
..Default::default()
};
let provider = BootAssetProvider::with_config(config).unwrap();
assert!(!provider.is_cached());
std::fs::write(version_dir.join("manifest.json"), b"{}").unwrap();
assert!(!provider.is_cached());
std::fs::write(version_dir.join("kernel"), b"vmlinux").unwrap();
assert!(!provider.is_cached());
std::fs::write(version_dir.join("rootfs.erofs"), b"erofs").unwrap();
assert!(provider.is_cached());
}
fn restore_env(original: Option<String>) {
unsafe {
match original {
Some(value) => std::env::set_var(BOOT_ASSET_VERSION_ENV, value),
None => std::env::remove_var(BOOT_ASSET_VERSION_ENV),
}
}
}
}