use std::collections::HashMap;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(any(test, feature = "test-probe"))]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
use tracing::{debug, info, warn};
use crate::error::{Result, SnapshotError};
mod block_tools;
mod persistence;
pub use block_tools::{
BlockTools, BusyboxBlockTools, UtilLinuxBlockTools, device_major_minor, mknod_blkdev,
};
use persistence::{
SetupOrphan, clear_owner_marker, loop_backs_path, loop_devices_for_backing_sync,
remove_file_durable,
};
const DMSETUP_CANDIDATES: &[&str] = &["/usr/sbin/dmsetup", "/sbin/dmsetup"];
const SNAPSHOT_CHUNK_SECTORS: u64 = 8;
pub const DM_NAME_PREFIX: &str = "arcbox-snap-";
pub const COW_FILE_PREFIX: &str = "arcbox-cow-";
pub const COW_FILE_SUFFIX: &str = ".img";
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 dm_present(dm_name: &str) -> bool {
Path::new(&format!("/dev/mapper/{dm_name}")).exists() || devtmpfs_dm_node(dm_name).is_some()
}
fn devtmpfs_dm_node(dm_name: &str) -> Option<String> {
std::fs::read_dir("/sys/block")
.ok()?
.flatten()
.find_map(|entry| -> Option<String> {
let name = entry.file_name();
let device = name.to_str().filter(|name| name.starts_with("dm-"))?;
let mapped = std::fs::read_to_string(entry.path().join("dm/name")).ok()?;
(mapped.trim() == dm_name).then(|| format!("/dev/{device}"))
})
}
fn validate_dm_name_suffix(sandbox_id: &str) -> Result<()> {
if sandbox_id.is_empty() {
return Err(SnapshotError::DeviceMapper("empty sandbox id".into()));
}
if DM_NAME_PREFIX.len() + sandbox_id.len() > DM_NAME_MAX_LEN {
return Err(SnapshotError::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(SnapshotError::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(any(test, feature = "test-probe"))]
#[derive(Default)]
pub struct CowTestProbe {
setups: AtomicUsize,
teardowns: Mutex<Vec<String>>,
}
#[cfg(any(test, feature = "test-probe"))]
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!("{COW_FILE_PREFIX}{sandbox_id}{COW_FILE_SUFFIX}")),
template_path: PathBuf::from(rootfs_path),
dm_name,
}
}
fn teardown(&self, handle: &CowHandle) {
self.teardowns.lock().unwrap().push(handle.dm_name.clone());
}
pub fn setup_count(&self) -> usize {
self.setups.load(Ordering::SeqCst)
}
pub fn teardown_count(&self) -> usize {
self.teardowns.lock().unwrap().len()
}
}
pub struct CowOptions {
pub data_dir: PathBuf,
pub block_tools: Arc<dyn BlockTools>,
pub dmsetup_candidates: Vec<PathBuf>,
}
impl CowOptions {
pub fn new(data_dir: impl Into<PathBuf>) -> Self {
Self {
data_dir: data_dir.into(),
block_tools: Arc::new(BusyboxBlockTools::default()),
dmsetup_candidates: DMSETUP_CANDIDATES.iter().map(PathBuf::from).collect(),
}
}
}
pub struct CowManager {
templates: Mutex<HashMap<PathBuf, TemplateEntry>>,
setup_orphans: Mutex<HashMap<String, SetupOrphan>>,
losetup_lock: AsyncMutex<()>,
cow_dir: PathBuf,
tools: Arc<dyn BlockTools>,
dmsetup_bin: Option<PathBuf>,
#[cfg(any(test, feature = "test-probe"))]
test_probe: Option<Arc<CowTestProbe>>,
}
impl CowManager {
pub fn new(options: CowOptions) -> Result<Self> {
let CowOptions {
data_dir,
block_tools,
dmsetup_candidates,
} = options;
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.into_iter().find(|bin| {
bin.exists()
&& Command::new(bin)
.arg("version")
.output()
.is_ok_and(|out| out.status.success())
});
if dmsetup_bin.is_none() {
warn!("dmsetup not found or unusable; 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,
tools: block_tools,
dmsetup_bin,
#[cfg(any(test, feature = "test-probe"))]
test_probe: None,
})
}
#[cfg(any(test, feature = "test-probe"))]
pub fn new_with_test_probe(options: CowOptions, probe: Arc<CowTestProbe>) -> Result<Self> {
let mut manager = Self::new(options)?;
manager.test_probe = Some(probe);
Ok(manager)
}
pub(super) async fn attach_loop(&self, backing: &Path, read_only: bool) -> Result<String> {
let tools = Arc::clone(&self.tools);
let backing = backing.to_path_buf();
run_blocking(move || tools.attach_loop(&backing, read_only)).await
}
pub(super) async fn detach_loop(&self, device: &str) -> Result<()> {
let tools = Arc::clone(&self.tools);
let device = device.to_owned();
run_blocking(move || tools.detach_loop(&device)).await
}
pub(super) async fn device_sectors(&self, device: &str) -> Result<u64> {
let tools = Arc::clone(&self.tools);
let device = device.to_owned();
run_blocking(move || tools.device_sectors(&device)).await
}
pub async fn setup(&self, sandbox_id: &str, rootfs_path: &str) -> Result<CowHandle> {
self.assemble(sandbox_id, rootfs_path, false).await
}
pub async fn reattach(&self, sandbox_id: &str, rootfs_path: &str) -> Result<CowHandle> {
self.assemble(sandbox_id, rootfs_path, true).await
}
async fn assemble(
&self,
sandbox_id: &str,
rootfs_path: &str,
reuse_existing_cow: bool,
) -> Result<CowHandle> {
validate_dm_name_suffix(sandbox_id)?;
#[cfg(any(test, feature = "test-probe"))]
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(SnapshotError::Unavailable(format!(
"sandbox {sandbox_id} still owns resources from an incomplete CoW setup"
)));
}
let dmsetup = self
.dmsetup_bin
.as_deref()
.ok_or_else(|| SnapshotError::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(SnapshotError::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(SnapshotError::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 self.attach_loop(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 self.device_sectors(&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!("{COW_FILE_PREFIX}{sandbox_id}{COW_FILE_SUFFIX}"));
let cow_size = sectors * 512;
if reuse_existing_cow {
let verified = std::fs::metadata(&cow_file)
.map_err(|e| {
SnapshotError::DeviceMapper(format!(
"preserved cow file {}: {e}",
cow_file.display()
))
})
.and_then(|metadata| {
if metadata.len() == cow_size {
Ok(())
} else {
Err(SnapshotError::DeviceMapper(format!(
"preserved cow file {} is {} bytes but template needs {cow_size}",
cow_file.display(),
metadata.len()
)))
}
});
if let Err(e) = verified {
return Err(self
.rollback_setup(sandbox_id, &template, None, None, None, e)
.await);
}
} else 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 rollback_cow_file = (!reuse_existing_cow).then_some(cow_file.clone());
let rollback_cow_file = rollback_cow_file.as_deref();
let cow_loop_result = {
let losetup_guard = self.losetup_lock.lock().await;
let result = self.attach_loop(&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, rollback_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),
rollback_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(any(test, feature = "test-probe"))]
if let Some(probe) = &self.test_probe {
probe.teardown(handle);
return Ok(());
}
let dmsetup = self
.dmsetup_bin
.as_deref()
.ok_or_else(|| SnapshotError::DeviceMapper("dmsetup binary not found".into()))?;
let mut failures = Vec::new();
let dm_removed = if !dm_present(&handle.dm_name) {
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 self.detach_loop(&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(SnapshotError::DeviceMapper(failures.join("; ")))
}
}
pub async fn detach_keep_cow(&self, handle: &CowHandle) -> Result<()> {
#[cfg(any(test, feature = "test-probe"))]
if let Some(probe) = &self.test_probe {
probe.teardown(handle);
return Ok(());
}
let dmsetup = self
.dmsetup_bin
.as_deref()
.ok_or_else(|| SnapshotError::DeviceMapper("dmsetup binary not found".into()))?;
let mut failures = Vec::new();
if dm_present(&handle.dm_name)
&& let Err(error) = dmsetup_remove(dmsetup, &handle.dm_name).await
{
failures.push(format!("remove {}: {error}", handle.dm_name));
}
if failures.is_empty()
&& loop_backs_path(&handle.cow_loop, &handle.cow_file)?
&& let Err(error) = self.detach_loop(&handle.cow_loop).await
{
failures.push(format!("detach {}: {error}", handle.cow_loop));
}
if failures.is_empty() {
self.release_template_ref(&handle.template_path, true)
.await?;
info!(sandbox = %handle.dm_name, "dm-snapshot detached, cow file retained");
Ok(())
} else {
Err(SnapshotError::DeviceMapper(failures.join("; ")))
}
}
pub async fn remove_preserved_cow(&self, sandbox_id: &str) -> Result<()> {
let cow_file = self
.cow_dir
.join(format!("{COW_FILE_PREFIX}{sandbox_id}{COW_FILE_SUFFIX}"));
if !cow_file.exists() {
return Ok(());
}
for loop_device in loop_devices_for_backing_sync(&cow_file)? {
self.detach_loop(&loop_device).await?;
}
remove_file_durable(&cow_file)?;
Ok(())
}
}
async fn run_blocking<T: Send + 'static>(
op: impl FnOnce() -> Result<T> + Send + 'static,
) -> Result<T> {
tokio::task::spawn_blocking(op)
.await
.map_err(|e| SnapshotError::DeviceMapper(format!("spawn_blocking join: {e}")))?
}
async fn run_cmd(mut cmd: Command) -> Result<std::process::Output> {
tokio::task::spawn_blocking(move || cmd.output())
.await
.map_err(|e| SnapshotError::DeviceMapper(format!("spawn_blocking join: {e}")))?
.map_err(|e| SnapshotError::DeviceMapper(format!("command spawn: {e}")))
}
async fn dmsetup_create(bin: &Path, 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(SnapshotError::DeviceMapper(format!(
"dmsetup create {name}: {stderr}"
)));
}
let mut mknodes = Command::new(bin);
mknodes.args(["mknodes", name]);
let mknodes = run_cmd(mknodes).await?;
if !mknodes.status.success() {
warn!(
name,
stderr = %String::from_utf8_lossy(&mknodes.stderr),
"dmsetup mknodes failed"
);
}
let node = format!("/dev/mapper/{name}");
if !Path::new(&node).exists() {
return Err(SnapshotError::DeviceMapper(format!(
"dm device {name} exists but has no node at {node}, and dmsetup mknodes did not \
create one; a sandbox would silently copy its whole rootfs instead of using this \
overlay"
)));
}
Ok(())
}
async fn dmsetup_remove(bin: &Path, name: &str) -> Result<()> {
let mut cmd = Command::new(bin);
cmd.args(["remove", "--retry", name]);
let output = run_cmd(cmd).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SnapshotError::DeviceMapper(format!(
"dmsetup remove {name}: {stderr}"
)));
}
Ok(())
}
async fn create_sparse_file(
path: &Path,
size: u64,
) -> std::result::Result<(), (SnapshotError, bool)> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || -> std::result::Result<(), (SnapshotError, bool)> {
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(|e| {
(
SnapshotError::DeviceMapper(format!("create cow file: {e}")),
false,
)
})?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| {
(
SnapshotError::DeviceMapper(format!("secure cow file: {e}")),
true,
)
})?;
file.set_len(size).map_err(|e| {
(
SnapshotError::DeviceMapper(format!("truncate cow file: {e}")),
true,
)
})?;
file.sync_all().map_err(|e| {
(
SnapshotError::DeviceMapper(format!("sync cow file: {e}")),
true,
)
})?;
let parent = path.parent().ok_or_else(|| {
(
SnapshotError::DeviceMapper("cow file has no parent".into()),
true,
)
})?;
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|e| {
(
SnapshotError::DeviceMapper(format!("sync cow directory: {e}")),
true,
)
})?;
Ok(())
})
.await
.map_err(|e| {
(
SnapshotError::DeviceMapper(format!("spawn_blocking join: {e}")),
false,
)
})?
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
fn manager(cow_dir: &Path) -> CowManager {
CowManager {
templates: Mutex::new(HashMap::new()),
setup_orphans: Mutex::new(HashMap::new()),
losetup_lock: AsyncMutex::new(()),
cow_dir: cow_dir.to_path_buf(),
tools: Arc::new(BusyboxBlockTools::default()),
dmsetup_bin: None,
test_probe: None,
}
}
fn fake_dmsetup(dir: &Path, listed: &[&str]) -> PathBuf {
let listing = listed
.iter()
.enumerate()
.map(|(minor, name)| format!("{name}\\t(253, {minor})"))
.collect::<Vec<_>>()
.join("\\n");
let binary = dir.join("dmsetup");
crate::test_support::write_script(
&binary,
&format!(
"#!/bin/sh\n\
if [ \"$1\" = ls ]; then printf '{listing}\\n'; else echo \"$@\" >> '{}'; fi\n",
dir.join("dmsetup.log").display()
),
);
std::fs::remove_file(dir.join("dmsetup.log")).ok();
binary
}
fn handle_for(dm_device: &Path, template: &Path) -> CowHandle {
CowHandle {
dm_name: "arcbox-snap-box".into(),
dm_device: dm_device.to_string_lossy().into_owned(),
cow_loop: "/dev/loop9".into(),
cow_file: PathBuf::from("/nonexistent/arcbox-cow-box.img"),
template_path: template.to_path_buf(),
}
}
#[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 = manager(tmp.path());
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 = manager(tmp.path());
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 = manager(tmp.path());
let _ = mgr
.rollback_setup(
"box",
Path::new("/template"),
None,
None,
None,
SnapshotError::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 mut mgr = manager(tmp.path());
mgr.dmsetup_bin = Some("/not-used".into());
mgr.templates.lock().unwrap().insert(
template.clone(),
TemplateEntry {
loop_device: "/dev/loop7".into(),
sectors: 1024,
refcount: 0,
},
);
assert!(matches!(
mgr.setup("box", template.to_str().unwrap()).await,
Err(SnapshotError::Unavailable(_))
));
assert_eq!(
mgr.templates
.lock()
.unwrap()
.get(&template)
.unwrap()
.refcount,
0
);
}
#[test]
fn a_live_dm_device_is_not_removed() {
let tmp = tempfile::tempdir().unwrap();
let mut mgr = manager(tmp.path());
mgr.dmsetup_bin = Some(fake_dmsetup(
tmp.path(),
&["arcbox-snap-live", "arcbox-snap-dead", "foreign-snapshot"],
));
mgr.reconcile_stale(&HashSet::new(), &HashSet::from(["live".to_owned()]))
.unwrap();
let removed = std::fs::read_to_string(tmp.path().join("dmsetup.log")).unwrap();
assert_eq!(
removed.lines().collect::<Vec<_>>(),
["remove --retry arcbox-snap-dead"],
"the sweep removed the wrong devices"
);
}
#[test]
fn a_live_sandbox_cow_file_is_kept() {
let tmp = tempfile::tempdir().unwrap();
let overlay = tmp.path().join("arcbox-cow-live.img");
std::fs::write(&overlay, b"exceptions").unwrap();
let mgr = manager(tmp.path());
mgr.reconcile_stale(&HashSet::new(), &HashSet::from(["live".to_owned()]))
.unwrap();
assert!(overlay.exists(), "a live sandbox's overlay was deleted");
}
#[test]
fn a_live_template_loop_survives_the_sweep() {
let tmp = tempfile::tempdir().unwrap();
let marker_dir = tmp.path().join(TEMPLATE_LOOP_DIR);
std::fs::create_dir_all(&marker_dir).unwrap();
let live = tmp.path().join("live.ext4");
let stale = tmp.path().join("stale.ext4");
std::fs::write(marker_dir.join("loop7"), live.to_string_lossy().as_bytes()).unwrap();
std::fs::write(marker_dir.join("loop8"), stale.to_string_lossy().as_bytes()).unwrap();
let mgr = manager(tmp.path());
mgr.templates.lock().unwrap().insert(
live,
TemplateEntry {
loop_device: "/dev/loop7".into(),
sectors: 1024,
refcount: 1,
},
);
mgr.reconcile_stale(&HashSet::new(), &HashSet::new())
.unwrap();
assert!(
marker_dir.join("loop7").exists(),
"the live template's recovery marker was cleared"
);
assert!(!marker_dir.join("loop8").exists());
}
#[test]
fn adopt_refuses_a_missing_dm_device() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path());
let handle = handle_for(
&tmp.path().join("arcbox-snap-box"),
&tmp.path().join("t.ext4"),
);
let error = mgr.adopt(&handle).unwrap_err();
assert!(
matches!(&error, SnapshotError::FailedPrecondition(m) if m.contains("arcbox-snap-box")),
"{error}"
);
assert!(mgr.templates.lock().unwrap().is_empty());
}
#[cfg(target_os = "linux")]
#[test]
fn adopt_refuses_a_template_no_loop_device_backs() {
let tmp = tempfile::tempdir().unwrap();
let dm_device = tmp.path().join("arcbox-snap-box");
std::fs::write(&dm_device, b"").unwrap();
let template = tmp.path().join("t.ext4");
std::fs::write(&template, b"rootfs").unwrap();
let mgr = manager(tmp.path());
let error = mgr.adopt(&handle_for(&dm_device, &template)).unwrap_err();
assert!(
matches!(&error, SnapshotError::FailedPrecondition(m) if m.contains("no loop device backs")),
"{error}"
);
assert!(
mgr.templates.lock().unwrap().is_empty(),
"a refused adoption took a template reference"
);
}
#[test]
fn a_template_reached_through_a_symlink_is_matched_by_its_resolved_path() {
let tmp = tempfile::tempdir().unwrap();
let templates = tmp.path().join("templates");
std::fs::create_dir(&templates).unwrap();
std::fs::write(templates.join("rootfs.ext4"), b"rootfs").unwrap();
let linked = tmp.path().join("data");
std::os::unix::fs::symlink(&templates, &linked).unwrap();
let configured = linked.join("rootfs.ext4");
let spellings = persistence::backing_spellings(&configured);
let resolved = std::fs::canonicalize(&configured).unwrap();
assert_ne!(
configured, resolved,
"the symlinked spelling has to differ, or this asserts nothing"
);
assert!(
spellings.contains(&configured.to_string_lossy().into_owned()),
"{spellings:?}"
);
assert!(
spellings.contains(&resolved.to_string_lossy().into_owned()),
"{spellings:?}"
);
}
#[test]
fn adopting_a_shared_template_registers_then_references() {
let mgr = manager(Path::new("/tmp"));
let template = Path::new("/templates/rootfs.ext4");
mgr.take_template_ref(template, "/dev/loop7", 2048).unwrap();
mgr.take_template_ref(template, "/dev/loop7", 2048).unwrap();
let templates = mgr.templates.lock().unwrap();
let entry = templates.get(template).unwrap();
assert_eq!(entry.refcount, 2);
assert_eq!(entry.loop_device, "/dev/loop7");
assert_eq!(entry.sectors, 2048);
}
#[test]
fn adopting_refuses_a_zero_ref_or_relocated_template() {
let mgr = manager(Path::new("/tmp"));
let template = Path::new("/templates/rootfs.ext4");
mgr.templates.lock().unwrap().insert(
template.to_path_buf(),
TemplateEntry {
loop_device: "/dev/loop7".into(),
sectors: 2048,
refcount: 0,
},
);
let incomplete = mgr
.take_template_ref(template, "/dev/loop7", 2048)
.unwrap_err();
assert!(
matches!(&incomplete, SnapshotError::Unavailable(m) if m.contains("incomplete CoW setup")),
"{incomplete}"
);
mgr.templates
.lock()
.unwrap()
.get_mut(template)
.unwrap()
.refcount = 1;
let relocated = mgr
.take_template_ref(template, "/dev/loop9", 2048)
.unwrap_err();
assert!(
matches!(&relocated, SnapshotError::FailedPrecondition(m) if m.contains("/dev/loop9")),
"{relocated}"
);
assert_eq!(
mgr.templates
.lock()
.unwrap()
.get(template)
.unwrap()
.refcount,
1,
"a refused adoption took a template reference"
);
}
#[tokio::test]
async fn test_release_template_refcount() {
let mgr = manager(Path::new("/tmp"));
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);
}
}
}