arcbox_snapshot/snapshot_cow/block_tools/
busybox.rs1use 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
13const ATTACH_ATTEMPTS: u32 = 8;
16
17const ATTACH_RETRY_DELAY: Duration = Duration::from_millis(5);
20
21#[derive(Debug, Clone)]
35pub struct BusyboxBlockTools {
36 busybox: PathBuf,
37}
38
39impl BusyboxBlockTools {
40 pub const DEFAULT_PATH: &'static str = "/bin/busybox";
42
43 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 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 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
155fn 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 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 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}