use std::collections::HashMap;
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use super::validate_name;
use crate::error::{CoreError, Result};
pub(super) const META_FILE: &str = "meta.json";
pub(super) const DISK_FILE: &str = "disk.img";
pub(super) const AUX_FILE: &str = "aux.img";
pub(super) const HARDWARE_MODEL_FILE: &str = "hwmodel.bin";
pub(super) const MACHINE_ID_FILE: &str = "machine-id.bin";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MacImageMeta {
pub name: String,
pub source: Option<String>,
#[serde(default)]
pub stream: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub os_version: Option<String>,
#[serde(default)]
pub os_build: Option<String>,
#[serde(default)]
pub runner_version: Option<String>,
pub minimum_cpu_count: u64,
pub minimum_memory_mib: u64,
pub disk_gb: u64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct MacImage {
pub meta: MacImageMeta,
pub dir: PathBuf,
}
impl MacImage {
#[must_use]
pub fn disk_path(&self) -> PathBuf {
self.dir.join(DISK_FILE)
}
#[must_use]
pub fn aux_path(&self) -> PathBuf {
self.dir.join(AUX_FILE)
}
#[must_use]
pub fn hardware_model_path(&self) -> PathBuf {
self.dir.join(HARDWARE_MODEL_FILE)
}
#[must_use]
pub fn machine_id_path(&self) -> PathBuf {
self.dir.join(MACHINE_ID_FILE)
}
pub fn clone_into(&self, dst_dir: &Path) -> Result<MacInstanceDisks> {
std::fs::create_dir_all(dst_dir)?;
let dst_disk = dst_dir.join(DISK_FILE);
cow_clone(&self.disk_path(), &dst_disk)?;
let dst_aux = dst_dir.join(AUX_FILE);
let _ = std::fs::remove_file(&dst_aux);
std::fs::copy(self.aux_path(), &dst_aux)?;
let hardware_model = std::fs::read(self.hardware_model_path())?;
let machine_id = std::fs::read(self.machine_id_path())?;
Ok(MacInstanceDisks {
disk: dst_disk,
aux: dst_aux,
hardware_model,
machine_id,
})
}
}
#[derive(Debug, Clone)]
pub struct MacInstanceDisks {
pub disk: PathBuf,
pub aux: PathBuf,
pub hardware_model: Vec<u8>,
pub machine_id: Vec<u8>,
}
pub struct MacImageManager {
images_dir: PathBuf,
pull_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
}
impl MacImageManager {
#[must_use]
pub fn new(data_dir: &Path) -> Self {
Self {
images_dir: data_dir.join("macos").join("images"),
pull_locks: Mutex::new(HashMap::new()),
}
}
pub(super) fn image_dir(&self, name: &str) -> Result<PathBuf> {
validate_name(name)?;
Ok(self.images_dir.join(name))
}
#[must_use]
pub fn cache_dir(&self) -> PathBuf {
self.images_dir
.parent()
.map_or_else(|| self.images_dir.join("cache"), |p| p.join("cache"))
}
pub(super) async fn pull_lock(&self, name: &str) -> Arc<Mutex<()>> {
Arc::clone(
self.pull_locks
.lock()
.await
.entry(name.to_string())
.or_default(),
)
}
pub fn get(&self, name: &str) -> Result<MacImage> {
let dir = self.image_dir(name)?;
let meta_path = dir.join(META_FILE);
if !meta_path.exists() {
return Err(CoreError::not_found(format!("macOS image '{name}'")));
}
let raw = std::fs::read_to_string(&meta_path)?;
let meta: MacImageMeta = serde_json::from_str(&raw)
.map_err(|e| CoreError::macos(format!("parse {}: {e}", meta_path.display())))?;
Ok(MacImage { meta, dir })
}
#[must_use]
pub fn list(&self) -> Vec<MacImage> {
let Ok(entries) = std::fs::read_dir(&self.images_dir) else {
return Vec::new();
};
let mut images = Vec::new();
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(String::from) else {
continue;
};
match self.get(&name) {
Ok(image) => images.push(image),
Err(e) => tracing::warn!("skipping macOS image '{name}': {e}"),
}
}
images
}
pub fn remove(&self, name: &str) -> Result<()> {
let dir = self.image_dir(name)?;
if !dir.exists() {
return Err(CoreError::not_found(format!("macOS image '{name}'")));
}
std::fs::remove_dir_all(&dir)?;
Ok(())
}
pub fn write_meta(&self, meta: &MacImageMeta) -> Result<()> {
Self::write_meta_in(&self.image_dir(&meta.name)?, meta)
}
pub(super) fn write_meta_in(dir: &Path, meta: &MacImageMeta) -> Result<()> {
std::fs::create_dir_all(dir)?;
let json = serde_json::to_string_pretty(meta)
.map_err(|e| CoreError::macos(format!("serialize image metadata: {e}")))?;
std::fs::write(dir.join(META_FILE), json)?;
Ok(())
}
}
fn cow_clone(src: &Path, dst: &Path) -> Result<()> {
let _ = std::fs::remove_file(dst); let src_c = CString::new(src.as_os_str().as_bytes())
.map_err(|e| CoreError::macos(format!("path {}: {e}", src.display())))?;
let dst_c = CString::new(dst.as_os_str().as_bytes())
.map_err(|e| CoreError::macos(format!("path {}: {e}", dst.display())))?;
let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
if rc == 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::ENOTSUP | libc::EXDEV) => {
tracing::warn!(
"clonefile unsupported for {} ({err}); falling back to a full copy",
dst.display()
);
std::fs::copy(src, dst)?;
Ok(())
}
_ => Err(CoreError::macos(format!(
"clonefile {} -> {}: {err}",
src.display(),
dst.display()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample_meta() -> MacImageMeta {
MacImageMeta {
name: "base".into(),
source: Some("/tmp/UniversalMac.ipsw".into()),
stream: None,
version: None,
os_version: None,
os_build: None,
runner_version: None,
minimum_cpu_count: 2,
minimum_memory_mib: 4096,
disk_gb: 64,
created_at: Utc::now(),
}
}
#[test]
fn meta_round_trips_through_registry() {
let dir = tempdir().unwrap();
let mgr = MacImageManager::new(dir.path());
let meta = sample_meta();
mgr.write_meta(&meta).unwrap();
let loaded = mgr.get("base").unwrap();
assert_eq!(loaded.meta, meta);
assert_eq!(
loaded.disk_path(),
mgr.image_dir("base").unwrap().join("disk.img")
);
assert_eq!(mgr.list().len(), 1);
}
#[test]
fn get_missing_image_is_not_found() {
let dir = tempdir().unwrap();
let mgr = MacImageManager::new(dir.path());
assert!(mgr.get("nope").is_err());
assert!(mgr.list().is_empty());
}
#[test]
fn traversal_names_are_rejected_before_touching_the_fs() {
let dir = tempdir().unwrap();
let victim = dir.path().join("victim");
std::fs::create_dir_all(&victim).unwrap();
let mgr = MacImageManager::new(dir.path());
assert!(mgr.get("../victim").is_err());
assert!(mgr.remove("../victim").is_err());
assert!(mgr.image_dir("a/b").is_err());
assert!(victim.exists());
}
}