Skip to main content

arcbox_snapshot/snapshot_cow/block_tools/
busybox.rs

1//! [`BlockTools`] over busybox applets — the System VM's userland.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::time::Duration;
6
7use tracing::debug;
8
9use crate::error::{Result, SnapshotError};
10
11use super::{BlockTools, loop_backing_file, parse_loop_device, verify_attached};
12
13/// How many free-device queries [`BusyboxBlockTools::attach_loop`] makes
14/// before giving up on a device that keeps being claimed underneath it.
15const ATTACH_ATTEMPTS: u32 = 8;
16
17/// Pause between those attempts — long enough for the competing attach to
18/// finish, short enough to be invisible next to the subprocess spawns.
19const ATTACH_RETRY_DELAY: Duration = Duration::from_millis(5);
20
21/// [`BlockTools`] over busybox applets — the System VM's userland.
22///
23/// Every operation runs `<busybox> <applet> …`; the applets are `losetup`
24/// and `blockdev`. BusyBox's `losetup` has no long options at all
25/// (`util-linux/losetup.c`: `[-rP] [-o OFS] {-f|LOOPDEV} FILE`), so
26/// util-linux's atomic allocate-and-report form `-f --show` is an
27/// unrecognized-option error there, and `-f FILE` attaches without printing
28/// which device it used. Attaching is therefore two applet runs: `losetup
29/// -f` prints the first free device, then `losetup [-r] /dev/loopN FILE`
30/// attaches to exactly that device. Another process can claim the queried
31/// device in between (`mount -o loop`, a concurrent template build), in
32/// which case the attach fails and [`attach_loop`](BlockTools::attach_loop)
33/// re-queries and retries, up to [`ATTACH_ATTEMPTS`] times.
34#[derive(Debug, Clone)]
35pub struct BusyboxBlockTools {
36    busybox: PathBuf,
37}
38
39impl BusyboxBlockTools {
40    /// Where the System VM's EROFS rootfs installs busybox.
41    pub const DEFAULT_PATH: &'static str = "/bin/busybox";
42
43    /// Use the busybox binary at `busybox`.
44    pub fn new(busybox: impl Into<PathBuf>) -> Self {
45        Self {
46            busybox: busybox.into(),
47        }
48    }
49
50    fn run(&self, applet: &str, args: &[&str]) -> Result<std::process::Output> {
51        Command::new(&self.busybox)
52            .arg(applet)
53            .args(args)
54            .output()
55            .map_err(|e| {
56                SnapshotError::DeviceMapper(format!(
57                    "spawn {} {applet}: {e}",
58                    self.busybox.display()
59                ))
60            })
61    }
62
63    /// `losetup -f`: the first free loop device, as `/dev/loopN`.
64    fn next_free_loop(&self) -> Result<String> {
65        let output = self.run("losetup", &["-f"])?;
66        if !output.status.success() {
67            let stderr = String::from_utf8_lossy(&output.stderr);
68            return Err(SnapshotError::DeviceMapper(format!("losetup -f: {stderr}")));
69        }
70        parse_loop_device("losetup -f", &output.stdout)
71    }
72}
73
74impl Default for BusyboxBlockTools {
75    fn default() -> Self {
76        Self::new(Self::DEFAULT_PATH)
77    }
78}
79
80impl BlockTools for BusyboxBlockTools {
81    /// `losetup -f`, then `losetup [-r] /dev/loopN FILE`, retried when the
82    /// queried device is claimed by someone else before the attach lands.
83    /// A lost race shows up either as the kernel's `EBUSY` from
84    /// `LOOP_CONFIGURE`/`LOOP_SET_FD` — busybox prints "Device or resource
85    /// busy" — or, when busybox notices the device is taken before it
86    /// issues the ioctl, as a failure with whatever stale errno text it had
87    /// on hand, so the classifier also asks sysfs whether the device gained
88    /// a backing file. Any other failure is reported with busybox's stderr.
89    fn attach_loop(&self, backing: &Path, read_only: bool) -> Result<String> {
90        let backing_str = backing
91            .to_str()
92            .ok_or_else(|| SnapshotError::DeviceMapper("non-UTF-8 path".into()))?;
93        let mut last_loss = String::new();
94        for attempt in 1..=ATTACH_ATTEMPTS {
95            let device = self.next_free_loop()?;
96            let mut args = Vec::with_capacity(3);
97            if read_only {
98                args.push("-r");
99            }
100            args.extend([device.as_str(), backing_str]);
101            let output = self.run("losetup", &args)?;
102            if output.status.success() {
103                verify_attached(self, &device, backing)?;
104                return Ok(device);
105            }
106            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
107            if !(is_busy_message(&stderr, backing_str) || loop_backing_file(&device)?.is_some()) {
108                return Err(SnapshotError::DeviceMapper(format!(
109                    "losetup {device} {}: {stderr}",
110                    backing.display()
111                )));
112            }
113            debug!(
114                %device,
115                attempt,
116                %stderr,
117                "free loop device was claimed by another process; re-querying"
118            );
119            last_loss = format!("{device}: {stderr}");
120            std::thread::sleep(ATTACH_RETRY_DELAY);
121        }
122        Err(SnapshotError::DeviceMapper(format!(
123            "losetup attach {}: another process claimed the free loop device on all \
124             {ATTACH_ATTEMPTS} attempts (last: {last_loss})",
125            backing.display()
126        )))
127    }
128
129    fn detach_loop(&self, device: &str) -> Result<()> {
130        let output = self.run("losetup", &["-d", device])?;
131        if !output.status.success() {
132            let stderr = String::from_utf8_lossy(&output.stderr);
133            return Err(SnapshotError::DeviceMapper(format!(
134                "losetup -d {device}: {stderr}"
135            )));
136        }
137        Ok(())
138    }
139
140    fn device_sectors(&self, device: &str) -> Result<u64> {
141        let output = self.run("blockdev", &["--getsz", device])?;
142        if !output.status.success() {
143            let stderr = String::from_utf8_lossy(&output.stderr);
144            return Err(SnapshotError::DeviceMapper(format!(
145                "blockdev --getsz {device}: {stderr}"
146            )));
147        }
148        String::from_utf8_lossy(&output.stdout)
149            .trim()
150            .parse::<u64>()
151            .map_err(|e| SnapshotError::DeviceMapper(format!("blockdev parse: {e}")))
152    }
153}
154
155/// Whether a failed attach's stderr names the lost-race error: the kernel's
156/// `EBUSY` from a device that was free a moment ago — "Device or resource
157/// busy" under glibc, "Resource busy" under musl, however the tool spells
158/// it. `losetup` echoes the backing path (`losetup: FILE: <reason>`, or
159/// just `losetup: FILE` when it died with errno 0), so that path is scrubbed
160/// first: a `busybox-…` template must not turn a permanent failure into a
161/// phantom race.
162fn is_busy_message(stderr: &str, backing: &str) -> bool {
163    stderr
164        .replace(backing, "")
165        .to_ascii_lowercase()
166        .contains("busy")
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::test_support::{calls, fake_tool};
173
174    #[test]
175    fn busybox_tools_default_to_the_system_vm_path() {
176        assert_eq!(
177            BusyboxBlockTools::default().busybox,
178            PathBuf::from(BusyboxBlockTools::DEFAULT_PATH)
179        );
180    }
181
182    #[test]
183    fn busybox_tools_report_a_missing_binary() {
184        let tools = BusyboxBlockTools::new("/nonexistent/arcbox-busybox");
185        let err = tools.detach_loop("/dev/loop0").unwrap_err();
186        assert!(err.to_string().contains("spawn"), "{err}");
187    }
188
189    #[test]
190    fn busy_classifier_reads_the_reason_not_the_echoed_path() {
191        let img = "/tmp/x.img";
192        assert!(is_busy_message(
193            "losetup: /tmp/x.img: Device or resource busy\n",
194            img
195        ));
196        assert!(is_busy_message("losetup: /tmp/x.img: Resource busy", img));
197        assert!(is_busy_message("losetup: /dev/loop3: EBUSY", img));
198        assert!(is_busy_message(
199            "losetup: /tmp/x.img: failed to set up loop device: Device or resource busy",
200            img
201        ));
202        assert!(!is_busy_message(
203            "losetup: /tmp/x.img: No such file or directory",
204            img
205        ));
206        assert!(!is_busy_message("", img));
207
208        let template = "/templates/busybox-1.36.ext4";
209        assert!(!is_busy_message(
210            "losetup: /templates/busybox-1.36.ext4: No such file or directory",
211            template
212        ));
213        assert!(!is_busy_message(
214            "losetup: /templates/busybox-1.36.ext4",
215            template
216        ));
217        assert!(is_busy_message(
218            "losetup: /templates/busybox-1.36.ext4: Resource busy",
219            template
220        ));
221    }
222
223    /// A busybox stand-in whose `case "$*"` body decides what each applet
224    /// run does; see `fake_tool`.
225    fn fake_busybox(dir: &Path, body: &str) -> (BusyboxBlockTools, PathBuf) {
226        let (script, log) = fake_tool(dir, "busybox", body);
227        (BusyboxBlockTools::new(script), log)
228    }
229
230    fn attach_calls(log: &Path) -> Vec<String> {
231        calls(log)
232            .into_iter()
233            .filter(|line| line.starts_with("losetup /dev/loop") || line.starts_with("losetup -r"))
234            .collect()
235    }
236
237    #[test]
238    fn attach_queries_a_free_device_then_attaches_to_exactly_that_one() {
239        let dir = tempfile::tempdir().unwrap();
240        let (tools, log) = fake_busybox(
241            dir.path(),
242            "\"losetup -f\") echo /dev/loop9999 ;;\n\
243             \"losetup -r /dev/loop9999 \"*) exit 0 ;;\n\
244             *) echo \"unexpected: $*\" >&2; exit 2 ;;",
245        );
246        let backing = dir.path().join("template.ext4");
247        std::fs::write(&backing, b"").unwrap();
248
249        let device = tools.attach_loop(&backing, true).unwrap();
250
251        assert_eq!(device, "/dev/loop9999");
252        assert_eq!(
253            attach_calls(&log),
254            [format!("losetup -r /dev/loop9999 {}", backing.display())]
255        );
256    }
257
258    #[test]
259    fn a_free_device_query_that_fails_is_reported_as_itself() {
260        let dir = tempfile::tempdir().unwrap();
261        let (tools, log) = fake_busybox(
262            dir.path(),
263            "\"losetup -f\") echo \"losetup: /dev/loop-control: No such device\" >&2; exit 1 ;;\n\
264             *) exit 2 ;;",
265        );
266
267        let err = tools
268            .attach_loop(Path::new("/tmp/cow.img"), false)
269            .unwrap_err();
270
271        assert!(err.to_string().contains("losetup -f"), "{err}");
272        assert!(err.to_string().contains("No such device"), "{err}");
273        // The attach never ran: there was no device to attach to.
274        assert_eq!(calls(&log), ["losetup -f"]);
275    }
276
277    #[test]
278    fn detach_and_size_drive_the_applets_and_parse_the_size() {
279        let dir = tempfile::tempdir().unwrap();
280        let (tools, log) = fake_busybox(
281            dir.path(),
282            "\"losetup -d /dev/loop9999\") exit 0 ;;\n\
283             \"blockdev --getsz /dev/loop9999\") echo 8192 ;;\n\
284             *) exit 2 ;;",
285        );
286
287        tools.detach_loop("/dev/loop9999").unwrap();
288        assert_eq!(tools.device_sectors("/dev/loop9999").unwrap(), 8192);
289        assert_eq!(
290            calls(&log),
291            ["losetup -d /dev/loop9999", "blockdev --getsz /dev/loop9999"]
292        );
293    }
294
295    #[test]
296    fn a_failed_detach_carries_the_applets_reason() {
297        let dir = tempfile::tempdir().unwrap();
298        let (tools, _) = fake_busybox(
299            dir.path(),
300            "*) echo \"losetup: /dev/loop9999: Device or resource busy\" >&2; exit 1 ;;",
301        );
302
303        let err = tools.detach_loop("/dev/loop9999").unwrap_err();
304
305        assert!(
306            err.to_string().contains("losetup -d /dev/loop9999"),
307            "{err}"
308        );
309        assert!(err.to_string().contains("Device or resource busy"), "{err}");
310    }
311
312    #[test]
313    fn a_size_that_is_not_a_number_is_a_parse_error_not_a_zero() {
314        let dir = tempfile::tempdir().unwrap();
315        let (tools, _) = fake_busybox(dir.path(), "*) echo \"lots of them\" ;;");
316
317        let err = tools.device_sectors("/dev/loop9999").unwrap_err();
318
319        assert!(err.to_string().contains("blockdev parse"), "{err}");
320    }
321
322    #[test]
323    fn a_size_query_that_fails_names_the_applet_call() {
324        let dir = tempfile::tempdir().unwrap();
325        let (tools, _) = fake_busybox(
326            dir.path(),
327            "*) echo \"blockdev: /dev/loop9999: No such device\" >&2; exit 1 ;;",
328        );
329
330        let err = tools.device_sectors("/dev/loop9999").unwrap_err();
331
332        assert!(
333            err.to_string().contains("blockdev --getsz /dev/loop9999"),
334            "{err}"
335        );
336    }
337
338    #[test]
339    fn attach_retries_a_claimed_device_a_bounded_number_of_times() {
340        let dir = tempfile::tempdir().unwrap();
341        let (tools, log) = fake_busybox(
342            dir.path(),
343            "\"losetup -f\") echo /dev/loop9999 ;;\n\
344             *) echo \"losetup: $3: Device or resource busy\" >&2; exit 1 ;;",
345        );
346
347        let err = tools
348            .attach_loop(Path::new("/tmp/cow.img"), false)
349            .unwrap_err();
350
351        assert!(err.to_string().contains("8 attempts"), "{err}");
352        assert_eq!(attach_calls(&log).len(), 8);
353    }
354
355    #[test]
356    fn attach_reports_any_other_failure_at_once() {
357        let dir = tempfile::tempdir().unwrap();
358        let (tools, log) = fake_busybox(
359            dir.path(),
360            "\"losetup -f\") echo /dev/loop9999 ;;\n\
361             *) echo \"losetup: /tmp/cow.img: No such file or directory\" >&2; exit 1 ;;",
362        );
363
364        let err = tools
365            .attach_loop(Path::new("/tmp/cow.img"), false)
366            .unwrap_err();
367
368        assert!(
369            err.to_string().contains("No such file or directory"),
370            "{err}"
371        );
372        assert_eq!(attach_calls(&log).len(), 1);
373    }
374}