Skip to main content

arcbox_snapshot/snapshot_cow/block_tools/
util_linux.rs

1//! [`BlockTools`] over util-linux `losetup`/`blockdev` — a stock distro.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use crate::error::{Result, SnapshotError};
7
8use super::{BlockTools, parse_loop_device, verify_attached};
9
10/// Reference search list for util-linux's `losetup`. The first entry that
11/// exists and answers `--version` as util-linux wins; `/usr/sbin` covers
12/// usrmerged distros, `/sbin` stock Debian.
13const LOSETUP_CANDIDATES: &[&str] = &["/usr/sbin/losetup", "/sbin/losetup"];
14
15/// Reference search list for util-linux's `blockdev`, same rule.
16const BLOCKDEV_CANDIDATES: &[&str] = &["/usr/sbin/blockdev", "/sbin/blockdev"];
17
18/// [`BlockTools`] over util-linux, the block tooling a stock distro ships.
19///
20/// Where [`BusyboxBlockTools`](super::BusyboxBlockTools) drives one
21/// multi-call binary with applet names, this drives two separate programs,
22/// and it attaches in a single command rather than two: util-linux's
23/// `losetup -f --show FILE` allocates, attaches, and prints the device it
24/// used, retrying `EBUSY`/`EAGAIN` itself (64 tries, 200 ms apart, in its
25/// own `create_loop`) when another process claims the device it picked. So
26/// there is no query-then-attach window here and no host-side retry loop —
27/// the difference that makes this a separate implementation rather than
28/// [`BusyboxBlockTools`](super::BusyboxBlockTools) pointed at another path.
29///
30/// `blockdev --getsz` is byte-identical between the two, and `losetup -r` /
31/// `losetup -d DEVICE` mean the same thing in both.
32#[derive(Debug, Clone)]
33pub struct UtilLinuxBlockTools {
34    losetup: PathBuf,
35    blockdev: PathBuf,
36}
37
38impl UtilLinuxBlockTools {
39    /// Use the binaries at `losetup` and `blockdev` as given. Nothing is
40    /// probed: a composer that knows where its userland lives (a bundled
41    /// copy, a non-standard prefix) says so and is believed.
42    pub fn new(losetup: impl Into<PathBuf>, blockdev: impl Into<PathBuf>) -> Self {
43        Self {
44            losetup: losetup.into(),
45            blockdev: blockdev.into(),
46        }
47    }
48
49    /// Find both binaries in the reference search lists, failing when
50    /// either is absent or is not util-linux.
51    ///
52    /// `PATH` is deliberately not searched — the crate never does (see
53    /// [`snapshot_cow`](crate::snapshot_cow)), and a node agent's `PATH` is
54    /// whatever its unit file happened to inherit.
55    pub fn discover() -> Result<Self> {
56        Self::discover_in(LOSETUP_CANDIDATES, BLOCKDEV_CANDIDATES)
57    }
58
59    fn discover_in(losetup: &[&str], blockdev: &[&str]) -> Result<Self> {
60        Ok(Self {
61            losetup: first_util_linux("losetup", losetup)?,
62            blockdev: first_util_linux("blockdev", blockdev)?,
63        })
64    }
65
66    fn run(&self, program: &Path, args: &[&str]) -> Result<std::process::Output> {
67        Command::new(program)
68            .args(args)
69            .output()
70            .map_err(|e| SnapshotError::DeviceMapper(format!("spawn {}: {e}", program.display())))
71    }
72}
73
74impl BlockTools for UtilLinuxBlockTools {
75    /// `losetup -f --show [-r] -- FILE`, whose stdout is the device that
76    /// was attached. The path is passed after `--` so a backing file whose
77    /// name starts with `-` cannot be read as an option.
78    fn attach_loop(&self, backing: &Path, read_only: bool) -> Result<String> {
79        let backing_str = backing
80            .to_str()
81            .ok_or_else(|| SnapshotError::DeviceMapper("non-UTF-8 path".into()))?;
82        let mut args = vec!["-f", "--show"];
83        if read_only {
84            args.push("-r");
85        }
86        args.extend(["--", backing_str]);
87        let output = self.run(&self.losetup, &args)?;
88        if !output.status.success() {
89            let stderr = String::from_utf8_lossy(&output.stderr);
90            return Err(SnapshotError::DeviceMapper(format!(
91                "losetup -f --show {}: {}",
92                backing.display(),
93                stderr.trim()
94            )));
95        }
96        let device = parse_loop_device("losetup -f --show", &output.stdout)?;
97        verify_attached(self, &device, backing)?;
98        Ok(device)
99    }
100
101    fn detach_loop(&self, device: &str) -> Result<()> {
102        let output = self.run(&self.losetup, &["-d", device])?;
103        if !output.status.success() {
104            let stderr = String::from_utf8_lossy(&output.stderr);
105            return Err(SnapshotError::DeviceMapper(format!(
106                "losetup -d {device}: {}",
107                stderr.trim()
108            )));
109        }
110        Ok(())
111    }
112
113    fn device_sectors(&self, device: &str) -> Result<u64> {
114        let output = self.run(&self.blockdev, &["--getsz", device])?;
115        if !output.status.success() {
116            let stderr = String::from_utf8_lossy(&output.stderr);
117            return Err(SnapshotError::DeviceMapper(format!(
118                "blockdev --getsz {device}: {}",
119                stderr.trim()
120            )));
121        }
122        String::from_utf8_lossy(&output.stdout)
123            .trim()
124            .parse::<u64>()
125            .map_err(|e| SnapshotError::DeviceMapper(format!("blockdev parse: {e}")))
126    }
127}
128
129/// The first candidate that exists and identifies itself as util-linux.
130///
131/// Existence is not enough, and the reason is not hypothetical: on
132/// busybox-based images `/sbin/losetup` is a symlink to busybox, whose
133/// applet rejects `--show` outright. util-linux's `--version` prints
134/// `<program> from util-linux <ver>` (`print_version`, `include/c.h`),
135/// while the applet has no such option and exits non-zero, so the probe
136/// separates them — the same shape as the crate's `dmsetup version` probe.
137fn first_util_linux(what: &str, candidates: &[&str]) -> Result<PathBuf> {
138    candidates
139        .iter()
140        .map(PathBuf::from)
141        .find(|bin| is_util_linux(bin))
142        .ok_or_else(|| {
143            SnapshotError::config(format!(
144                "no util-linux {what} among {}",
145                candidates.join(", ")
146            ))
147        })
148}
149
150/// Whether `bin` answers `--version` as a util-linux program.
151fn is_util_linux(bin: &Path) -> bool {
152    Command::new(bin)
153        .arg("--version")
154        .output()
155        .is_ok_and(|out| {
156            out.status.success() && String::from_utf8_lossy(&out.stdout).contains("util-linux")
157        })
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::test_support::{calls, fake_tool};
164
165    /// util-linux stand-ins for both binaries, each driving its own
166    /// `case "$*"` body; see `fake_tool`.
167    fn fake_tools(dir: &Path, losetup: &str, blockdev: &str) -> (UtilLinuxBlockTools, PathBuf) {
168        let (losetup, log) = fake_tool(dir, "losetup", losetup);
169        let (blockdev, _) = fake_tool(dir, "blockdev", blockdev);
170        (UtilLinuxBlockTools::new(losetup, blockdev), log)
171    }
172
173    const NO_BLOCKDEV: &str = "*) echo \"unexpected: $*\" >&2; exit 2 ;;";
174
175    #[test]
176    fn attach_is_one_command_that_reports_the_device_it_used() {
177        let dir = tempfile::tempdir().unwrap();
178        let (tools, log) = fake_tools(
179            dir.path(),
180            "\"-f --show -- \"*) echo /dev/loop9999 ;;\n\
181             *) echo \"unexpected: $*\" >&2; exit 2 ;;",
182            NO_BLOCKDEV,
183        );
184        let backing = dir.path().join("template.ext4");
185        std::fs::write(&backing, b"").unwrap();
186
187        let device = tools.attach_loop(&backing, false).unwrap();
188
189        assert_eq!(device, "/dev/loop9999");
190        assert_eq!(
191            calls(&log),
192            [format!("-f --show -- {}", backing.display())],
193            "attach must not query a free device first"
194        );
195    }
196
197    #[test]
198    fn a_read_only_attach_passes_r() {
199        let dir = tempfile::tempdir().unwrap();
200        let (tools, log) = fake_tools(
201            dir.path(),
202            "\"-f --show -r -- \"*) echo /dev/loop9999 ;;\n\
203             *) echo \"unexpected: $*\" >&2; exit 2 ;;",
204            NO_BLOCKDEV,
205        );
206
207        let device = tools.attach_loop(Path::new("/tmp/cow.img"), true).unwrap();
208
209        assert_eq!(device, "/dev/loop9999");
210        assert_eq!(calls(&log), ["-f --show -r -- /tmp/cow.img"]);
211    }
212
213    #[test]
214    fn a_failed_attach_reports_losetups_own_reason_without_retrying() {
215        let dir = tempfile::tempdir().unwrap();
216        let (tools, log) = fake_tools(
217            dir.path(),
218            "*) echo \"losetup: /tmp/cow.img: failed to set up loop device: \
219             Device or resource busy\" >&2; exit 1 ;;",
220            NO_BLOCKDEV,
221        );
222
223        let err = tools
224            .attach_loop(Path::new("/tmp/cow.img"), false)
225            .unwrap_err();
226
227        assert!(err.to_string().contains("Device or resource busy"), "{err}");
228        assert_eq!(
229            calls(&log).len(),
230            1,
231            "losetup retries EBUSY itself; a second attempt here would double the wait"
232        );
233    }
234
235    #[test]
236    fn attach_rejects_output_that_is_not_a_loop_device() {
237        let dir = tempfile::tempdir().unwrap();
238        let (tools, _) = fake_tools(
239            dir.path(),
240            "*) echo \"losetup from util-linux 2.37.2\" ;;",
241            NO_BLOCKDEV,
242        );
243
244        let err = tools
245            .attach_loop(Path::new("/tmp/cow.img"), false)
246            .unwrap_err();
247
248        assert!(err.to_string().contains("not a /dev/loopN"), "{err}");
249    }
250
251    #[test]
252    fn detach_and_size_drive_their_own_binaries() {
253        let dir = tempfile::tempdir().unwrap();
254        let (tools, log) = fake_tools(
255            dir.path(),
256            "\"-d /dev/loop9999\") exit 0 ;;\n\
257             *) exit 2 ;;",
258            "\"--getsz /dev/loop9999\") echo 8192 ;;\n\
259             *) exit 2 ;;",
260        );
261
262        tools.detach_loop("/dev/loop9999").unwrap();
263        assert_eq!(tools.device_sectors("/dev/loop9999").unwrap(), 8192);
264        assert_eq!(calls(&log), ["-d /dev/loop9999"]);
265    }
266
267    #[test]
268    fn a_failed_detach_carries_losetups_reason() {
269        let dir = tempfile::tempdir().unwrap();
270        let (tools, _) = fake_tools(
271            dir.path(),
272            "*) echo \"losetup: /dev/loop9999: detach failed: Device or resource busy\" >&2; \
273             exit 1 ;;",
274            NO_BLOCKDEV,
275        );
276
277        let err = tools.detach_loop("/dev/loop9999").unwrap_err();
278
279        assert!(
280            err.to_string().contains("losetup -d /dev/loop9999"),
281            "{err}"
282        );
283        assert!(err.to_string().contains("Device or resource busy"), "{err}");
284    }
285
286    #[test]
287    fn a_size_that_is_not_a_number_is_a_parse_error_not_a_zero() {
288        let dir = tempfile::tempdir().unwrap();
289        let (tools, _) = fake_tools(dir.path(), NO_BLOCKDEV, "*) echo \"lots of them\" ;;");
290
291        let err = tools.device_sectors("/dev/loop9999").unwrap_err();
292
293        assert!(err.to_string().contains("blockdev parse"), "{err}");
294    }
295
296    #[test]
297    fn a_size_query_that_fails_names_the_call() {
298        let dir = tempfile::tempdir().unwrap();
299        let (tools, _) = fake_tools(
300            dir.path(),
301            NO_BLOCKDEV,
302            "*) echo \"blockdev: cannot open /dev/loop9999: No such device\" >&2; exit 1 ;;",
303        );
304
305        let err = tools.device_sectors("/dev/loop9999").unwrap_err();
306
307        assert!(
308            err.to_string().contains("blockdev --getsz /dev/loop9999"),
309            "{err}"
310        );
311    }
312
313    #[test]
314    fn a_missing_binary_is_reported_as_a_failed_spawn() {
315        let tools = UtilLinuxBlockTools::new(
316            "/nonexistent/arcbox-losetup",
317            "/nonexistent/arcbox-blockdev",
318        );
319        let err = tools.detach_loop("/dev/loop0").unwrap_err();
320        assert!(err.to_string().contains("spawn"), "{err}");
321    }
322
323    #[test]
324    fn discovery_names_the_binary_it_could_not_find() {
325        let err = UtilLinuxBlockTools::discover_in(
326            &["/nonexistent/arcbox-losetup"],
327            &["/nonexistent/arcbox-blockdev"],
328        )
329        .unwrap_err();
330        assert!(err.to_string().contains("no util-linux losetup"), "{err}");
331        assert!(
332            err.to_string().contains("/nonexistent/arcbox-losetup"),
333            "{err}"
334        );
335    }
336
337    #[test]
338    fn discovery_skips_a_busybox_applet_symlinked_into_sbin() {
339        let dir = tempfile::tempdir().unwrap();
340        // BusyBox's losetup has no --version: it prints usage and fails.
341        let (applet, _) = fake_tool(
342            dir.path(),
343            "losetup",
344            "*) echo \"losetup: unrecognized option '--version'\" >&2; exit 1 ;;",
345        );
346        let (real, _) = fake_tool(
347            dir.path(),
348            "losetup-real",
349            "\"--version\") echo \"losetup from util-linux 2.37.2\" ;;\n*) exit 2 ;;",
350        );
351        let (blockdev, _) = fake_tool(
352            dir.path(),
353            "blockdev",
354            "\"--version\") echo \"blockdev from util-linux 2.37.2\" ;;\n*) exit 2 ;;",
355        );
356
357        let tools = UtilLinuxBlockTools::discover_in(
358            &[applet.to_str().unwrap(), real.to_str().unwrap()],
359            &[blockdev.to_str().unwrap()],
360        )
361        .unwrap();
362
363        assert_eq!(tools.losetup, real);
364        assert_eq!(tools.blockdev, blockdev);
365    }
366}