Skip to main content

arcbox_snapshot/snapshot_cow/
persistence.rs

1use std::collections::HashSet;
2use std::collections::hash_map::Entry;
3use std::io::Write;
4use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use tracing::{debug, info, warn};
9
10use super::block_tools::loop_backing_file;
11use super::{
12    COW_FILE_PREFIX, COW_FILE_SUFFIX, CowHandle, CowManager, DM_NAME_PREFIX, TEMPLATE_LOOP_DIR,
13    TEMPLATE_MARKER_TEMP_PREFIX, TEMPLATE_PENDING_PREFIX, TemplateEntry, dmsetup_remove,
14};
15use crate::error::{Result, SnapshotError};
16
17#[derive(Clone, Default)]
18pub(super) struct SetupOrphan {
19    pending_template_marker: Option<PathBuf>,
20    template_loop: Option<(String, PathBuf)>,
21    template_path: Option<PathBuf>,
22    dm_name: Option<String>,
23    cow_loop: Option<String>,
24    cow_file: Option<PathBuf>,
25}
26
27impl SetupOrphan {
28    fn is_empty(&self) -> bool {
29        self.pending_template_marker.is_none()
30            && self.template_loop.is_none()
31            && self.template_path.is_none()
32            && self.dm_name.is_none()
33            && self.cow_loop.is_none()
34            && self.cow_file.is_none()
35    }
36}
37
38impl CowManager {
39    /// Re-register the CoW resources of a sandbox whose VM is still running.
40    ///
41    /// Verifies the dm device is present and that the template's loop device
42    /// is still attached and still backs `handle.template_path`, then takes
43    /// the template refcount the handle is documented to own — which is what
44    /// keeps [`Self::reconcile_stale`] from detaching a shared loop out from
45    /// under a live rootfs, and what makes the eventual teardown balance.
46    ///
47    /// Call before [`Self::reconcile_stale`], once per adopted sandbox. A
48    /// failed verification is an error rather than a silent skip: the
49    /// caller's fallback is to tear the sandbox down, and it has to know.
50    pub fn adopt(&self, handle: &CowHandle) -> Result<()> {
51        #[cfg(any(test, feature = "test-probe"))]
52        if self.test_probe.is_some() {
53            // The probe stands in for every device this method inspects.
54            return Ok(());
55        }
56        // The device the guest is running on. Without it there is no
57        // snapshot to re-register, whatever the journal claims.
58        if !Path::new(&handle.dm_device).exists() {
59            return Err(SnapshotError::FailedPrecondition(format!(
60                "sandbox device {} is gone",
61                handle.dm_device
62            )));
63        }
64        // That snapshot's origin. Rediscovered rather than journaled: the
65        // loop belongs to the kernel, and only the kernel can say whether it
66        // still backs the template this handle names.
67        let loop_device = match loop_devices_for_backing_sync(&handle.template_path)?.as_slice() {
68            [device] => device.clone(),
69            [] => {
70                return Err(SnapshotError::FailedPrecondition(format!(
71                    "no loop device backs template {}",
72                    handle.template_path.display()
73                )));
74            }
75            // Which of them the live snapshot's origin is cannot be told from
76            // the backing file alone, and holding the refcount on the wrong
77            // one leaks the other at teardown.
78            devices => {
79                return Err(SnapshotError::FailedPrecondition(format!(
80                    "template {} is attached through {}",
81                    handle.template_path.display(),
82                    devices.join(", ")
83                )));
84            }
85        };
86        // Read before the map is locked, and unconditionally: the template
87        // map must never be held across a block-tools subprocess, and one
88        // extra `blockdev` per adopted sandbox is invisible next to the rest
89        // of a startup sweep.
90        let sectors = self.tools.device_sectors(&loop_device)?;
91        self.take_template_ref(&handle.template_path, &loop_device, sectors)?;
92        info!(
93            dm = %handle.dm_name,
94            template = %handle.template_path.display(),
95            loop_device = %loop_device,
96            "adopted a live sandbox's dm-snapshot"
97        );
98        Ok(())
99    }
100
101    /// Register `template` as held through `loop_device`, or add a reference
102    /// to the registration another holder already made.
103    ///
104    /// The bookkeeping half of [`Self::adopt`], with the device checks
105    /// already done: the reference the adopted handle's teardown decrements.
106    pub(super) fn take_template_ref(
107        &self,
108        template: &Path,
109        loop_device: &str,
110        sectors: u64,
111    ) -> Result<()> {
112        match self.templates.lock().unwrap().entry(template.to_path_buf()) {
113            Entry::Occupied(mut occupied) => {
114                let entry = occupied.get_mut();
115                // The same "incomplete setup" sentinel the acquire path
116                // refuses on: a template whose detach failed with no owner
117                // left to retry it must not be resurrected by an adoption.
118                if entry.refcount == 0 {
119                    return Err(SnapshotError::Unavailable(format!(
120                        "template {} still owns resources from an incomplete CoW setup",
121                        template.display()
122                    )));
123                }
124                // One template, one loop: a second one is a view of the
125                // world this manager cannot reconcile, and picking either
126                // would leak the other.
127                if entry.loop_device != loop_device {
128                    return Err(SnapshotError::FailedPrecondition(format!(
129                        "template {} is held through {} but backs {loop_device}",
130                        template.display(),
131                        entry.loop_device
132                    )));
133                }
134                entry.refcount += 1;
135            }
136            Entry::Vacant(vacant) => {
137                vacant.insert(TemplateEntry {
138                    loop_device: loop_device.to_owned(),
139                    sectors,
140                    refcount: 1,
141                });
142            }
143        }
144        Ok(())
145    }
146
147    /// Remove orphaned dm-snapshot devices, COW files, and template loop
148    /// devices left over from a previous crash. Called after orphaned
149    /// Firecracker processes are dead — every one of them except the
150    /// sandboxes named by `live_devices`, whose VMs are still running and
151    /// were reclaimed by [`Self::adopt`]. It is synchronous because every
152    /// command is short and startup is already gated on reconciliation.
153    ///
154    /// Both sets are keyed by the *resource owner* — the id in
155    /// `arcbox-cow-{owner}.img` and `arcbox-snap-{owner}` — which for a
156    /// sandbox that adopted a pre-warmed pool slot is the slot's id, not the
157    /// sandbox's.
158    ///
159    /// `keep_cow_files` names owners whose COW file is *retained state*, not
160    /// an orphan: a paused sandbox keeps its (detached) overlay on disk so
161    /// Resume can re-assemble the dm-snapshot with every written block
162    /// intact (CORE-21). Deleting those files here would silently destroy a
163    /// paused sandbox's disk across an agent restart.
164    ///
165    /// `live_devices` names owners whose dm-snapshot is in use by a running
166    /// VM. The two sets stay separate because they describe genuinely
167    /// different states: a paused sandbox's device was already torn down at
168    /// pause, so reading its retained file as a live device would leave the
169    /// device of a sandbox that crashed mid-pause unreaped. The other
170    /// direction does hold — a live device's overlay is its running disk —
171    /// so those files are kept whether or not the caller lists them twice.
172    pub fn reconcile_stale(
173        &self,
174        keep_cow_files: &HashSet<String>,
175        live_devices: &HashSet<String>,
176    ) -> Result<()> {
177        let dmsetup = self.dmsetup_bin.as_deref();
178
179        // 1. Remove stale dm devices first — they pin the loop devices
180        //    underneath, so the loop detach below would fail otherwise.
181        if let Some(dmsetup) = dmsetup {
182            let output =
183                run_sync_checked(Command::new(dmsetup).args(["ls", "--target", "snapshot"]))?;
184            let stdout = String::from_utf8_lossy(&output.stdout);
185            for line in stdout.lines() {
186                if let Some(name) = line.split_whitespace().next()
187                    && let Some(owner) = name.strip_prefix(DM_NAME_PREFIX)
188                {
189                    // A live VM holds its device open, so removing it would
190                    // fail — and fail the whole sweep, taking every other
191                    // sandbox's reconciliation with it.
192                    if live_devices.contains(owner) {
193                        debug!(dm = %name, "keeping live sandbox dm-snapshot");
194                        continue;
195                    }
196                    debug!(dm = %name, "removing stale dm-snapshot");
197                    // `--retry` for the same udev probe race `dmsetup_remove` documents.
198                    run_sync_checked(Command::new(dmsetup).args(["remove", "--retry", name]))?;
199                }
200            }
201        }
202
203        // 2. Detach loops backing stale COW files, then unlink the files.
204        //    Overlays retained by a paused sandbox are skipped wholesale.
205        for entry in std::fs::read_dir(&self.cow_dir)? {
206            let entry = entry?;
207            let path = entry.path();
208            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
209                continue;
210            };
211            if let Some(rest) = name.strip_prefix(COW_FILE_PREFIX) {
212                let id = rest.strip_suffix(COW_FILE_SUFFIX).unwrap_or(rest);
213                if keep_cow_files.contains(id) || live_devices.contains(id) {
214                    debug!(file = %path.display(), "keeping retained sandbox cow file");
215                    continue;
216                }
217                for loop_device in loop_devices_for_backing_sync(&path)? {
218                    self.tools.detach_loop(&loop_device)?;
219                }
220                debug!(file = %path.display(), "removing stale cow file");
221                remove_file_durable(&path)?;
222            }
223        }
224
225        // 3. Detach orphaned template loop devices.
226        //
227        // Template attaches are tracked only in the in-memory `templates`
228        // HashMap, which holds nothing at startup but what [`Self::adopt`]
229        // put there — without this pass, every crash+restart cycle would
230        // permanently leak one read-only loop device per unique rootfs
231        // template, eventually exhausting the 256-entry loop namespace.
232        //
233        // We use marker files written at attach time (under
234        // `{cow_dir}/.template-loops/`) rather than a system-wide "any
235        // RO loop" scan, so we never touch loops attached by other
236        // services in the guest (containerd snapshotter, squashfs mounts).
237        self.cleanup_stale_template_markers()?;
238        Ok(())
239    }
240
241    /// Marker path for the template loop `loop_dev` (e.g.
242    /// `{cow_dir}/.template-loops/loop0`).  Returns `None` for a
243    /// malformed device path.
244    pub(super) fn template_marker_path(&self, loop_dev: &str) -> Option<PathBuf> {
245        let basename = Path::new(loop_dev).file_name()?;
246        Some(self.cow_dir.join(TEMPLATE_LOOP_DIR).join(basename))
247    }
248
249    pub(super) fn write_template_marker(&self, loop_dev: &str, template_path: &Path) -> Result<()> {
250        let Some(marker) = self.template_marker_path(loop_dev) else {
251            return Err(SnapshotError::DeviceMapper(format!(
252                "unparseable template loop device: {loop_dev}"
253            )));
254        };
255        write_owner_marker(&marker, template_path)?;
256        Ok(())
257    }
258
259    pub(super) fn write_template_pending(
260        &self,
261        sandbox_id: &str,
262        template_path: &Path,
263    ) -> Result<PathBuf> {
264        let marker = self
265            .cow_dir
266            .join(TEMPLATE_LOOP_DIR)
267            .join(format!("{TEMPLATE_PENDING_PREFIX}{sandbox_id}"));
268        write_owner_marker(&marker, template_path)?;
269        Ok(marker)
270    }
271
272    pub(super) async fn abort_template_acquisition(
273        &self,
274        sandbox_id: &str,
275        pending: &Path,
276        loop_device: Option<&str>,
277        template_path: &Path,
278        error: SnapshotError,
279    ) -> SnapshotError {
280        let mut failures = Vec::new();
281        let detached = match loop_device {
282            Some(loop_device) => match self.detach_template_loop(loop_device).await {
283                Ok(()) => true,
284                Err(cleanup) => {
285                    failures.push(cleanup.to_string());
286                    false
287                }
288            },
289            None => true,
290        };
291        if detached && let Err(cleanup) = clear_owner_marker(pending) {
292            failures.push(cleanup.to_string());
293        }
294        if !failures.is_empty() {
295            self.setup_orphans.lock().unwrap().insert(
296                sandbox_id.to_owned(),
297                SetupOrphan {
298                    pending_template_marker: Some(pending.to_path_buf()),
299                    template_loop: loop_device
300                        .map(|loop_device| (loop_device.to_owned(), template_path.to_path_buf())),
301                    ..Default::default()
302                },
303            );
304        }
305        incomplete_cleanup(error, failures)
306    }
307
308    pub(super) fn cleanup_stale_template_markers(&self) -> Result<()> {
309        let dir = self.cow_dir.join(TEMPLATE_LOOP_DIR);
310        let entries = match std::fs::read_dir(&dir) {
311            Ok(entries) => entries,
312            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
313            Err(error) => return Err(error.into()),
314        };
315        for entry in entries {
316            let entry = entry?;
317            let marker_path = entry.path();
318            let Some(loop_basename) = marker_path.file_name().and_then(|n| n.to_str()) else {
319                return Err(SnapshotError::DeviceMapper(format!(
320                    "invalid template marker path: {}",
321                    marker_path.display()
322                )));
323            };
324            if loop_basename.starts_with(TEMPLATE_MARKER_TEMP_PREFIX) {
325                clear_owner_marker(&marker_path)?;
326                continue;
327            }
328            let expected_backing = std::fs::read_to_string(&marker_path)?.trim().to_string();
329            if expected_backing.is_empty() {
330                return Err(SnapshotError::DeviceMapper(format!(
331                    "empty template marker: {}",
332                    marker_path.display()
333                )));
334            }
335            // The map is the authority on which template loops are in use:
336            // an entry means a live dm-snapshot has this template as its
337            // origin, either because this manager attached it or because
338            // [`Self::adopt`] re-registered a still-running guest's. The
339            // marker stays too — it is that loop's recovery record, and its
340            // owner clears it on teardown. Before the pending branch, not
341            // after: that branch detaches *everything* backing the template,
342            // which is precisely the live loop. A pending marker for a live
343            // template is instead cleared by a later sweep, once nothing
344            // holds it — by which time its own loop, if it ever got one, is
345            // the one the teardown detached.
346            if self
347                .templates
348                .lock()
349                .unwrap()
350                .contains_key(Path::new(&expected_backing))
351            {
352                debug!(
353                    marker = %marker_path.display(),
354                    template = %expected_backing,
355                    "keeping live template loop"
356                );
357                continue;
358            }
359            if loop_basename.starts_with(TEMPLATE_PENDING_PREFIX) {
360                for loop_device in loop_devices_for_backing_sync(Path::new(&expected_backing))? {
361                    self.tools.detach_loop(&loop_device)?;
362                }
363                clear_owner_marker(&marker_path)?;
364                continue;
365            }
366
367            let dev = format!("/dev/{loop_basename}");
368            // Verify the loop is still attached AND still backs the
369            // expected template, so we never detach a /dev/loopN that
370            // was reused by another process after our crash. Either
371            // spelling counts (see [`backing_spellings`]): the marker holds
372            // the path this manager was configured with, the kernel answers
373            // with the one it resolved, and reading a difference between
374            // them as "reused by someone else" clears the marker and strands
375            // the loop forever — the exact leak this pass exists to prevent.
376            let expected = backing_spellings(Path::new(&expected_backing));
377            let actual_backing = loop_backing_file(&dev)?;
378
379            if actual_backing
380                .as_ref()
381                .is_some_and(|actual| expected.contains(actual))
382            {
383                debug!(dev = %dev, "detaching stale template loop");
384                self.tools.detach_loop(&dev)?;
385            } else {
386                debug!(
387                    dev = %dev,
388                    expected = %expected_backing,
389                    actual = ?actual_backing,
390                    "skipping stale template loop: backing mismatch"
391                );
392            }
393
394            clear_owner_marker(&marker_path)?;
395        }
396        std::fs::File::open(&dir)?.sync_all()?;
397        Ok(())
398    }
399
400    pub(super) async fn rollback_setup(
401        &self,
402        sandbox_id: &str,
403        template_path: &Path,
404        dm_name: Option<&str>,
405        cow_loop: Option<&str>,
406        cow_file: Option<&Path>,
407        error: SnapshotError,
408    ) -> SnapshotError {
409        let mut failures = Vec::new();
410        let mut orphan = SetupOrphan::default();
411        let dm_removed = match dm_name {
412            // Asked of device-mapper rather than of `/dev/mapper`: a setup
413            // that failed *because* the node was never created still has a
414            // live device to reclaim, and stat-ing the absent node would
415            // strand exactly what this rollback exists to remove.
416            Some(dm_name) if super::dm_present(dm_name) => {
417                let cleanup = match self.dmsetup_bin.as_deref() {
418                    Some(dmsetup) => dmsetup_remove(dmsetup, dm_name).await,
419                    None => Err(SnapshotError::DeviceMapper(
420                        "dmsetup binary not found".into(),
421                    )),
422                };
423                match cleanup {
424                    Ok(()) => true,
425                    Err(cleanup) => {
426                        failures.push(cleanup.to_string());
427                        orphan.dm_name = Some(dm_name.to_owned());
428                        false
429                    }
430                }
431            }
432            _ => true,
433        };
434        let cow_detached = match (dm_removed, cow_loop) {
435            (false, Some(loop_device)) => {
436                orphan.cow_loop = Some(loop_device.to_owned());
437                false
438            }
439            (true, Some(loop_device)) => match self.detach_loop(loop_device).await {
440                Ok(()) => true,
441                Err(cleanup) => {
442                    failures.push(cleanup.to_string());
443                    orphan.cow_loop = Some(loop_device.to_owned());
444                    false
445                }
446            },
447            (_, None) => true,
448        };
449        if let Some(cow_file) = cow_file {
450            if cow_detached {
451                if let Err(cleanup) = remove_file_durable(cow_file) {
452                    failures.push(format!("remove {}: {cleanup}", cow_file.display()));
453                    orphan.cow_file = Some(cow_file.to_path_buf());
454                }
455            } else if !cow_detached {
456                orphan.cow_file = Some(cow_file.to_path_buf());
457            }
458        }
459        if let Err(cleanup) = self.release_template_ref(template_path, false).await {
460            failures.push(cleanup.to_string());
461            orphan.template_path = Some(template_path.to_path_buf());
462        }
463        if !orphan.is_empty() {
464            self.setup_orphans
465                .lock()
466                .unwrap()
467                .insert(sandbox_id.to_owned(), orphan);
468        }
469        incomplete_cleanup(error, failures)
470    }
471
472    pub async fn cleanup_setup_orphan(&self, sandbox_id: &str) -> Result<()> {
473        let Some(orphan) = self.setup_orphans.lock().unwrap().get(sandbox_id).cloned() else {
474            return Ok(());
475        };
476
477        if let Some(dm_name) = &orphan.dm_name
478            && Path::new(&format!("/dev/mapper/{dm_name}")).exists()
479        {
480            let dmsetup = self
481                .dmsetup_bin
482                .as_deref()
483                .ok_or_else(|| SnapshotError::DeviceMapper("dmsetup binary not found".into()))?;
484            dmsetup_remove(dmsetup, dm_name).await?;
485        }
486        if let (Some(cow_loop), Some(cow_file)) = (&orphan.cow_loop, &orphan.cow_file)
487            && loop_backs_path(cow_loop, cow_file)?
488        {
489            self.detach_loop(cow_loop).await?;
490        }
491        if let Some(cow_file) = &orphan.cow_file {
492            remove_file_durable(cow_file)?;
493        }
494        if let Some(template_path) = &orphan.template_path {
495            self.release_template_ref(template_path, false).await?;
496        }
497        if let Some((template_loop, template_path)) = &orphan.template_loop
498            && loop_backs_path(template_loop, template_path)?
499        {
500            self.detach_template_loop(template_loop).await?;
501        }
502        if let Some(pending) = &orphan.pending_template_marker {
503            clear_owner_marker(pending)?;
504        }
505        self.setup_orphans.lock().unwrap().remove(sandbox_id);
506        Ok(())
507    }
508
509    /// Decrement the refcount for a template; detach its loop device when
510    /// the count reaches zero.
511    ///
512    /// `restore_ref_on_failure` is true only for an already-delivered
513    /// `CowHandle`. Setup rollback has no owner to restore, so a failed detach
514    /// stays cached with refcount zero for a future setup or restart sweep.
515    pub(super) async fn release_template_ref(
516        &self,
517        template_path: &Path,
518        restore_ref_on_failure: bool,
519    ) -> Result<()> {
520        let _losetup_guard = self.losetup_lock.lock().await;
521        let Some(entry) = ({
522            let mut templates = self.templates.lock().unwrap();
523            let Some(entry) = templates.get_mut(template_path) else {
524                return Ok(());
525            };
526            if entry.refcount > 1 {
527                entry.refcount -= 1;
528                return Ok(());
529            }
530            templates.remove(template_path)
531        }) else {
532            return Ok(());
533        };
534
535        if let Err(error) = self.detach_template_loop(&entry.loop_device).await {
536            let mut entry = entry;
537            entry.refcount = usize::from(restore_ref_on_failure);
538            self.templates
539                .lock()
540                .unwrap()
541                .insert(template_path.to_path_buf(), entry);
542            return Err(error);
543        }
544        Ok(())
545    }
546
547    async fn detach_template_loop(&self, loop_device: &str) -> Result<()> {
548        self.detach_loop(loop_device).await?;
549        if let Some(marker) = self.template_marker_path(loop_device)
550            && let Err(error) = clear_owner_marker(&marker)
551        {
552            warn!(
553                marker = %marker.display(),
554                error = %error,
555                "detached template loop but failed to remove recovery marker"
556            );
557        }
558        Ok(())
559    }
560}
561
562fn write_owner_marker(marker: &Path, backing: &Path) -> Result<()> {
563    let parent = marker
564        .parent()
565        .ok_or_else(|| SnapshotError::DeviceMapper("resource marker has no parent".into()))?;
566    let name = marker
567        .file_name()
568        .ok_or_else(|| SnapshotError::DeviceMapper("resource marker has no file name".into()))?;
569    std::fs::create_dir_all(parent)?;
570    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
571    let temporary = parent.join(format!(
572        "{TEMPLATE_MARKER_TEMP_PREFIX}{}",
573        name.to_string_lossy()
574    ));
575    let mut file = std::fs::OpenOptions::new()
576        .write(true)
577        .create(true)
578        .truncate(true)
579        .mode(0o600)
580        .open(&temporary)?;
581    file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
582    file.write_all(backing.to_string_lossy().as_bytes())?;
583    file.sync_all()?;
584    std::fs::rename(&temporary, marker)?;
585    std::fs::File::open(parent)?.sync_all()?;
586    if let Some(grandparent) = parent.parent() {
587        std::fs::File::open(grandparent)?.sync_all()?;
588    }
589    Ok(())
590}
591
592pub(super) fn clear_owner_marker(marker: &Path) -> Result<()> {
593    match std::fs::remove_file(marker) {
594        Ok(()) => {}
595        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
596        Err(error) => return Err(error.into()),
597    }
598    let parent = marker
599        .parent()
600        .ok_or_else(|| SnapshotError::DeviceMapper("resource marker has no parent".into()))?;
601    std::fs::File::open(parent)?.sync_all()?;
602    Ok(())
603}
604
605pub(super) fn remove_file_durable(path: &Path) -> Result<()> {
606    match std::fs::remove_file(path) {
607        Ok(()) => {}
608        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
609        Err(error) => return Err(error.into()),
610    }
611    let parent = path
612        .parent()
613        .ok_or_else(|| SnapshotError::DeviceMapper("owned file has no parent".into()))?;
614    std::fs::File::open(parent)?.sync_all()?;
615    Ok(())
616}
617
618/// How the kernel may spell `backing` in a loop device's `backing_file`:
619/// the caller's own spelling, and the resolved path.
620///
621/// [`loop_backing_file`] reports the *resolved* path of the file the device
622/// holds open, which is not the caller's spelling whenever that path
623/// reaches the file through a symlink — a data directory pointed at another
624/// volume, say. The attach path already accepts either
625/// (`BusyboxBlockTools::verify_attached`), and rediscovery has to as well:
626/// comparing one spelling makes a loop this manager attached itself
627/// invisible to teardown, and makes [`CowManager::adopt`] refuse a live
628/// sandbox whose rootfs is right there.
629pub(super) fn backing_spellings(backing: &Path) -> Vec<String> {
630    let literal = backing.to_string_lossy().into_owned();
631    let resolved = std::fs::canonicalize(backing)
632        .map(|path| path.to_string_lossy().into_owned())
633        .ok()
634        .filter(|resolved| *resolved != literal);
635    std::iter::once(literal).chain(resolved).collect()
636}
637
638pub(super) fn loop_backs_path(loop_device: &str, backing: &Path) -> Result<bool> {
639    let expected = backing_spellings(backing);
640    Ok(loop_backing_file(loop_device)?.is_some_and(|actual| expected.contains(&actual)))
641}
642
643pub(super) fn loop_devices_for_backing_sync(backing: &Path) -> Result<Vec<String>> {
644    let expected = backing_spellings(backing);
645    let mut devices = Vec::new();
646    for entry in std::fs::read_dir("/sys/block")? {
647        let entry = entry?;
648        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
649            continue;
650        };
651        let Some(index) = name.strip_prefix("loop") else {
652            continue;
653        };
654        if index.is_empty() || !index.bytes().all(|byte| byte.is_ascii_digit()) {
655            continue;
656        }
657        let device = format!("/dev/{name}");
658        if loop_backing_file(&device)?.is_some_and(|actual| expected.contains(&actual)) {
659            devices.push(device);
660        }
661    }
662    devices.sort();
663    Ok(devices)
664}
665
666fn incomplete_cleanup(
667    error: SnapshotError,
668    failures: impl IntoIterator<Item = String>,
669) -> SnapshotError {
670    let failures: Vec<_> = failures.into_iter().collect();
671    if failures.is_empty() {
672        error
673    } else {
674        SnapshotError::Unavailable(format!(
675            "{error}; resource rollback is incomplete: {}",
676            failures.join("; ")
677        ))
678    }
679}
680
681fn run_sync_checked(command: &mut Command) -> Result<std::process::Output> {
682    let output = command
683        .output()
684        .map_err(|error| SnapshotError::DeviceMapper(format!("command spawn: {error}")))?;
685    if output.status.success() {
686        Ok(output)
687    } else {
688        Err(SnapshotError::DeviceMapper(
689            String::from_utf8_lossy(&output.stderr).trim().to_owned(),
690        ))
691    }
692}