use std::collections::HashMap;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
#[cfg(test)]
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Mutex as AsyncMutex;
use tracing::{debug, info, warn};
use crate::error::{Result, VmmError};
mod persistence;
use persistence::{
SetupOrphan, clear_owner_marker, loop_backs_path, loop_devices_for_backing_sync,
remove_file_durable,
};
const BUSYBOX: &str = "/bin/busybox";
const DMSETUP_CANDIDATES: &[&str] = &["/arcbox/bin/dmsetup", "/usr/sbin/dmsetup", "/sbin/dmsetup"];
const SNAPSHOT_CHUNK_SECTORS: u64 = 8;
const DM_NAME_PREFIX: &str = "arcbox-snap-";
const DM_NAME_MAX_LEN: usize = 127;
const TEMPLATE_LOOP_DIR: &str = ".template-loops";
const TEMPLATE_PENDING_PREFIX: &str = "pending-";
const TEMPLATE_MARKER_TEMP_PREFIX: &str = ".tmp-";
fn validate_dm_name_suffix(sandbox_id: &str) -> Result<()> {
if sandbox_id.is_empty() {
return Err(VmmError::DeviceMapper("empty sandbox id".into()));
}
if DM_NAME_PREFIX.len() + sandbox_id.len() > DM_NAME_MAX_LEN {
return Err(VmmError::DeviceMapper(format!(
"sandbox id too long for dm-name (max {} chars after prefix)",
DM_NAME_MAX_LEN - DM_NAME_PREFIX.len()
)));
}
if let Some(bad) = sandbox_id
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '.' | '-')))
{
return Err(VmmError::DeviceMapper(format!(
"sandbox id contains character {bad:?} not allowed in dm-name"
)));
}
Ok(())
}
struct TemplateEntry {
loop_device: String,
sectors: u64,
refcount: usize,
}
#[derive(Debug)]
pub struct CowHandle {
pub dm_name: String,
pub dm_device: String,
pub cow_loop: String,
pub cow_file: PathBuf,
pub template_path: PathBuf,
}
#[cfg(test)]
#[derive(Default)]
pub(crate) struct CowTestProbe {
setups: AtomicUsize,
teardowns: Mutex<Vec<String>>,
}
#[cfg(test)]
impl CowTestProbe {
fn setup(&self, sandbox_id: &str, rootfs_path: &str, cow_dir: &Path) -> CowHandle {
self.setups.fetch_add(1, Ordering::SeqCst);
let dm_name = format!("{DM_NAME_PREFIX}{sandbox_id}");
CowHandle {
dm_device: format!("/dev/mapper/{dm_name}"),
cow_loop: format!("/dev/loop-test-{sandbox_id}"),
cow_file: cow_dir.join(format!("arcbox-cow-{sandbox_id}.img")),
template_path: PathBuf::from(rootfs_path),
dm_name,
}
}
fn teardown(&self, handle: &CowHandle) {
self.teardowns.lock().unwrap().push(handle.dm_name.clone());
}
pub(crate) fn setup_count(&self) -> usize {
self.setups.load(Ordering::SeqCst)
}
pub(crate) fn teardown_count(&self) -> usize {
self.teardowns.lock().unwrap().len()
}
}
pub struct CowManager {
templates: Mutex<HashMap<PathBuf, TemplateEntry>>,
setup_orphans: Mutex<HashMap<String, SetupOrphan>>,
losetup_lock: AsyncMutex<()>,
cow_dir: PathBuf,
dmsetup_bin: Option<String>,
#[cfg(test)]
test_probe: Option<Arc<CowTestProbe>>,
}
impl CowManager {
pub fn new(data_dir: &str) -> Result<Self> {
let data_dir = PathBuf::from(data_dir);
let cow_dir = data_dir.join("cow");
let marker_dir = cow_dir.join(TEMPLATE_LOOP_DIR);
std::fs::create_dir_all(&marker_dir)?;
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(&cow_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(&marker_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::File::open(&marker_dir)?.sync_all()?;
std::fs::File::open(&cow_dir)?.sync_all()?;
std::fs::File::open(&data_dir)?.sync_all()?;
let dmsetup_bin = DMSETUP_CANDIDATES
.iter()
.find(|p| Path::new(p).exists())
.map(|s| (*s).to_string());
if dmsetup_bin.is_none() {
warn!("dmsetup not found; dm-snapshot CoW will be unavailable");
}
Ok(Self {
templates: Mutex::new(HashMap::new()),
setup_orphans: Mutex::new(HashMap::new()),
losetup_lock: AsyncMutex::new(()),
cow_dir,
dmsetup_bin,
#[cfg(test)]
test_probe: None,
})
}
#[cfg(test)]
pub(crate) fn new_with_test_probe(data_dir: &str, probe: Arc<CowTestProbe>) -> Result<Self> {
let mut manager = Self::new(data_dir)?;
manager.test_probe = Some(probe);
Ok(manager)
}
pub async fn setup(&self, sandbox_id: &str, rootfs_path: &str) -> Result<CowHandle> {
validate_dm_name_suffix(sandbox_id)?;
#[cfg(test)]
if let Some(probe) = &self.test_probe {
return Ok(probe.setup(sandbox_id, rootfs_path, &self.cow_dir));
}
if self.setup_orphans.lock().unwrap().contains_key(sandbox_id) {
return Err(VmmError::Unavailable(format!(
"sandbox {sandbox_id} still owns resources from an incomplete CoW setup"
)));
}
let dmsetup = self
.dmsetup_bin
.as_deref()
.ok_or_else(|| VmmError::DeviceMapper("dmsetup binary not found".into()))?;
let template = PathBuf::from(rootfs_path);
let (template_loop, sectors) = 'acquire: {
if let Some(cached) = {
let mut templates = self.templates.lock().unwrap();
let entry = templates.get_mut(&template);
if entry.as_ref().is_some_and(|entry| entry.refcount == 0) {
return Err(VmmError::Unavailable(format!(
"template {} still owns resources from an incomplete CoW setup",
template.display()
)));
}
entry.map(|entry| {
entry.refcount += 1;
debug!(
template = %rootfs_path,
loop_dev = %entry.loop_device,
refcount = entry.refcount,
"reusing template loop device"
);
(entry.loop_device.clone(), entry.sectors)
})
} {
break 'acquire cached;
}
let _losetup_guard = self.losetup_lock.lock().await;
if let Some(cached) = {
let mut templates = self.templates.lock().unwrap();
let entry = templates.get_mut(&template);
if entry.as_ref().is_some_and(|entry| entry.refcount == 0) {
return Err(VmmError::Unavailable(format!(
"template {} still owns resources from an incomplete CoW setup",
template.display()
)));
}
entry.map(|entry| {
entry.refcount += 1;
debug!(
template = %rootfs_path,
loop_dev = %entry.loop_device,
refcount = entry.refcount,
"reusing template loop device (after lock)"
);
(entry.loop_device.clone(), entry.sectors)
})
} {
break 'acquire cached;
}
let pending = self.write_template_pending(sandbox_id, &template)?;
let loop_dev = match losetup_attach(BUSYBOX, Path::new(rootfs_path), true).await {
Ok(loop_dev) => loop_dev,
Err(error) => {
return Err(self
.abort_template_acquisition(sandbox_id, &pending, None, &template, error)
.await);
}
};
if let Err(error) = self.write_template_marker(&loop_dev, &template) {
return Err(self
.abort_template_acquisition(
sandbox_id,
&pending,
Some(&loop_dev),
&template,
error,
)
.await);
}
let sectors = match blockdev_getsz(BUSYBOX, &loop_dev).await {
Ok(sectors) => sectors,
Err(error) => {
return Err(self
.abort_template_acquisition(
sandbox_id,
&pending,
Some(&loop_dev),
&template,
error,
)
.await);
}
};
if let Err(error) = clear_owner_marker(&pending) {
return Err(self
.abort_template_acquisition(
sandbox_id,
&pending,
Some(&loop_dev),
&template,
error,
)
.await);
}
debug!(
template = %rootfs_path,
loop_dev = %loop_dev,
sectors,
"attached new template loop device"
);
{
let mut templates = self.templates.lock().unwrap();
templates.insert(
template.clone(),
TemplateEntry {
loop_device: loop_dev.clone(),
sectors,
refcount: 1,
},
);
}
(loop_dev, sectors)
};
let cow_file = self.cow_dir.join(format!("arcbox-cow-{sandbox_id}.img"));
let cow_size = sectors * 512;
if let Err((e, owns_file)) = create_sparse_file(&cow_file, cow_size).await {
return Err(self
.rollback_setup(
sandbox_id,
&template,
None,
None,
owns_file.then_some(cow_file.as_path()),
e,
)
.await);
}
let cow_loop_result = {
let losetup_guard = self.losetup_lock.lock().await;
let result = losetup_attach(BUSYBOX, &cow_file, false).await;
drop(losetup_guard);
result
};
let cow_loop = match cow_loop_result {
Ok(dev) => dev,
Err(e) => {
return Err(self
.rollback_setup(sandbox_id, &template, None, None, Some(&cow_file), e)
.await);
}
};
let dm_name = format!("{DM_NAME_PREFIX}{sandbox_id}");
let table =
format!("0 {sectors} snapshot {template_loop} {cow_loop} P {SNAPSHOT_CHUNK_SECTORS}");
if let Err(e) = dmsetup_create(dmsetup, &dm_name, &table).await {
return Err(self
.rollback_setup(
sandbox_id,
&template,
Some(&dm_name),
Some(&cow_loop),
Some(&cow_file),
e,
)
.await);
}
let dm_device = format!("/dev/mapper/{dm_name}");
info!(
sandbox_id,
dm_device = %dm_device,
cow_file = %cow_file.display(),
"dm-snapshot created"
);
Ok(CowHandle {
dm_name,
dm_device,
cow_loop,
cow_file,
template_path: template,
})
}
pub async fn teardown(&self, handle: &CowHandle) {
if let Err(error) = self.teardown_checked(handle).await {
warn!(dm = %handle.dm_name, error = %error, "dm-snapshot teardown incomplete");
}
}
pub async fn teardown_checked(&self, handle: &CowHandle) -> Result<()> {
#[cfg(test)]
if let Some(probe) = &self.test_probe {
probe.teardown(handle);
return Ok(());
}
let dmsetup = self
.dmsetup_bin
.as_deref()
.ok_or_else(|| VmmError::DeviceMapper("dmsetup binary not found".into()))?;
let mut failures = Vec::new();
let dm_removed = if !Path::new(&handle.dm_device).exists() {
true
} else {
match dmsetup_remove(dmsetup, &handle.dm_name).await {
Ok(()) => true,
Err(error) => {
failures.push(format!("remove {}: {error}", handle.dm_name));
false
}
}
};
let loop_detached = if loop_backs_path(&handle.cow_loop, &handle.cow_file)? {
match losetup_detach(BUSYBOX, &handle.cow_loop).await {
Ok(()) => true,
Err(error) => {
failures.push(format!("detach {}: {error}", handle.cow_loop));
false
}
}
} else {
match loop_devices_for_backing_sync(&handle.cow_file) {
Ok(devices) if devices.is_empty() => true,
Ok(devices) => {
failures.push(format!(
"{} is still attached through {} after {} changed ownership",
handle.cow_file.display(),
devices.join(", "),
handle.cow_loop
));
false
}
Err(error) => {
failures.push(format!(
"verify loop ownership for {}: {error}",
handle.cow_file.display()
));
false
}
}
};
if dm_removed && loop_detached {
if let Err(error) = remove_file_durable(&handle.cow_file) {
failures.push(format!("remove {}: {error}", handle.cow_file.display()));
}
}
if failures.is_empty() {
self.release_template_ref(&handle.template_path, true)
.await?;
info!(sandbox = %handle.dm_name, "dm-snapshot teardown complete");
Ok(())
} else {
Err(VmmError::DeviceMapper(failures.join("; ")))
}
}
}
async fn run_cmd(mut cmd: Command) -> Result<std::process::Output> {
tokio::task::spawn_blocking(move || cmd.output())
.await
.map_err(|e| VmmError::DeviceMapper(format!("spawn_blocking join: {e}")))?
.map_err(|e| VmmError::DeviceMapper(format!("command spawn: {e}")))
}
async fn losetup_attach(bin: &str, path: &Path, read_only: bool) -> Result<String> {
let path_str = path
.to_str()
.ok_or_else(|| VmmError::DeviceMapper("non-UTF-8 path".into()))?;
let mut cmd = Command::new(bin);
if read_only {
cmd.args(["losetup", "-r", "-f", "--show", path_str]);
} else {
cmd.args(["losetup", "-f", "--show", path_str]);
}
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmmError::DeviceMapper(format!(
"losetup attach {}: {stderr}",
path.display()
)));
}
let dev = String::from_utf8_lossy(&output.stdout).trim().to_string();
if dev.is_empty() {
return Err(VmmError::DeviceMapper(
"losetup --show returned empty device path".into(),
));
}
Ok(dev)
}
async fn losetup_detach(bin: &str, dev: &str) -> Result<()> {
let mut cmd = Command::new(bin);
cmd.args(["losetup", "-d", dev]);
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmmError::DeviceMapper(format!(
"losetup -d {dev}: {stderr}"
)));
}
Ok(())
}
async fn blockdev_getsz(bin: &str, dev: &str) -> Result<u64> {
let mut cmd = Command::new(bin);
cmd.args(["blockdev", "--getsz", dev]);
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmmError::DeviceMapper(format!(
"blockdev --getsz {dev}: {stderr}"
)));
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u64>()
.map_err(|e| VmmError::DeviceMapper(format!("blockdev parse: {e}")))
}
async fn dmsetup_create(bin: &str, name: &str, table: &str) -> Result<()> {
let mut cmd = Command::new(bin);
cmd.args(["create", name, "--table", table]);
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmmError::DeviceMapper(format!(
"dmsetup create {name}: {stderr}"
)));
}
Ok(())
}
async fn dmsetup_remove(bin: &str, name: &str) -> Result<()> {
let mut cmd = Command::new(bin);
cmd.args(["remove", name]);
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmmError::DeviceMapper(format!(
"dmsetup remove {name}: {stderr}"
)));
}
Ok(())
}
async fn create_sparse_file(path: &Path, size: u64) -> std::result::Result<(), (VmmError, bool)> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || -> std::result::Result<(), (VmmError, bool)> {
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(|e| {
(
VmmError::DeviceMapper(format!("create cow file: {e}")),
false,
)
})?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| {
(
VmmError::DeviceMapper(format!("secure cow file: {e}")),
true,
)
})?;
file.set_len(size).map_err(|e| {
(
VmmError::DeviceMapper(format!("truncate cow file: {e}")),
true,
)
})?;
file.sync_all()
.map_err(|e| (VmmError::DeviceMapper(format!("sync cow file: {e}")), true))?;
let parent = path.parent().ok_or_else(|| {
(
VmmError::DeviceMapper("cow file has no parent".into()),
true,
)
})?;
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|e| {
(
VmmError::DeviceMapper(format!("sync cow directory: {e}")),
true,
)
})?;
Ok(())
})
.await
.map_err(|e| {
(
VmmError::DeviceMapper(format!("spawn_blocking join: {e}")),
false,
)
})?
}
pub async fn device_major_minor(path: &str) -> Result<(u32, u32)> {
let mut cmd = Command::new(BUSYBOX);
cmd.args(["stat", "-c", "%t %T", path]);
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmmError::DeviceMapper(format!("stat {path}: {stderr}")));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.split_whitespace().collect();
if parts.len() != 2 {
return Err(VmmError::DeviceMapper(format!(
"unexpected stat output for {path}: {stdout}"
)));
}
let major = u32::from_str_radix(parts[0], 16)
.map_err(|e| VmmError::DeviceMapper(format!("parse major: {e}")))?;
let minor = u32::from_str_radix(parts[1], 16)
.map_err(|e| VmmError::DeviceMapper(format!("parse minor: {e}")))?;
Ok((major, minor))
}
pub async fn mknod_blkdev(node_path: &Path, major: u32, minor: u32) -> Result<()> {
let path_str = node_path
.to_str()
.ok_or_else(|| VmmError::DeviceMapper("non-UTF-8 node path".into()))?;
let mut cmd = Command::new(BUSYBOX);
cmd.args([
"mknod",
path_str,
"b",
&major.to_string(),
&minor.to_string(),
]);
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VmmError::DeviceMapper(format!(
"mknod {path_str}: {stderr}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dm_name_format() {
let name = format!("{DM_NAME_PREFIX}test-sandbox-123");
assert_eq!(name, "arcbox-snap-test-sandbox-123");
}
#[test]
fn validate_dm_name_suffix_accepts_uuid_and_basic_ids() {
validate_dm_name_suffix("550e8400-e29b-41d4-a716-446655440000").unwrap();
validate_dm_name_suffix("sandbox_1").unwrap();
validate_dm_name_suffix("a.b+c-d").unwrap();
}
#[test]
fn validate_dm_name_suffix_rejects_invalid_chars() {
assert!(validate_dm_name_suffix("").is_err());
assert!(validate_dm_name_suffix("has space").is_err());
assert!(validate_dm_name_suffix("with/slash").is_err());
assert!(validate_dm_name_suffix("with:colon").is_err());
assert!(validate_dm_name_suffix(&"x".repeat(DM_NAME_MAX_LEN)).is_err());
}
#[test]
fn test_cow_file_path() {
let cow_dir = PathBuf::from("/var/lib/firecracker-vmm/cow");
let path = cow_dir.join(format!("arcbox-cow-{}.img", "sandbox-1"));
assert_eq!(
path,
PathBuf::from("/var/lib/firecracker-vmm/cow/arcbox-cow-sandbox-1.img")
);
}
#[test]
fn test_snapshot_table_format() {
let sectors = 2097152_u64; let table =
format!("0 {sectors} snapshot /dev/loop0 /dev/loop1 P {SNAPSHOT_CHUNK_SECTORS}");
assert_eq!(table, "0 2097152 snapshot /dev/loop0 /dev/loop1 P 8");
}
#[test]
fn template_marker_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let mgr = CowManager {
templates: Mutex::new(HashMap::new()),
setup_orphans: Mutex::new(HashMap::new()),
losetup_lock: AsyncMutex::new(()),
cow_dir: tmp.path().to_path_buf(),
dmsetup_bin: None,
test_probe: None,
};
let template = PathBuf::from("/var/lib/arcbox/rootfs.ext4");
mgr.write_template_marker("/dev/loop7", &template).unwrap();
let marker = mgr.template_marker_path("/dev/loop7").unwrap();
assert_eq!(marker.file_name().unwrap(), "loop7");
assert!(marker.exists());
let content = std::fs::read_to_string(&marker).unwrap();
assert_eq!(content, template.to_string_lossy());
let pending = mgr.write_template_pending("box", &template).unwrap();
assert!(pending.exists());
clear_owner_marker(&pending).unwrap();
assert!(!pending.exists());
}
#[test]
fn stale_marker_temp_is_discarded() {
let tmp = tempfile::tempdir().unwrap();
let marker_dir = tmp.path().join(TEMPLATE_LOOP_DIR);
std::fs::create_dir_all(&marker_dir).unwrap();
let temporary = marker_dir.join(format!("{TEMPLATE_MARKER_TEMP_PREFIX}loop7"));
std::fs::write(&temporary, b"/partial").unwrap();
let mgr = CowManager {
templates: Mutex::new(HashMap::new()),
setup_orphans: Mutex::new(HashMap::new()),
losetup_lock: AsyncMutex::new(()),
cow_dir: tmp.path().to_path_buf(),
dmsetup_bin: None,
test_probe: None,
};
mgr.cleanup_stale_template_markers().unwrap();
assert!(!temporary.exists());
}
#[tokio::test]
async fn sparse_cow_creation_never_overwrites_an_owned_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("cow.img");
create_sparse_file(&path, 4096).await.unwrap();
let (_, owns_file) = create_sparse_file(&path, 8192).await.unwrap_err();
assert!(!owns_file);
assert_eq!(std::fs::metadata(path).unwrap().len(), 4096);
}
#[tokio::test]
async fn rollback_never_removes_an_unowned_cow_file() {
let tmp = tempfile::tempdir().unwrap();
let cow_file = tmp.path().join("existing.img");
std::fs::write(&cow_file, b"owned by another setup").unwrap();
let mgr = CowManager {
templates: Mutex::new(HashMap::new()),
setup_orphans: Mutex::new(HashMap::new()),
losetup_lock: AsyncMutex::new(()),
cow_dir: tmp.path().to_path_buf(),
dmsetup_bin: None,
test_probe: None,
};
let _ = mgr
.rollback_setup(
"box",
Path::new("/template"),
None,
None,
None,
VmmError::DeviceMapper("create failed".into()),
)
.await;
assert_eq!(std::fs::read(&cow_file).unwrap(), b"owned by another setup");
}
#[tokio::test]
async fn zero_ref_template_is_not_reused() {
let tmp = tempfile::tempdir().unwrap();
let template = PathBuf::from("/template");
let mgr = CowManager {
templates: Mutex::new(HashMap::from([(
template.clone(),
TemplateEntry {
loop_device: "/dev/loop7".into(),
sectors: 1024,
refcount: 0,
},
)])),
setup_orphans: Mutex::new(HashMap::new()),
losetup_lock: AsyncMutex::new(()),
cow_dir: tmp.path().to_path_buf(),
dmsetup_bin: Some("/not-used".into()),
test_probe: None,
};
assert!(matches!(
mgr.setup("box", template.to_str().unwrap()).await,
Err(VmmError::Unavailable(_))
));
assert_eq!(
mgr.templates
.lock()
.unwrap()
.get(&template)
.unwrap()
.refcount,
0
);
}
#[tokio::test]
async fn test_release_template_refcount() {
let mgr = CowManager {
templates: Mutex::new(HashMap::new()),
setup_orphans: Mutex::new(HashMap::new()),
losetup_lock: AsyncMutex::new(()),
cow_dir: PathBuf::from("/tmp"),
dmsetup_bin: None,
test_probe: None,
};
let path = PathBuf::from("/tmp/template.ext4");
{
let mut t = mgr.templates.lock().unwrap();
t.insert(
path.clone(),
TemplateEntry {
loop_device: "/dev/loop99".into(),
sectors: 1024,
refcount: 2,
},
);
}
mgr.release_template_ref(&path, true).await.unwrap();
{
let t = mgr.templates.lock().unwrap();
assert_eq!(t.get(&path).unwrap().refcount, 1);
}
}
}