Skip to main content

arcbox_snapshot/
snapshot_cow.rs

1//! Block-level copy-on-write for sandbox rootfs via dm-snapshot.
2//!
3//! Instead of copying the full rootfs ext4 image for every sandbox,
4//! `CowManager` creates a dm-snapshot backed by a sparse COW file.
5//! The template image is shared read-only across all sandboxes that
6//! use the same rootfs; only written blocks consume disk space.
7//!
8//! Requires `CONFIG_DM_SNAPSHOT=y` in the kernel, a `dmsetup` binary at one
9//! of [`CowOptions::dmsetup_candidates`], and loop-device tooling supplied
10//! through the [`BlockTools`] seam ([`BusyboxBlockTools`] is the reference:
11//! the System VM's busybox applets; [`UtilLinuxBlockTools`] is the stock
12//! distro's `/sbin` userland). `PATH` is never searched — neither the
13//! guest nor a node agent has a meaningful one, so both implementations
14//! take absolute paths.
15
16use 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
40// ---------------------------------------------------------------------------
41// Constants
42// ---------------------------------------------------------------------------
43
44/// Reference search list for the `dmsetup` binary.  The first existing,
45/// working entry wins.  `/usr/sbin/dmsetup` covers usrmerged distros,
46/// `/sbin/dmsetup` stock Debian/Alpine.  A composer with a bundled copy
47/// (the System VM ships one under `/arcbox/bin`) lists it first through
48/// [`CowOptions::dmsetup_candidates`].
49const DMSETUP_CANDIDATES: &[&str] = &["/usr/sbin/dmsetup", "/sbin/dmsetup"];
50
51/// dm-snapshot chunk size in 512-byte sectors (4096 bytes = 8 sectors).
52const SNAPSHOT_CHUNK_SECTORS: u64 = 8;
53
54/// Device-mapper name prefix for sandbox snapshots.
55///
56/// Public with [`COW_FILE_PREFIX`] and [`COW_FILE_SUFFIX`] because the
57/// convention has an inverse: [`CowManager::reconcile_stale`] reads an
58/// owner back out of a name on the host, and a composer holding a durable
59/// record it cannot otherwise interpret does the same to decide what that
60/// record still names. One definition, so the two directions cannot drift.
61pub const DM_NAME_PREFIX: &str = "arcbox-snap-";
62
63/// File-name prefix of a sandbox's copy-on-write overlay, under `cow_dir`.
64pub const COW_FILE_PREFIX: &str = "arcbox-cow-";
65
66/// File-name suffix of a sandbox's copy-on-write overlay. Optional when
67/// reading one back: `reconcile_stale` accepts a name without it.
68pub const COW_FILE_SUFFIX: &str = ".img";
69
70/// Maximum length of a device-mapper name (DM_NAME_LEN - 1, from linux/dm-ioctl.h).
71const DM_NAME_MAX_LEN: usize = 127;
72
73/// Subdirectory of `cow_dir` holding template-loop marker files for
74/// crash recovery.  Each file is named after the loop's basename (e.g.
75/// `loop0`) and contains the absolute path of the backing template; on
76/// startup we use these to identify *our* attached read-only loops
77/// rather than every read-only loop on the system.
78const TEMPLATE_LOOP_DIR: &str = ".template-loops";
79const TEMPLATE_PENDING_PREFIX: &str = "pending-";
80const TEMPLATE_MARKER_TEMP_PREFIX: &str = ".tmp-";
81
82/// Whether device-mapper holds a device by this name.
83///
84/// Asked of the kernel rather than of `/dev/mapper`, because that directory
85/// is only as good as the udev rules that populate it: a teardown reading a
86/// missing symlink as a missing device skips the removal and reports success,
87/// leaving the device and everything under it attached for good.
88fn dm_present(dm_name: &str) -> bool {
89    Path::new(&format!("/dev/mapper/{dm_name}")).exists() || devtmpfs_dm_node(dm_name).is_some()
90}
91
92/// The kernel's own node for `dm_name`, found by matching `dm/name` in sysfs.
93fn 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
105/// Validate that `sandbox_id` can be used as the suffix of a dm-name.
106///
107/// Device-mapper allows `[A-Za-z0-9_+.-]` (see kernel `validate_name`).
108/// Sandboxes are most commonly UUIDs, which already pass; this rejects
109/// caller-supplied IDs containing whitespace, `/`, `:`, etc., before
110/// `dmsetup create` errors out with a confusing message.
111fn 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
132// ---------------------------------------------------------------------------
133// Types
134// ---------------------------------------------------------------------------
135
136/// Tracks a read-only loop device for a template rootfs image.
137struct TemplateEntry {
138    /// Loop device path, e.g. `/dev/loop0`.
139    loop_device: String,
140    /// Template size in 512-byte sectors.
141    sectors: u64,
142    /// Number of active sandboxes using this template.
143    refcount: usize,
144}
145
146/// Per-sandbox CoW state.  Stored in `SandboxInstance` for cleanup.
147///
148/// Intentionally `!Clone`: every handle owns a refcount on its template,
149/// and a stray clone would double-decrement on teardown.
150#[derive(Debug)]
151pub struct CowHandle {
152    /// dm device name, e.g. `arcbox-snap-<sandbox_id>`.
153    pub dm_name: String,
154    /// Absolute device path, e.g. `/dev/mapper/arcbox-snap-<sandbox_id>`.
155    pub dm_device: String,
156    /// Loop device backing the sparse COW file.
157    pub cow_loop: String,
158    /// Path to the sparse COW file on disk.
159    pub cow_file: PathBuf,
160    /// Original template rootfs path (used to release the template refcount).
161    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
198/// What a [`CowManager`] needs from its environment.
199///
200/// [`CowOptions::new`] fills in the reference environment — the System
201/// VM's userland — so today's callers pass a data directory and nothing
202/// else; a composer on a different userland overrides the fields it owns.
203pub struct CowOptions {
204    /// Data directory; COW files live under `{data_dir}/cow/`.
205    pub data_dir: PathBuf,
206    /// Loop-device and block-size operations.
207    pub block_tools: Arc<dyn BlockTools>,
208    /// Where to look for `dmsetup`. The first entry that exists and answers
209    /// `dmsetup version` is used; none usable degrades the manager to
210    /// copy-mode fallback (every setup fails with an actionable error).
211    pub dmsetup_candidates: Vec<PathBuf>,
212}
213
214impl CowOptions {
215    /// The reference environment: busybox at [`BusyboxBlockTools::DEFAULT_PATH`]
216    /// and the stock-distro `dmsetup` search list.
217    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
226/// Manages template loop devices and per-sandbox dm-snapshot lifecycle.
227pub struct CowManager {
228    templates: Mutex<HashMap<PathBuf, TemplateEntry>>,
229    setup_orphans: Mutex<HashMap<String, SetupOrphan>>,
230    /// Serializes the cache-miss attach+insert window so two concurrent
231    /// first-time setups for the same template converge on a single
232    /// `TemplateEntry` instead of each attaching its own loop device and
233    /// leaking the loser.  (The other race — a free loop device claimed by
234    /// another process between query and attach — is [`BlockTools`]'s to
235    /// handle; `BusyboxBlockTools` re-queries and retries, since busybox
236    /// has no atomic `losetup -f --show`.)
237    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
245// ---------------------------------------------------------------------------
246// CowManager
247// ---------------------------------------------------------------------------
248
249impl CowManager {
250    /// Create a new manager over `options`; COW files are stored under
251    /// `{data_dir}/cow/`.
252    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        // Existence is not usability: driving device-mapper needs
269        // /dev/mapper/control, i.e. root inside the VM. A process that cannot
270        // talk to the driver (unprivileged dev host, CI runner) can never have
271        // created dm snapshots either, so it degrades to the same copy-mode
272        // fallback as a missing binary instead of failing every dm command.
273        // Every candidate gets the same test: an existing-but-broken entry
274        // earlier in the list must not shadow a working one later.
275        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    /// [`BlockTools::attach_loop`] on a blocking thread.
307    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    /// [`BlockTools::detach_loop`] on a blocking thread.
314    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    /// [`BlockTools::device_sectors`] on a blocking thread.
321    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    /// Create a dm-snapshot for `sandbox_id` using `rootfs_path` as template.
328    ///
329    /// Returns a [`CowHandle`] whose `dm_device` field can be passed to
330    /// Firecracker as the rootfs block device.
331    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    /// Re-assemble the dm-snapshot for a paused sandbox from its preserved
336    /// COW file (CORE-21 resume).
337    ///
338    /// The snapshot table uses persistent mode (`P`), so the exception store
339    /// in the retained overlay carries every block the sandbox wrote before
340    /// it was paused. Unlike [`Self::setup`], the COW file must already
341    /// exist and match the template size, and no failure path deletes it —
342    /// the overlay is the sandbox's disk.
343    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        // --- 1. Acquire template loop device (shared, refcounted) -----------
372        //
373        // Fast path: cache hit under the sync mutex, no I/O needed.
374        // Slow path: hold the async losetup_lock across attach+insert so a
375        // concurrent first-time setup of the same template cannot race ahead,
376        // attach a second loop device, and leak the loser's entry on insert.
377        let (template_loop, sectors) = 'acquire: {
378            // Fast path under the sync mutex.
379            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            // Slow path: serialize attach+insert across all concurrent
403            // first-time callers for this template.
404            let _losetup_guard = self.losetup_lock.lock().await;
405
406            // Re-check the cache: another caller may have populated it while
407            // we were waiting for the lock.
408            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            // Genuinely first to attach — do the work and publish the entry
432            // before releasing the lock.
433            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            // Persist ownership before publishing the loop. If the marker
443            // cannot be made durable, detach immediately rather than creating
444            // a resource that restart reconciliation cannot identify.
445            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        // --- 2. Create (or, on reattach, verify) the sparse COW file --------
502        //
503        // On reattach the overlay is the sandbox's retained disk, so every
504        // rollback below passes `cow_file: None` — a failed re-assembly must
505        // never delete it.
506        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        // --- 3. Attach COW file as a loop device ----------------------------
550        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        // --- 4. Create dm-snapshot device -----------------------------------
566        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    /// Tear down a dm-snapshot, logging any incomplete cleanup.
601    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    /// Tear down a dm-snapshot and report whether every owned resource was
608    /// released. Crash reconciliation and durable Remove use this result to
609    /// avoid declaring cleanup complete when a retry is still required.
610    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        // 1. Remove dm device.
623        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        // 2. Detach COW loop device.
636        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        // 3. Delete COW sparse file only after both dm and loop are released.
667        //    Unlinking while still referenced would delay space reclamation
668        //    until the last kernel reference drops.
669        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            // Release the shared template exactly once, after every per-sandbox
677            // resource is gone. A failed teardown keeps the handle for retry.
678            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    /// Tear down the dm device and COW loop but keep the COW file on disk.
688    ///
689    /// This is the pause half of CORE-21: the persistent (`P`) exception
690    /// store in the retained file preserves every written block, and
691    /// [`Self::reattach`] re-assembles the identical device on resume. A
692    /// failed step keeps the handle valid for retry, exactly like
693    /// [`Self::teardown_checked`].
694    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    /// Delete the COW file a pause left behind (sandbox Remove / TTL).
729    ///
730    /// Idempotent: a missing file is success. Any loop still backing the
731    /// file is detached first so the unlink actually frees the space.
732    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
747// ---------------------------------------------------------------------------
748// Shell helpers
749// ---------------------------------------------------------------------------
750
751/// Run a blocking block-tools operation on a blocking thread.
752async 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
760/// Run a synchronous [`Command`] on a blocking thread.
761///
762/// `tokio::process::Command` conflicts with the PID-1 SIGCHLD reaper
763/// (`spawn_reaper`), causing `ECHILD` errors.  Using `std::process::Command`
764/// inside `spawn_blocking` avoids this because `waitpid` is called
765/// synchronously before the signal can be stolen.
766async 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
773/// Create a dm-snapshot device via `dmsetup create`.
774///
775/// Followed by `dmsetup mknodes`, which is what makes `/dev/mapper/<name>`
776/// exist on a host running no udevd. Whether `dmsetup create` creates that
777/// node itself depends on how the binary was built: the System VM's makes it
778/// directly, while a distro's is built against libudev and defers to rules
779/// that never run when there is no daemon — leaving the device live, listed
780/// by `dmsetup ls`, and reachable only through the kernel's own `/dev/dm-N`.
781/// Everything downstream names the device by its `/dev/mapper` path, so it
782/// has to be there: without it the guest's disk is staged as a full rootfs
783/// copy instead of an overlay, and teardown reads a live device as gone.
784///
785/// A no-op where udev already made the node. If the node is still missing
786/// afterwards this fails rather than returning a handle whose device path
787/// resolves to nothing: the caller's next step stats that path, reads ENOENT
788/// as "no dm-snapshot here", and stages a full copy of the rootfs instead —
789/// so a setup that returned `Ok` would have quietly cost a Computer 32 GB.
790async 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
824/// Remove a dm device via `dmsetup remove --retry`.
825///
826/// The caller has already reaped the VM process, so its opener is gone, but
827/// on a host with udev that very close is what wakes the next one: the dm
828/// udev rules put an inotify `watch` on every dm node, a close-after-write
829/// re-triggers the blkid probe, and the probe holds the device open for a
830/// moment — right when the remove ioctl runs. `--retry` repeats the remove
831/// on `EBUSY` for a few seconds instead of failing on the first, which is
832/// libdevmapper's own answer to that race; a device that is genuinely still
833/// in use fails as before, only later. (Reconciliation at startup uses the
834/// same flag: `persistence.rs`.)
835async 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
850/// Create a sparse file of the given size in bytes.
851async 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// ---------------------------------------------------------------------------
913// Tests
914// ---------------------------------------------------------------------------
915
916#[cfg(test)]
917mod tests {
918    use std::collections::HashSet;
919
920    use super::*;
921
922    /// A manager over `cow_dir` with nothing attached and no `dmsetup`;
923    /// tests that need one adjust the field they are about.
924    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    /// A `dmsetup` that lists `listed` as snapshot devices and appends every
937    /// other invocation to `{dir}/dmsetup.log`, so a test can assert which
938    /// devices the sweep decided to remove without a device-mapper.
939    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        // `write_script` execs the stand-in once to prove the kernel will
956        // let it run, and that argument-less run appends to the log the
957        // tests read back. Start them from an empty record.
958        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; // 1 GiB
1007        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    /// The sweep must leave an adopted sandbox's device alone: a live VM
1108    /// holds it open, so removing it fails — and `reconcile_stale` reports
1109    /// that failure for the whole sweep, taking every other sandbox's
1110    /// reconciliation down with it.
1111    #[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    /// A live device's overlay is the disk it is running on, so the caller
1132    /// naming it as live is enough — it does not also have to remember to
1133    /// list it among the retained files.
1134    #[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    /// The template map is the authority on which loops are in use, so a
1148    /// marker whose template is live survives — detaching that loop would
1149    /// pull the rootfs out from under the guest running on it, and the
1150    /// marker is the loop's own recovery record.
1151    #[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    /// Nothing to re-register, whatever the journal claims — and the caller
1181    /// has to hear about it, because its fallback is to kill the sandbox.
1182    #[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    /// The loop is the kernel's, so only the kernel can say whether it still
1201    /// backs the recorded template; nothing backing it means the origin of
1202    /// that snapshot cannot be identified, let alone held. Linux-only: off
1203    /// it there is no `/sys/block` to answer either way.
1204    #[cfg(target_os = "linux")]
1205    #[test]
1206    fn adopt_refuses_a_template_no_loop_device_backs() {
1207        let tmp = tempfile::tempdir().unwrap();
1208        // Stands in for the live device node: `adopt` asks only whether the
1209        // guest's device is still there.
1210        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    /// The kernel answers with the path it resolved, so rediscovering a loop
1229    /// by the spelling the caller configured only works when that spelling
1230    /// is already canonical. A data directory reached through a symlink is
1231    /// not an exotic deployment, and there it would cost a live sandbox its
1232    /// adoption and hide this manager's own loops from teardown.
1233    #[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    /// What makes the eventual teardown balance: the first adopted sandbox
1261    /// registers the template it found, and every later one holding the same
1262    /// template adds a reference rather than a second registration.
1263    #[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    /// Two refusals with the same consequence — the caller kills the sandbox
1279    /// rather than adopt onto a template this manager cannot account for: an
1280    /// entry left at refcount zero is the incomplete-setup sentinel the
1281    /// acquire path also refuses on, and an entry naming a different loop
1282    /// means one of the two is a leak whichever way it is resolved.
1283    #[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        // First release: refcount 2 → 1, entry stays.
1347        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}