Skip to main content

arcbox_snapshot/snapshot_cow/block_tools/
mod.rs

1//! Block-device operations the CoW manager needs from its environment.
2//!
3//! The manager needs three things it cannot do through the filesystem
4//! alone: attach a file as a loop device, detach one, and read a block
5//! device's size. Which program performs them is an environment fact —
6//! busybox applets in the System VM's EROFS userland, `util-linux` on a
7//! stock distro, or no program at all through the loop-control ioctls —
8//! so [`BlockTools`] names the operations and the composer supplies the
9//! implementation. Two ship here: [`BusyboxBlockTools`], the reference
10//! (the System VM's behaviour, unchanged), and [`UtilLinuxBlockTools`]
11//! for a stock distro's `/sbin` userland. Each gets its own file — what
12//! differs between userlands is the whole tool surface, not a path; see
13//! [`UtilLinuxBlockTools`] for what busybox cannot express.
14//!
15//! Device-node helpers that only need syscalls (`stat`, `mknod`) or a
16//! sysfs read are plain functions here rather than trait methods: there is
17//! nothing environment specific about them once they stop shelling out.
18
19mod busybox;
20mod util_linux;
21
22pub use busybox::BusyboxBlockTools;
23pub use util_linux::UtilLinuxBlockTools;
24
25use std::path::Path;
26
27use crate::error::{Result, SnapshotError};
28
29/// Loop-device and block-size operations, supplied by the composer.
30///
31/// Every method is synchronous and may block on a subprocess or an ioctl;
32/// [`CowManager`](super::CowManager) calls them from `spawn_blocking`.
33/// Implementations are shared across sandboxes and tasks, hence
34/// `Send + Sync`.
35pub trait BlockTools: Send + Sync {
36    /// Attach `backing` as a loop device and return the device path
37    /// (`/dev/loopN`). Must hold up against concurrent callers in other
38    /// processes: either allocate and attach in one atomic step
39    /// (`LOOP_CONFIGURE`, util-linux `losetup -f --show`) or, when the tool
40    /// cannot, query a free device, attach to it, and retry when another
41    /// process claimed it in between (what [`BusyboxBlockTools`] does).
42    fn attach_loop(&self, backing: &Path, read_only: bool) -> Result<String>;
43
44    /// Detach a loop device.
45    fn detach_loop(&self, device: &str) -> Result<()>;
46
47    /// Size of a block device in 512-byte sectors.
48    fn device_sectors(&self, device: &str) -> Result<u64>;
49}
50
51/// A `/dev/loopN` device name printed by a tool, with the trailing newline
52/// stripped and nothing else accepted — the name is reused verbatim as an
53/// attach target and as the sysfs node to verify against.
54///
55/// `what` names the invocation for the error message.
56fn parse_loop_device(what: &str, stdout: &[u8]) -> Result<String> {
57    let text = String::from_utf8_lossy(stdout);
58    let device = text.trim();
59    match device.strip_prefix("/dev/loop") {
60        Some(index) if !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()) => {
61            Ok(device.to_owned())
62        }
63        _ => Err(SnapshotError::DeviceMapper(format!(
64            "{what} printed {device:?}, not a /dev/loopN device"
65        ))),
66    }
67}
68
69/// Confirm the kernel agrees that `device` now backs `backing`.
70///
71/// `/sys/block/loopN/loop/backing_file` is the kernel's own view of the
72/// attachment (the resolved path of the file it holds open); a tool's exit
73/// status only says its ioctls returned zero. No sysfs node — no `/sys`
74/// mounted, an unusual kernel — leaves nothing to check and is not a
75/// failure. A device that answers with a different file is detached again
76/// so a failed attach leaks nothing.
77fn verify_attached(tools: &dyn BlockTools, device: &str, backing: &Path) -> Result<()> {
78    match loop_backing_file(device)? {
79        Some(actual) => verify_reported_backing(tools, device, backing, &actual),
80        None => Ok(()),
81    }
82}
83
84/// The decision half of [`verify_attached`], split out so the mismatch
85/// branch is reachable without a real loop device: sysfs is the one input
86/// a test cannot supply.
87fn verify_reported_backing(
88    tools: &dyn BlockTools,
89    device: &str,
90    backing: &Path,
91    actual: &str,
92) -> Result<()> {
93    let matches = actual == backing.to_string_lossy()
94        || std::fs::canonicalize(backing).is_ok_and(|path| path.to_string_lossy() == actual);
95    if matches {
96        return Ok(());
97    }
98    let detach_note = match tools.detach_loop(device) {
99        Ok(()) => String::new(),
100        Err(detach) => format!("; and detaching it again failed: {detach}"),
101    };
102    Err(SnapshotError::DeviceMapper(format!(
103        "{device} backs {actual}, not {}{detach_note}",
104        backing.display()
105    )))
106}
107
108/// The file the kernel reports as backing loop device `device`
109/// (`/dev/loopN`), read from `/sys/block/loopN/loop/backing_file`: the
110/// resolved path of the file the device holds open.
111///
112/// `None` when the device is unattached or the sysfs node is absent — a
113/// host without sysfs, or a name that is not a loop device at all.
114pub(super) fn loop_backing_file(device: &str) -> Result<Option<String>> {
115    let Some(name) = Path::new(device).file_name().and_then(|name| name.to_str()) else {
116        return Ok(None);
117    };
118    match std::fs::read_to_string(format!("/sys/block/{name}/loop/backing_file")) {
119        Ok(path) => Ok(Some(path.trim().to_owned())),
120        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
121        Err(error) => Err(error.into()),
122    }
123}
124
125/// The `(major, minor)` device numbers of the block device at `path`.
126///
127/// Follows symlinks, so `/dev/mapper/<name>` resolves whether device-mapper
128/// created a node there itself (no udev, the System VM) or udev left a
129/// symlink to `/dev/dm-N` (a stock distro).
130#[cfg(target_os = "linux")]
131pub fn device_major_minor(path: &str) -> Result<(u32, u32)> {
132    use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};
133
134    let metadata = std::fs::metadata(path)
135        .map_err(|e| SnapshotError::DeviceMapper(format!("stat {path}: {e}")))?;
136    if !metadata.file_type().is_block_device() {
137        return Err(SnapshotError::DeviceMapper(format!(
138            "{path} is not a block device"
139        )));
140    }
141    let dev = metadata.rdev();
142    // Linux packs 12-bit majors and 20-bit minors into `dev_t`; nix hands
143    // them back as `u64`, and a value outside `u32` is a corrupt node, not
144    // something to truncate.
145    let narrow = |what: &str, value: u64| {
146        u32::try_from(value).map_err(|_| {
147            SnapshotError::DeviceMapper(format!("{path}: {what} {value} out of range"))
148        })
149    };
150    Ok((
151        narrow("major", nix::sys::stat::major(dev))?,
152        narrow("minor", nix::sys::stat::minor(dev))?,
153    ))
154}
155
156/// Create a block device node at `node_path` for `(major, minor)`, mode 0600.
157///
158/// The caller owns the node's ownership: a jailer chroot `chown`s it to the
159/// jailer uid/gid right after, which is why the mode need not be wider.
160#[cfg(target_os = "linux")]
161pub fn mknod_blkdev(node_path: &Path, major: u32, minor: u32) -> Result<()> {
162    use nix::sys::stat::{Mode, SFlag, makedev, mknod};
163
164    mknod(
165        node_path,
166        SFlag::S_IFBLK,
167        Mode::S_IRUSR | Mode::S_IWUSR,
168        makedev(u64::from(major), u64::from(minor)),
169    )
170    .map_err(|e| SnapshotError::DeviceMapper(format!("mknod {}: {e}", node_path.display())))
171}
172
173/// Block device nodes are a Linux concept; off Linux the crate compiles but
174/// the operation is unavailable.
175#[cfg(not(target_os = "linux"))]
176pub fn device_major_minor(path: &str) -> Result<(u32, u32)> {
177    Err(SnapshotError::Unavailable(format!(
178        "block device numbers for {path}: Linux-only"
179    )))
180}
181
182/// Block device nodes are a Linux concept; off Linux the crate compiles but
183/// the operation is unavailable.
184#[cfg(not(target_os = "linux"))]
185pub fn mknod_blkdev(node_path: &Path, _major: u32, _minor: u32) -> Result<()> {
186    Err(SnapshotError::Unavailable(format!(
187        "mknod {}: Linux-only",
188        node_path.display()
189    )))
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[cfg(target_os = "linux")]
197    #[test]
198    fn device_major_minor_rejects_regular_files() {
199        let dir = tempfile::tempdir().unwrap();
200        let file = dir.path().join("plain");
201        std::fs::write(&file, b"x").unwrap();
202        let err = device_major_minor(file.to_str().unwrap()).unwrap_err();
203        assert!(err.to_string().contains("not a block device"), "{err}");
204    }
205
206    #[cfg(target_os = "linux")]
207    #[test]
208    fn device_major_minor_reports_missing_paths() {
209        let err = device_major_minor("/nonexistent/arcbox-blkdev").unwrap_err();
210        assert!(err.to_string().contains("stat"), "{err}");
211    }
212
213    /// Records the detaches `verify_reported_backing` orders, and can be
214    /// told to fail them.
215    struct DetachSpy {
216        detached: std::sync::Mutex<Vec<String>>,
217        fails: bool,
218    }
219
220    impl DetachSpy {
221        fn new(fails: bool) -> Self {
222            Self {
223                detached: std::sync::Mutex::new(Vec::new()),
224                fails,
225            }
226        }
227
228        fn detached(&self) -> Vec<String> {
229            self.detached.lock().unwrap().clone()
230        }
231    }
232
233    impl BlockTools for DetachSpy {
234        fn attach_loop(&self, _backing: &Path, _read_only: bool) -> Result<String> {
235            unreachable!("verification never attaches")
236        }
237
238        fn detach_loop(&self, device: &str) -> Result<()> {
239            self.detached.lock().unwrap().push(device.to_owned());
240            if self.fails {
241                return Err(SnapshotError::DeviceMapper("device busy".into()));
242            }
243            Ok(())
244        }
245
246        fn device_sectors(&self, _device: &str) -> Result<u64> {
247            unreachable!("verification never sizes")
248        }
249    }
250
251    #[test]
252    fn a_device_backing_the_expected_file_verifies_without_a_detach() {
253        let spy = DetachSpy::new(false);
254
255        verify_reported_backing(
256            &spy,
257            "/dev/loop0",
258            Path::new("/tmp/cow.img"),
259            "/tmp/cow.img",
260        )
261        .unwrap();
262
263        assert_eq!(spy.detached(), Vec::<String>::new());
264    }
265
266    #[test]
267    fn a_device_backing_something_else_is_given_back_and_reported() {
268        let spy = DetachSpy::new(false);
269
270        let err = verify_reported_backing(
271            &spy,
272            "/dev/loop0",
273            Path::new("/tmp/cow.img"),
274            "/tmp/other.img",
275        )
276        .unwrap_err();
277
278        assert!(
279            err.to_string().contains("/dev/loop0 backs /tmp/other.img"),
280            "{err}"
281        );
282        assert_eq!(spy.detached(), ["/dev/loop0"]);
283    }
284
285    #[test]
286    fn a_mismatch_whose_detach_also_fails_reports_both() {
287        let spy = DetachSpy::new(true);
288
289        let err = verify_reported_backing(
290            &spy,
291            "/dev/loop0",
292            Path::new("/tmp/cow.img"),
293            "/tmp/other.img",
294        )
295        .unwrap_err();
296
297        assert!(err.to_string().contains("not /tmp/cow.img"), "{err}");
298        assert!(
299            err.to_string().contains("detaching it again failed"),
300            "{err}"
301        );
302    }
303
304    #[test]
305    fn a_printed_device_is_a_loop_device_with_the_newline_stripped() {
306        assert_eq!(
307            parse_loop_device("losetup -f", b"/dev/loop3\n").unwrap(),
308            "/dev/loop3"
309        );
310        assert_eq!(
311            parse_loop_device("losetup -f", b"/dev/loop12").unwrap(),
312            "/dev/loop12"
313        );
314        for garbage in [
315            &b""[..],
316            b"\n",
317            b"/dev/loop",
318            b"/dev/loopX",
319            b"loop3\n",
320            b"/dev/sda1\n",
321            // util-linux's `losetup -f` appends " (lost)" when the /dev
322            // node is gone while the kernel still knows the device
323            // (`losetup.c` A_FIND_FREE, `loopcxt_is_lost`). Only the bare
324            // name is a usable target.
325            b"/dev/loop3 (lost)\n",
326        ] {
327            let err = parse_loop_device("losetup -f", garbage).unwrap_err();
328            assert!(
329                err.to_string().contains("not a /dev/loopN"),
330                "{garbage:?}: {err}"
331            );
332        }
333    }
334}