1use std::collections::HashMap;
17use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
18use std::path::{Path, PathBuf};
19use std::process::Command;
20#[cfg(any(test, feature = "test-probe"))]
21use std::sync::atomic::{AtomicUsize, Ordering};
22use std::sync::{Arc, Mutex};
23
24use tokio::sync::Mutex as AsyncMutex;
25use tracing::{debug, info, warn};
26
27use crate::error::{Result, SnapshotError};
28
29mod block_tools;
30mod persistence;
31
32pub use block_tools::{
33 BlockTools, BusyboxBlockTools, UtilLinuxBlockTools, device_major_minor, mknod_blkdev,
34};
35use persistence::{
36 SetupOrphan, clear_owner_marker, loop_backs_path, loop_devices_for_backing_sync,
37 remove_file_durable,
38};
39
40const DMSETUP_CANDIDATES: &[&str] = &["/usr/sbin/dmsetup", "/sbin/dmsetup"];
50
51const SNAPSHOT_CHUNK_SECTORS: u64 = 8;
53
54pub const DM_NAME_PREFIX: &str = "arcbox-snap-";
62
63pub const COW_FILE_PREFIX: &str = "arcbox-cow-";
65
66pub const COW_FILE_SUFFIX: &str = ".img";
69
70const DM_NAME_MAX_LEN: usize = 127;
72
73const TEMPLATE_LOOP_DIR: &str = ".template-loops";
79const TEMPLATE_PENDING_PREFIX: &str = "pending-";
80const TEMPLATE_MARKER_TEMP_PREFIX: &str = ".tmp-";
81
82fn dm_present(dm_name: &str) -> bool {
89 Path::new(&format!("/dev/mapper/{dm_name}")).exists() || devtmpfs_dm_node(dm_name).is_some()
90}
91
92fn devtmpfs_dm_node(dm_name: &str) -> Option<String> {
94 std::fs::read_dir("/sys/block")
95 .ok()?
96 .flatten()
97 .find_map(|entry| -> Option<String> {
98 let name = entry.file_name();
99 let device = name.to_str().filter(|name| name.starts_with("dm-"))?;
100 let mapped = std::fs::read_to_string(entry.path().join("dm/name")).ok()?;
101 (mapped.trim() == dm_name).then(|| format!("/dev/{device}"))
102 })
103}
104
105fn validate_dm_name_suffix(sandbox_id: &str) -> Result<()> {
112 if sandbox_id.is_empty() {
113 return Err(SnapshotError::DeviceMapper("empty sandbox id".into()));
114 }
115 if DM_NAME_PREFIX.len() + sandbox_id.len() > DM_NAME_MAX_LEN {
116 return Err(SnapshotError::DeviceMapper(format!(
117 "sandbox id too long for dm-name (max {} chars after prefix)",
118 DM_NAME_MAX_LEN - DM_NAME_PREFIX.len()
119 )));
120 }
121 if let Some(bad) = sandbox_id
122 .chars()
123 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '.' | '-')))
124 {
125 return Err(SnapshotError::DeviceMapper(format!(
126 "sandbox id contains character {bad:?} not allowed in dm-name"
127 )));
128 }
129 Ok(())
130}
131
132struct TemplateEntry {
138 loop_device: String,
140 sectors: u64,
142 refcount: usize,
144}
145
146#[derive(Debug)]
151pub struct CowHandle {
152 pub dm_name: String,
154 pub dm_device: String,
156 pub cow_loop: String,
158 pub cow_file: PathBuf,
160 pub template_path: PathBuf,
162}
163
164#[cfg(any(test, feature = "test-probe"))]
165#[derive(Default)]
166pub struct CowTestProbe {
167 setups: AtomicUsize,
168 teardowns: Mutex<Vec<String>>,
169}
170
171#[cfg(any(test, feature = "test-probe"))]
172impl CowTestProbe {
173 fn setup(&self, sandbox_id: &str, rootfs_path: &str, cow_dir: &Path) -> CowHandle {
174 self.setups.fetch_add(1, Ordering::SeqCst);
175 let dm_name = format!("{DM_NAME_PREFIX}{sandbox_id}");
176 CowHandle {
177 dm_device: format!("/dev/mapper/{dm_name}"),
178 cow_loop: format!("/dev/loop-test-{sandbox_id}"),
179 cow_file: cow_dir.join(format!("{COW_FILE_PREFIX}{sandbox_id}{COW_FILE_SUFFIX}")),
180 template_path: PathBuf::from(rootfs_path),
181 dm_name,
182 }
183 }
184
185 fn teardown(&self, handle: &CowHandle) {
186 self.teardowns.lock().unwrap().push(handle.dm_name.clone());
187 }
188
189 pub fn setup_count(&self) -> usize {
190 self.setups.load(Ordering::SeqCst)
191 }
192
193 pub fn teardown_count(&self) -> usize {
194 self.teardowns.lock().unwrap().len()
195 }
196}
197
198pub struct CowOptions {
204 pub data_dir: PathBuf,
206 pub block_tools: Arc<dyn BlockTools>,
208 pub dmsetup_candidates: Vec<PathBuf>,
212}
213
214impl CowOptions {
215 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
218 Self {
219 data_dir: data_dir.into(),
220 block_tools: Arc::new(BusyboxBlockTools::default()),
221 dmsetup_candidates: DMSETUP_CANDIDATES.iter().map(PathBuf::from).collect(),
222 }
223 }
224}
225
226pub struct CowManager {
228 templates: Mutex<HashMap<PathBuf, TemplateEntry>>,
229 setup_orphans: Mutex<HashMap<String, SetupOrphan>>,
230 losetup_lock: AsyncMutex<()>,
238 cow_dir: PathBuf,
239 tools: Arc<dyn BlockTools>,
240 dmsetup_bin: Option<PathBuf>,
241 #[cfg(any(test, feature = "test-probe"))]
242 test_probe: Option<Arc<CowTestProbe>>,
243}
244
245impl CowManager {
250 pub fn new(options: CowOptions) -> Result<Self> {
253 let CowOptions {
254 data_dir,
255 block_tools,
256 dmsetup_candidates,
257 } = options;
258 let cow_dir = data_dir.join("cow");
259 let marker_dir = cow_dir.join(TEMPLATE_LOOP_DIR);
260 std::fs::create_dir_all(&marker_dir)?;
261 std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
262 std::fs::set_permissions(&cow_dir, std::fs::Permissions::from_mode(0o700))?;
263 std::fs::set_permissions(&marker_dir, std::fs::Permissions::from_mode(0o700))?;
264 std::fs::File::open(&marker_dir)?.sync_all()?;
265 std::fs::File::open(&cow_dir)?.sync_all()?;
266 std::fs::File::open(&data_dir)?.sync_all()?;
267
268 let dmsetup_bin = dmsetup_candidates.into_iter().find(|bin| {
276 bin.exists()
277 && Command::new(bin)
278 .arg("version")
279 .output()
280 .is_ok_and(|out| out.status.success())
281 });
282
283 if dmsetup_bin.is_none() {
284 warn!("dmsetup not found or unusable; dm-snapshot CoW will be unavailable");
285 }
286
287 Ok(Self {
288 templates: Mutex::new(HashMap::new()),
289 setup_orphans: Mutex::new(HashMap::new()),
290 losetup_lock: AsyncMutex::new(()),
291 cow_dir,
292 tools: block_tools,
293 dmsetup_bin,
294 #[cfg(any(test, feature = "test-probe"))]
295 test_probe: None,
296 })
297 }
298
299 #[cfg(any(test, feature = "test-probe"))]
300 pub fn new_with_test_probe(options: CowOptions, probe: Arc<CowTestProbe>) -> Result<Self> {
301 let mut manager = Self::new(options)?;
302 manager.test_probe = Some(probe);
303 Ok(manager)
304 }
305
306 pub(super) async fn attach_loop(&self, backing: &Path, read_only: bool) -> Result<String> {
308 let tools = Arc::clone(&self.tools);
309 let backing = backing.to_path_buf();
310 run_blocking(move || tools.attach_loop(&backing, read_only)).await
311 }
312
313 pub(super) async fn detach_loop(&self, device: &str) -> Result<()> {
315 let tools = Arc::clone(&self.tools);
316 let device = device.to_owned();
317 run_blocking(move || tools.detach_loop(&device)).await
318 }
319
320 pub(super) async fn device_sectors(&self, device: &str) -> Result<u64> {
322 let tools = Arc::clone(&self.tools);
323 let device = device.to_owned();
324 run_blocking(move || tools.device_sectors(&device)).await
325 }
326
327 pub async fn setup(&self, sandbox_id: &str, rootfs_path: &str) -> Result<CowHandle> {
332 self.assemble(sandbox_id, rootfs_path, false).await
333 }
334
335 pub async fn reattach(&self, sandbox_id: &str, rootfs_path: &str) -> Result<CowHandle> {
344 self.assemble(sandbox_id, rootfs_path, true).await
345 }
346
347 async fn assemble(
348 &self,
349 sandbox_id: &str,
350 rootfs_path: &str,
351 reuse_existing_cow: bool,
352 ) -> Result<CowHandle> {
353 validate_dm_name_suffix(sandbox_id)?;
354 #[cfg(any(test, feature = "test-probe"))]
355 if let Some(probe) = &self.test_probe {
356 return Ok(probe.setup(sandbox_id, rootfs_path, &self.cow_dir));
357 }
358 if self.setup_orphans.lock().unwrap().contains_key(sandbox_id) {
359 return Err(SnapshotError::Unavailable(format!(
360 "sandbox {sandbox_id} still owns resources from an incomplete CoW setup"
361 )));
362 }
363
364 let dmsetup = self
365 .dmsetup_bin
366 .as_deref()
367 .ok_or_else(|| SnapshotError::DeviceMapper("dmsetup binary not found".into()))?;
368
369 let template = PathBuf::from(rootfs_path);
370
371 let (template_loop, sectors) = 'acquire: {
378 if let Some(cached) = {
380 let mut templates = self.templates.lock().unwrap();
381 let entry = templates.get_mut(&template);
382 if entry.as_ref().is_some_and(|entry| entry.refcount == 0) {
383 return Err(SnapshotError::Unavailable(format!(
384 "template {} still owns resources from an incomplete CoW setup",
385 template.display()
386 )));
387 }
388 entry.map(|entry| {
389 entry.refcount += 1;
390 debug!(
391 template = %rootfs_path,
392 loop_dev = %entry.loop_device,
393 refcount = entry.refcount,
394 "reusing template loop device"
395 );
396 (entry.loop_device.clone(), entry.sectors)
397 })
398 } {
399 break 'acquire cached;
400 }
401
402 let _losetup_guard = self.losetup_lock.lock().await;
405
406 if let Some(cached) = {
409 let mut templates = self.templates.lock().unwrap();
410 let entry = templates.get_mut(&template);
411 if entry.as_ref().is_some_and(|entry| entry.refcount == 0) {
412 return Err(SnapshotError::Unavailable(format!(
413 "template {} still owns resources from an incomplete CoW setup",
414 template.display()
415 )));
416 }
417 entry.map(|entry| {
418 entry.refcount += 1;
419 debug!(
420 template = %rootfs_path,
421 loop_dev = %entry.loop_device,
422 refcount = entry.refcount,
423 "reusing template loop device (after lock)"
424 );
425 (entry.loop_device.clone(), entry.sectors)
426 })
427 } {
428 break 'acquire cached;
429 }
430
431 let pending = self.write_template_pending(sandbox_id, &template)?;
434 let loop_dev = match self.attach_loop(Path::new(rootfs_path), true).await {
435 Ok(loop_dev) => loop_dev,
436 Err(error) => {
437 return Err(self
438 .abort_template_acquisition(sandbox_id, &pending, None, &template, error)
439 .await);
440 }
441 };
442 if let Err(error) = self.write_template_marker(&loop_dev, &template) {
446 return Err(self
447 .abort_template_acquisition(
448 sandbox_id,
449 &pending,
450 Some(&loop_dev),
451 &template,
452 error,
453 )
454 .await);
455 }
456 let sectors = match self.device_sectors(&loop_dev).await {
457 Ok(sectors) => sectors,
458 Err(error) => {
459 return Err(self
460 .abort_template_acquisition(
461 sandbox_id,
462 &pending,
463 Some(&loop_dev),
464 &template,
465 error,
466 )
467 .await);
468 }
469 };
470 if let Err(error) = clear_owner_marker(&pending) {
471 return Err(self
472 .abort_template_acquisition(
473 sandbox_id,
474 &pending,
475 Some(&loop_dev),
476 &template,
477 error,
478 )
479 .await);
480 }
481 debug!(
482 template = %rootfs_path,
483 loop_dev = %loop_dev,
484 sectors,
485 "attached new template loop device"
486 );
487 {
488 let mut templates = self.templates.lock().unwrap();
489 templates.insert(
490 template.clone(),
491 TemplateEntry {
492 loop_device: loop_dev.clone(),
493 sectors,
494 refcount: 1,
495 },
496 );
497 }
498 (loop_dev, sectors)
499 };
500
501 let cow_file = self
507 .cow_dir
508 .join(format!("{COW_FILE_PREFIX}{sandbox_id}{COW_FILE_SUFFIX}"));
509 let cow_size = sectors * 512;
510 if reuse_existing_cow {
511 let verified = std::fs::metadata(&cow_file)
512 .map_err(|e| {
513 SnapshotError::DeviceMapper(format!(
514 "preserved cow file {}: {e}",
515 cow_file.display()
516 ))
517 })
518 .and_then(|metadata| {
519 if metadata.len() == cow_size {
520 Ok(())
521 } else {
522 Err(SnapshotError::DeviceMapper(format!(
523 "preserved cow file {} is {} bytes but template needs {cow_size}",
524 cow_file.display(),
525 metadata.len()
526 )))
527 }
528 });
529 if let Err(e) = verified {
530 return Err(self
531 .rollback_setup(sandbox_id, &template, None, None, None, e)
532 .await);
533 }
534 } else if let Err((e, owns_file)) = create_sparse_file(&cow_file, cow_size).await {
535 return Err(self
536 .rollback_setup(
537 sandbox_id,
538 &template,
539 None,
540 None,
541 owns_file.then_some(cow_file.as_path()),
542 e,
543 )
544 .await);
545 }
546 let rollback_cow_file = (!reuse_existing_cow).then_some(cow_file.clone());
547 let rollback_cow_file = rollback_cow_file.as_deref();
548
549 let cow_loop_result = {
551 let losetup_guard = self.losetup_lock.lock().await;
552 let result = self.attach_loop(&cow_file, false).await;
553 drop(losetup_guard);
554 result
555 };
556 let cow_loop = match cow_loop_result {
557 Ok(dev) => dev,
558 Err(e) => {
559 return Err(self
560 .rollback_setup(sandbox_id, &template, None, None, rollback_cow_file, e)
561 .await);
562 }
563 };
564
565 let dm_name = format!("{DM_NAME_PREFIX}{sandbox_id}");
567 let table =
568 format!("0 {sectors} snapshot {template_loop} {cow_loop} P {SNAPSHOT_CHUNK_SECTORS}");
569
570 if let Err(e) = dmsetup_create(dmsetup, &dm_name, &table).await {
571 return Err(self
572 .rollback_setup(
573 sandbox_id,
574 &template,
575 Some(&dm_name),
576 Some(&cow_loop),
577 rollback_cow_file,
578 e,
579 )
580 .await);
581 }
582
583 let dm_device = format!("/dev/mapper/{dm_name}");
584 info!(
585 sandbox_id,
586 dm_device = %dm_device,
587 cow_file = %cow_file.display(),
588 "dm-snapshot created"
589 );
590
591 Ok(CowHandle {
592 dm_name,
593 dm_device,
594 cow_loop,
595 cow_file,
596 template_path: template,
597 })
598 }
599
600 pub async fn teardown(&self, handle: &CowHandle) {
602 if let Err(error) = self.teardown_checked(handle).await {
603 warn!(dm = %handle.dm_name, error = %error, "dm-snapshot teardown incomplete");
604 }
605 }
606
607 pub async fn teardown_checked(&self, handle: &CowHandle) -> Result<()> {
611 #[cfg(any(test, feature = "test-probe"))]
612 if let Some(probe) = &self.test_probe {
613 probe.teardown(handle);
614 return Ok(());
615 }
616 let dmsetup = self
617 .dmsetup_bin
618 .as_deref()
619 .ok_or_else(|| SnapshotError::DeviceMapper("dmsetup binary not found".into()))?;
620 let mut failures = Vec::new();
621
622 let dm_removed = if !dm_present(&handle.dm_name) {
624 true
625 } else {
626 match dmsetup_remove(dmsetup, &handle.dm_name).await {
627 Ok(()) => true,
628 Err(error) => {
629 failures.push(format!("remove {}: {error}", handle.dm_name));
630 false
631 }
632 }
633 };
634
635 let loop_detached = if loop_backs_path(&handle.cow_loop, &handle.cow_file)? {
637 match self.detach_loop(&handle.cow_loop).await {
638 Ok(()) => true,
639 Err(error) => {
640 failures.push(format!("detach {}: {error}", handle.cow_loop));
641 false
642 }
643 }
644 } else {
645 match loop_devices_for_backing_sync(&handle.cow_file) {
646 Ok(devices) if devices.is_empty() => true,
647 Ok(devices) => {
648 failures.push(format!(
649 "{} is still attached through {} after {} changed ownership",
650 handle.cow_file.display(),
651 devices.join(", "),
652 handle.cow_loop
653 ));
654 false
655 }
656 Err(error) => {
657 failures.push(format!(
658 "verify loop ownership for {}: {error}",
659 handle.cow_file.display()
660 ));
661 false
662 }
663 }
664 };
665
666 if dm_removed && loop_detached {
670 if let Err(error) = remove_file_durable(&handle.cow_file) {
671 failures.push(format!("remove {}: {error}", handle.cow_file.display()));
672 }
673 }
674
675 if failures.is_empty() {
676 self.release_template_ref(&handle.template_path, true)
679 .await?;
680 info!(sandbox = %handle.dm_name, "dm-snapshot teardown complete");
681 Ok(())
682 } else {
683 Err(SnapshotError::DeviceMapper(failures.join("; ")))
684 }
685 }
686
687 pub async fn detach_keep_cow(&self, handle: &CowHandle) -> Result<()> {
695 #[cfg(any(test, feature = "test-probe"))]
696 if let Some(probe) = &self.test_probe {
697 probe.teardown(handle);
698 return Ok(());
699 }
700 let dmsetup = self
701 .dmsetup_bin
702 .as_deref()
703 .ok_or_else(|| SnapshotError::DeviceMapper("dmsetup binary not found".into()))?;
704 let mut failures = Vec::new();
705
706 if dm_present(&handle.dm_name)
707 && let Err(error) = dmsetup_remove(dmsetup, &handle.dm_name).await
708 {
709 failures.push(format!("remove {}: {error}", handle.dm_name));
710 }
711 if failures.is_empty()
712 && loop_backs_path(&handle.cow_loop, &handle.cow_file)?
713 && let Err(error) = self.detach_loop(&handle.cow_loop).await
714 {
715 failures.push(format!("detach {}: {error}", handle.cow_loop));
716 }
717
718 if failures.is_empty() {
719 self.release_template_ref(&handle.template_path, true)
720 .await?;
721 info!(sandbox = %handle.dm_name, "dm-snapshot detached, cow file retained");
722 Ok(())
723 } else {
724 Err(SnapshotError::DeviceMapper(failures.join("; ")))
725 }
726 }
727
728 pub async fn remove_preserved_cow(&self, sandbox_id: &str) -> Result<()> {
733 let cow_file = self
734 .cow_dir
735 .join(format!("{COW_FILE_PREFIX}{sandbox_id}{COW_FILE_SUFFIX}"));
736 if !cow_file.exists() {
737 return Ok(());
738 }
739 for loop_device in loop_devices_for_backing_sync(&cow_file)? {
740 self.detach_loop(&loop_device).await?;
741 }
742 remove_file_durable(&cow_file)?;
743 Ok(())
744 }
745}
746
747async fn run_blocking<T: Send + 'static>(
753 op: impl FnOnce() -> Result<T> + Send + 'static,
754) -> Result<T> {
755 tokio::task::spawn_blocking(op)
756 .await
757 .map_err(|e| SnapshotError::DeviceMapper(format!("spawn_blocking join: {e}")))?
758}
759
760async fn run_cmd(mut cmd: Command) -> Result<std::process::Output> {
767 tokio::task::spawn_blocking(move || cmd.output())
768 .await
769 .map_err(|e| SnapshotError::DeviceMapper(format!("spawn_blocking join: {e}")))?
770 .map_err(|e| SnapshotError::DeviceMapper(format!("command spawn: {e}")))
771}
772
773async fn dmsetup_create(bin: &Path, name: &str, table: &str) -> Result<()> {
791 let mut cmd = Command::new(bin);
792 cmd.args(["create", name, "--table", table]);
793
794 let output = run_cmd(cmd).await?;
795
796 if !output.status.success() {
797 let stderr = String::from_utf8_lossy(&output.stderr);
798 return Err(SnapshotError::DeviceMapper(format!(
799 "dmsetup create {name}: {stderr}"
800 )));
801 }
802
803 let mut mknodes = Command::new(bin);
804 mknodes.args(["mknodes", name]);
805 let mknodes = run_cmd(mknodes).await?;
806 if !mknodes.status.success() {
807 warn!(
808 name,
809 stderr = %String::from_utf8_lossy(&mknodes.stderr),
810 "dmsetup mknodes failed"
811 );
812 }
813 let node = format!("/dev/mapper/{name}");
814 if !Path::new(&node).exists() {
815 return Err(SnapshotError::DeviceMapper(format!(
816 "dm device {name} exists but has no node at {node}, and dmsetup mknodes did not \
817 create one; a sandbox would silently copy its whole rootfs instead of using this \
818 overlay"
819 )));
820 }
821 Ok(())
822}
823
824async fn dmsetup_remove(bin: &Path, name: &str) -> Result<()> {
836 let mut cmd = Command::new(bin);
837 cmd.args(["remove", "--retry", name]);
838
839 let output = run_cmd(cmd).await?;
840
841 if !output.status.success() {
842 let stderr = String::from_utf8_lossy(&output.stderr);
843 return Err(SnapshotError::DeviceMapper(format!(
844 "dmsetup remove {name}: {stderr}"
845 )));
846 }
847 Ok(())
848}
849
850async fn create_sparse_file(
852 path: &Path,
853 size: u64,
854) -> std::result::Result<(), (SnapshotError, bool)> {
855 let path = path.to_path_buf();
856 tokio::task::spawn_blocking(move || -> std::result::Result<(), (SnapshotError, bool)> {
857 let file = std::fs::OpenOptions::new()
858 .write(true)
859 .create_new(true)
860 .mode(0o600)
861 .open(&path)
862 .map_err(|e| {
863 (
864 SnapshotError::DeviceMapper(format!("create cow file: {e}")),
865 false,
866 )
867 })?;
868 file.set_permissions(std::fs::Permissions::from_mode(0o600))
869 .map_err(|e| {
870 (
871 SnapshotError::DeviceMapper(format!("secure cow file: {e}")),
872 true,
873 )
874 })?;
875 file.set_len(size).map_err(|e| {
876 (
877 SnapshotError::DeviceMapper(format!("truncate cow file: {e}")),
878 true,
879 )
880 })?;
881 file.sync_all().map_err(|e| {
882 (
883 SnapshotError::DeviceMapper(format!("sync cow file: {e}")),
884 true,
885 )
886 })?;
887 let parent = path.parent().ok_or_else(|| {
888 (
889 SnapshotError::DeviceMapper("cow file has no parent".into()),
890 true,
891 )
892 })?;
893 std::fs::File::open(parent)
894 .and_then(|directory| directory.sync_all())
895 .map_err(|e| {
896 (
897 SnapshotError::DeviceMapper(format!("sync cow directory: {e}")),
898 true,
899 )
900 })?;
901 Ok(())
902 })
903 .await
904 .map_err(|e| {
905 (
906 SnapshotError::DeviceMapper(format!("spawn_blocking join: {e}")),
907 false,
908 )
909 })?
910}
911
912#[cfg(test)]
917mod tests {
918 use std::collections::HashSet;
919
920 use super::*;
921
922 fn manager(cow_dir: &Path) -> CowManager {
925 CowManager {
926 templates: Mutex::new(HashMap::new()),
927 setup_orphans: Mutex::new(HashMap::new()),
928 losetup_lock: AsyncMutex::new(()),
929 cow_dir: cow_dir.to_path_buf(),
930 tools: Arc::new(BusyboxBlockTools::default()),
931 dmsetup_bin: None,
932 test_probe: None,
933 }
934 }
935
936 fn fake_dmsetup(dir: &Path, listed: &[&str]) -> PathBuf {
940 let listing = listed
941 .iter()
942 .enumerate()
943 .map(|(minor, name)| format!("{name}\\t(253, {minor})"))
944 .collect::<Vec<_>>()
945 .join("\\n");
946 let binary = dir.join("dmsetup");
947 crate::test_support::write_script(
948 &binary,
949 &format!(
950 "#!/bin/sh\n\
951 if [ \"$1\" = ls ]; then printf '{listing}\\n'; else echo \"$@\" >> '{}'; fi\n",
952 dir.join("dmsetup.log").display()
953 ),
954 );
955 std::fs::remove_file(dir.join("dmsetup.log")).ok();
959 binary
960 }
961
962 fn handle_for(dm_device: &Path, template: &Path) -> CowHandle {
963 CowHandle {
964 dm_name: "arcbox-snap-box".into(),
965 dm_device: dm_device.to_string_lossy().into_owned(),
966 cow_loop: "/dev/loop9".into(),
967 cow_file: PathBuf::from("/nonexistent/arcbox-cow-box.img"),
968 template_path: template.to_path_buf(),
969 }
970 }
971
972 #[test]
973 fn test_dm_name_format() {
974 let name = format!("{DM_NAME_PREFIX}test-sandbox-123");
975 assert_eq!(name, "arcbox-snap-test-sandbox-123");
976 }
977
978 #[test]
979 fn validate_dm_name_suffix_accepts_uuid_and_basic_ids() {
980 validate_dm_name_suffix("550e8400-e29b-41d4-a716-446655440000").unwrap();
981 validate_dm_name_suffix("sandbox_1").unwrap();
982 validate_dm_name_suffix("a.b+c-d").unwrap();
983 }
984
985 #[test]
986 fn validate_dm_name_suffix_rejects_invalid_chars() {
987 assert!(validate_dm_name_suffix("").is_err());
988 assert!(validate_dm_name_suffix("has space").is_err());
989 assert!(validate_dm_name_suffix("with/slash").is_err());
990 assert!(validate_dm_name_suffix("with:colon").is_err());
991 assert!(validate_dm_name_suffix(&"x".repeat(DM_NAME_MAX_LEN)).is_err());
992 }
993
994 #[test]
995 fn test_cow_file_path() {
996 let cow_dir = PathBuf::from("/var/lib/firecracker-vmm/cow");
997 let path = cow_dir.join(format!("arcbox-cow-{}.img", "sandbox-1"));
998 assert_eq!(
999 path,
1000 PathBuf::from("/var/lib/firecracker-vmm/cow/arcbox-cow-sandbox-1.img")
1001 );
1002 }
1003
1004 #[test]
1005 fn test_snapshot_table_format() {
1006 let sectors = 2097152_u64; let table =
1008 format!("0 {sectors} snapshot /dev/loop0 /dev/loop1 P {SNAPSHOT_CHUNK_SECTORS}");
1009 assert_eq!(table, "0 2097152 snapshot /dev/loop0 /dev/loop1 P 8");
1010 }
1011
1012 #[test]
1013 fn template_marker_round_trip() {
1014 let tmp = tempfile::tempdir().unwrap();
1015 let mgr = manager(tmp.path());
1016
1017 let template = PathBuf::from("/var/lib/arcbox/rootfs.ext4");
1018 mgr.write_template_marker("/dev/loop7", &template).unwrap();
1019 let marker = mgr.template_marker_path("/dev/loop7").unwrap();
1020 assert_eq!(marker.file_name().unwrap(), "loop7");
1021 assert!(marker.exists());
1022 let content = std::fs::read_to_string(&marker).unwrap();
1023 assert_eq!(content, template.to_string_lossy());
1024
1025 let pending = mgr.write_template_pending("box", &template).unwrap();
1026 assert!(pending.exists());
1027 clear_owner_marker(&pending).unwrap();
1028 assert!(!pending.exists());
1029 }
1030
1031 #[test]
1032 fn stale_marker_temp_is_discarded() {
1033 let tmp = tempfile::tempdir().unwrap();
1034 let marker_dir = tmp.path().join(TEMPLATE_LOOP_DIR);
1035 std::fs::create_dir_all(&marker_dir).unwrap();
1036 let temporary = marker_dir.join(format!("{TEMPLATE_MARKER_TEMP_PREFIX}loop7"));
1037 std::fs::write(&temporary, b"/partial").unwrap();
1038 let mgr = manager(tmp.path());
1039
1040 mgr.cleanup_stale_template_markers().unwrap();
1041
1042 assert!(!temporary.exists());
1043 }
1044
1045 #[tokio::test]
1046 async fn sparse_cow_creation_never_overwrites_an_owned_file() {
1047 let tmp = tempfile::tempdir().unwrap();
1048 let path = tmp.path().join("cow.img");
1049 create_sparse_file(&path, 4096).await.unwrap();
1050
1051 let (_, owns_file) = create_sparse_file(&path, 8192).await.unwrap_err();
1052 assert!(!owns_file);
1053 assert_eq!(std::fs::metadata(path).unwrap().len(), 4096);
1054 }
1055
1056 #[tokio::test]
1057 async fn rollback_never_removes_an_unowned_cow_file() {
1058 let tmp = tempfile::tempdir().unwrap();
1059 let cow_file = tmp.path().join("existing.img");
1060 std::fs::write(&cow_file, b"owned by another setup").unwrap();
1061 let mgr = manager(tmp.path());
1062
1063 let _ = mgr
1064 .rollback_setup(
1065 "box",
1066 Path::new("/template"),
1067 None,
1068 None,
1069 None,
1070 SnapshotError::DeviceMapper("create failed".into()),
1071 )
1072 .await;
1073
1074 assert_eq!(std::fs::read(&cow_file).unwrap(), b"owned by another setup");
1075 }
1076
1077 #[tokio::test]
1078 async fn zero_ref_template_is_not_reused() {
1079 let tmp = tempfile::tempdir().unwrap();
1080 let template = PathBuf::from("/template");
1081 let mut mgr = manager(tmp.path());
1082 mgr.dmsetup_bin = Some("/not-used".into());
1083 mgr.templates.lock().unwrap().insert(
1084 template.clone(),
1085 TemplateEntry {
1086 loop_device: "/dev/loop7".into(),
1087 sectors: 1024,
1088 refcount: 0,
1089 },
1090 );
1091
1092 assert!(matches!(
1093 mgr.setup("box", template.to_str().unwrap()).await,
1094 Err(SnapshotError::Unavailable(_))
1095 ));
1096 assert_eq!(
1097 mgr.templates
1098 .lock()
1099 .unwrap()
1100 .get(&template)
1101 .unwrap()
1102 .refcount,
1103 0
1104 );
1105 }
1106
1107 #[test]
1112 fn a_live_dm_device_is_not_removed() {
1113 let tmp = tempfile::tempdir().unwrap();
1114 let mut mgr = manager(tmp.path());
1115 mgr.dmsetup_bin = Some(fake_dmsetup(
1116 tmp.path(),
1117 &["arcbox-snap-live", "arcbox-snap-dead", "foreign-snapshot"],
1118 ));
1119
1120 mgr.reconcile_stale(&HashSet::new(), &HashSet::from(["live".to_owned()]))
1121 .unwrap();
1122
1123 let removed = std::fs::read_to_string(tmp.path().join("dmsetup.log")).unwrap();
1124 assert_eq!(
1125 removed.lines().collect::<Vec<_>>(),
1126 ["remove --retry arcbox-snap-dead"],
1127 "the sweep removed the wrong devices"
1128 );
1129 }
1130
1131 #[test]
1135 fn a_live_sandbox_cow_file_is_kept() {
1136 let tmp = tempfile::tempdir().unwrap();
1137 let overlay = tmp.path().join("arcbox-cow-live.img");
1138 std::fs::write(&overlay, b"exceptions").unwrap();
1139 let mgr = manager(tmp.path());
1140
1141 mgr.reconcile_stale(&HashSet::new(), &HashSet::from(["live".to_owned()]))
1142 .unwrap();
1143
1144 assert!(overlay.exists(), "a live sandbox's overlay was deleted");
1145 }
1146
1147 #[test]
1152 fn a_live_template_loop_survives_the_sweep() {
1153 let tmp = tempfile::tempdir().unwrap();
1154 let marker_dir = tmp.path().join(TEMPLATE_LOOP_DIR);
1155 std::fs::create_dir_all(&marker_dir).unwrap();
1156 let live = tmp.path().join("live.ext4");
1157 let stale = tmp.path().join("stale.ext4");
1158 std::fs::write(marker_dir.join("loop7"), live.to_string_lossy().as_bytes()).unwrap();
1159 std::fs::write(marker_dir.join("loop8"), stale.to_string_lossy().as_bytes()).unwrap();
1160 let mgr = manager(tmp.path());
1161 mgr.templates.lock().unwrap().insert(
1162 live,
1163 TemplateEntry {
1164 loop_device: "/dev/loop7".into(),
1165 sectors: 1024,
1166 refcount: 1,
1167 },
1168 );
1169
1170 mgr.reconcile_stale(&HashSet::new(), &HashSet::new())
1171 .unwrap();
1172
1173 assert!(
1174 marker_dir.join("loop7").exists(),
1175 "the live template's recovery marker was cleared"
1176 );
1177 assert!(!marker_dir.join("loop8").exists());
1178 }
1179
1180 #[test]
1183 fn adopt_refuses_a_missing_dm_device() {
1184 let tmp = tempfile::tempdir().unwrap();
1185 let mgr = manager(tmp.path());
1186 let handle = handle_for(
1187 &tmp.path().join("arcbox-snap-box"),
1188 &tmp.path().join("t.ext4"),
1189 );
1190
1191 let error = mgr.adopt(&handle).unwrap_err();
1192
1193 assert!(
1194 matches!(&error, SnapshotError::FailedPrecondition(m) if m.contains("arcbox-snap-box")),
1195 "{error}"
1196 );
1197 assert!(mgr.templates.lock().unwrap().is_empty());
1198 }
1199
1200 #[cfg(target_os = "linux")]
1205 #[test]
1206 fn adopt_refuses_a_template_no_loop_device_backs() {
1207 let tmp = tempfile::tempdir().unwrap();
1208 let dm_device = tmp.path().join("arcbox-snap-box");
1211 std::fs::write(&dm_device, b"").unwrap();
1212 let template = tmp.path().join("t.ext4");
1213 std::fs::write(&template, b"rootfs").unwrap();
1214 let mgr = manager(tmp.path());
1215
1216 let error = mgr.adopt(&handle_for(&dm_device, &template)).unwrap_err();
1217
1218 assert!(
1219 matches!(&error, SnapshotError::FailedPrecondition(m) if m.contains("no loop device backs")),
1220 "{error}"
1221 );
1222 assert!(
1223 mgr.templates.lock().unwrap().is_empty(),
1224 "a refused adoption took a template reference"
1225 );
1226 }
1227
1228 #[test]
1234 fn a_template_reached_through_a_symlink_is_matched_by_its_resolved_path() {
1235 let tmp = tempfile::tempdir().unwrap();
1236 let templates = tmp.path().join("templates");
1237 std::fs::create_dir(&templates).unwrap();
1238 std::fs::write(templates.join("rootfs.ext4"), b"rootfs").unwrap();
1239 let linked = tmp.path().join("data");
1240 std::os::unix::fs::symlink(&templates, &linked).unwrap();
1241 let configured = linked.join("rootfs.ext4");
1242
1243 let spellings = persistence::backing_spellings(&configured);
1244
1245 let resolved = std::fs::canonicalize(&configured).unwrap();
1246 assert_ne!(
1247 configured, resolved,
1248 "the symlinked spelling has to differ, or this asserts nothing"
1249 );
1250 assert!(
1251 spellings.contains(&configured.to_string_lossy().into_owned()),
1252 "{spellings:?}"
1253 );
1254 assert!(
1255 spellings.contains(&resolved.to_string_lossy().into_owned()),
1256 "{spellings:?}"
1257 );
1258 }
1259
1260 #[test]
1264 fn adopting_a_shared_template_registers_then_references() {
1265 let mgr = manager(Path::new("/tmp"));
1266 let template = Path::new("/templates/rootfs.ext4");
1267
1268 mgr.take_template_ref(template, "/dev/loop7", 2048).unwrap();
1269 mgr.take_template_ref(template, "/dev/loop7", 2048).unwrap();
1270
1271 let templates = mgr.templates.lock().unwrap();
1272 let entry = templates.get(template).unwrap();
1273 assert_eq!(entry.refcount, 2);
1274 assert_eq!(entry.loop_device, "/dev/loop7");
1275 assert_eq!(entry.sectors, 2048);
1276 }
1277
1278 #[test]
1284 fn adopting_refuses_a_zero_ref_or_relocated_template() {
1285 let mgr = manager(Path::new("/tmp"));
1286 let template = Path::new("/templates/rootfs.ext4");
1287 mgr.templates.lock().unwrap().insert(
1288 template.to_path_buf(),
1289 TemplateEntry {
1290 loop_device: "/dev/loop7".into(),
1291 sectors: 2048,
1292 refcount: 0,
1293 },
1294 );
1295
1296 let incomplete = mgr
1297 .take_template_ref(template, "/dev/loop7", 2048)
1298 .unwrap_err();
1299 assert!(
1300 matches!(&incomplete, SnapshotError::Unavailable(m) if m.contains("incomplete CoW setup")),
1301 "{incomplete}"
1302 );
1303
1304 mgr.templates
1305 .lock()
1306 .unwrap()
1307 .get_mut(template)
1308 .unwrap()
1309 .refcount = 1;
1310 let relocated = mgr
1311 .take_template_ref(template, "/dev/loop9", 2048)
1312 .unwrap_err();
1313 assert!(
1314 matches!(&relocated, SnapshotError::FailedPrecondition(m) if m.contains("/dev/loop9")),
1315 "{relocated}"
1316 );
1317 assert_eq!(
1318 mgr.templates
1319 .lock()
1320 .unwrap()
1321 .get(template)
1322 .unwrap()
1323 .refcount,
1324 1,
1325 "a refused adoption took a template reference"
1326 );
1327 }
1328
1329 #[tokio::test]
1330 async fn test_release_template_refcount() {
1331 let mgr = manager(Path::new("/tmp"));
1332
1333 let path = PathBuf::from("/tmp/template.ext4");
1334 {
1335 let mut t = mgr.templates.lock().unwrap();
1336 t.insert(
1337 path.clone(),
1338 TemplateEntry {
1339 loop_device: "/dev/loop99".into(),
1340 sectors: 1024,
1341 refcount: 2,
1342 },
1343 );
1344 }
1345
1346 mgr.release_template_ref(&path, true).await.unwrap();
1348 {
1349 let t = mgr.templates.lock().unwrap();
1350 assert_eq!(t.get(&path).unwrap().refcount, 1);
1351 }
1352 }
1353}