use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use tracing::debug;
use crate::error::{Result, SnapshotError};
use super::{BlockTools, loop_backing_file, parse_loop_device, verify_attached};
const ATTACH_ATTEMPTS: u32 = 8;
const ATTACH_RETRY_DELAY: Duration = Duration::from_millis(5);
#[derive(Debug, Clone)]
pub struct BusyboxBlockTools {
busybox: PathBuf,
}
impl BusyboxBlockTools {
pub const DEFAULT_PATH: &'static str = "/bin/busybox";
pub fn new(busybox: impl Into<PathBuf>) -> Self {
Self {
busybox: busybox.into(),
}
}
fn run(&self, applet: &str, args: &[&str]) -> Result<std::process::Output> {
Command::new(&self.busybox)
.arg(applet)
.args(args)
.output()
.map_err(|e| {
SnapshotError::DeviceMapper(format!(
"spawn {} {applet}: {e}",
self.busybox.display()
))
})
}
fn next_free_loop(&self) -> Result<String> {
let output = self.run("losetup", &["-f"])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SnapshotError::DeviceMapper(format!("losetup -f: {stderr}")));
}
parse_loop_device("losetup -f", &output.stdout)
}
}
impl Default for BusyboxBlockTools {
fn default() -> Self {
Self::new(Self::DEFAULT_PATH)
}
}
impl BlockTools for BusyboxBlockTools {
fn attach_loop(&self, backing: &Path, read_only: bool) -> Result<String> {
let backing_str = backing
.to_str()
.ok_or_else(|| SnapshotError::DeviceMapper("non-UTF-8 path".into()))?;
let mut last_loss = String::new();
for attempt in 1..=ATTACH_ATTEMPTS {
let device = self.next_free_loop()?;
let mut args = Vec::with_capacity(3);
if read_only {
args.push("-r");
}
args.extend([device.as_str(), backing_str]);
let output = self.run("losetup", &args)?;
if output.status.success() {
verify_attached(self, &device, backing)?;
return Ok(device);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
if !(is_busy_message(&stderr, backing_str) || loop_backing_file(&device)?.is_some()) {
return Err(SnapshotError::DeviceMapper(format!(
"losetup {device} {}: {stderr}",
backing.display()
)));
}
debug!(
%device,
attempt,
%stderr,
"free loop device was claimed by another process; re-querying"
);
last_loss = format!("{device}: {stderr}");
std::thread::sleep(ATTACH_RETRY_DELAY);
}
Err(SnapshotError::DeviceMapper(format!(
"losetup attach {}: another process claimed the free loop device on all \
{ATTACH_ATTEMPTS} attempts (last: {last_loss})",
backing.display()
)))
}
fn detach_loop(&self, device: &str) -> Result<()> {
let output = self.run("losetup", &["-d", device])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SnapshotError::DeviceMapper(format!(
"losetup -d {device}: {stderr}"
)));
}
Ok(())
}
fn device_sectors(&self, device: &str) -> Result<u64> {
let output = self.run("blockdev", &["--getsz", device])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SnapshotError::DeviceMapper(format!(
"blockdev --getsz {device}: {stderr}"
)));
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u64>()
.map_err(|e| SnapshotError::DeviceMapper(format!("blockdev parse: {e}")))
}
}
fn is_busy_message(stderr: &str, backing: &str) -> bool {
stderr
.replace(backing, "")
.to_ascii_lowercase()
.contains("busy")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{calls, fake_tool};
#[test]
fn busybox_tools_default_to_the_system_vm_path() {
assert_eq!(
BusyboxBlockTools::default().busybox,
PathBuf::from(BusyboxBlockTools::DEFAULT_PATH)
);
}
#[test]
fn busybox_tools_report_a_missing_binary() {
let tools = BusyboxBlockTools::new("/nonexistent/arcbox-busybox");
let err = tools.detach_loop("/dev/loop0").unwrap_err();
assert!(err.to_string().contains("spawn"), "{err}");
}
#[test]
fn busy_classifier_reads_the_reason_not_the_echoed_path() {
let img = "/tmp/x.img";
assert!(is_busy_message(
"losetup: /tmp/x.img: Device or resource busy\n",
img
));
assert!(is_busy_message("losetup: /tmp/x.img: Resource busy", img));
assert!(is_busy_message("losetup: /dev/loop3: EBUSY", img));
assert!(is_busy_message(
"losetup: /tmp/x.img: failed to set up loop device: Device or resource busy",
img
));
assert!(!is_busy_message(
"losetup: /tmp/x.img: No such file or directory",
img
));
assert!(!is_busy_message("", img));
let template = "/templates/busybox-1.36.ext4";
assert!(!is_busy_message(
"losetup: /templates/busybox-1.36.ext4: No such file or directory",
template
));
assert!(!is_busy_message(
"losetup: /templates/busybox-1.36.ext4",
template
));
assert!(is_busy_message(
"losetup: /templates/busybox-1.36.ext4: Resource busy",
template
));
}
fn fake_busybox(dir: &Path, body: &str) -> (BusyboxBlockTools, PathBuf) {
let (script, log) = fake_tool(dir, "busybox", body);
(BusyboxBlockTools::new(script), log)
}
fn attach_calls(log: &Path) -> Vec<String> {
calls(log)
.into_iter()
.filter(|line| line.starts_with("losetup /dev/loop") || line.starts_with("losetup -r"))
.collect()
}
#[test]
fn attach_queries_a_free_device_then_attaches_to_exactly_that_one() {
let dir = tempfile::tempdir().unwrap();
let (tools, log) = fake_busybox(
dir.path(),
"\"losetup -f\") echo /dev/loop9999 ;;\n\
\"losetup -r /dev/loop9999 \"*) exit 0 ;;\n\
*) echo \"unexpected: $*\" >&2; exit 2 ;;",
);
let backing = dir.path().join("template.ext4");
std::fs::write(&backing, b"").unwrap();
let device = tools.attach_loop(&backing, true).unwrap();
assert_eq!(device, "/dev/loop9999");
assert_eq!(
attach_calls(&log),
[format!("losetup -r /dev/loop9999 {}", backing.display())]
);
}
#[test]
fn a_free_device_query_that_fails_is_reported_as_itself() {
let dir = tempfile::tempdir().unwrap();
let (tools, log) = fake_busybox(
dir.path(),
"\"losetup -f\") echo \"losetup: /dev/loop-control: No such device\" >&2; exit 1 ;;\n\
*) exit 2 ;;",
);
let err = tools
.attach_loop(Path::new("/tmp/cow.img"), false)
.unwrap_err();
assert!(err.to_string().contains("losetup -f"), "{err}");
assert!(err.to_string().contains("No such device"), "{err}");
assert_eq!(calls(&log), ["losetup -f"]);
}
#[test]
fn detach_and_size_drive_the_applets_and_parse_the_size() {
let dir = tempfile::tempdir().unwrap();
let (tools, log) = fake_busybox(
dir.path(),
"\"losetup -d /dev/loop9999\") exit 0 ;;\n\
\"blockdev --getsz /dev/loop9999\") echo 8192 ;;\n\
*) exit 2 ;;",
);
tools.detach_loop("/dev/loop9999").unwrap();
assert_eq!(tools.device_sectors("/dev/loop9999").unwrap(), 8192);
assert_eq!(
calls(&log),
["losetup -d /dev/loop9999", "blockdev --getsz /dev/loop9999"]
);
}
#[test]
fn a_failed_detach_carries_the_applets_reason() {
let dir = tempfile::tempdir().unwrap();
let (tools, _) = fake_busybox(
dir.path(),
"*) echo \"losetup: /dev/loop9999: Device or resource busy\" >&2; exit 1 ;;",
);
let err = tools.detach_loop("/dev/loop9999").unwrap_err();
assert!(
err.to_string().contains("losetup -d /dev/loop9999"),
"{err}"
);
assert!(err.to_string().contains("Device or resource busy"), "{err}");
}
#[test]
fn a_size_that_is_not_a_number_is_a_parse_error_not_a_zero() {
let dir = tempfile::tempdir().unwrap();
let (tools, _) = fake_busybox(dir.path(), "*) echo \"lots of them\" ;;");
let err = tools.device_sectors("/dev/loop9999").unwrap_err();
assert!(err.to_string().contains("blockdev parse"), "{err}");
}
#[test]
fn a_size_query_that_fails_names_the_applet_call() {
let dir = tempfile::tempdir().unwrap();
let (tools, _) = fake_busybox(
dir.path(),
"*) echo \"blockdev: /dev/loop9999: No such device\" >&2; exit 1 ;;",
);
let err = tools.device_sectors("/dev/loop9999").unwrap_err();
assert!(
err.to_string().contains("blockdev --getsz /dev/loop9999"),
"{err}"
);
}
#[test]
fn attach_retries_a_claimed_device_a_bounded_number_of_times() {
let dir = tempfile::tempdir().unwrap();
let (tools, log) = fake_busybox(
dir.path(),
"\"losetup -f\") echo /dev/loop9999 ;;\n\
*) echo \"losetup: $3: Device or resource busy\" >&2; exit 1 ;;",
);
let err = tools
.attach_loop(Path::new("/tmp/cow.img"), false)
.unwrap_err();
assert!(err.to_string().contains("8 attempts"), "{err}");
assert_eq!(attach_calls(&log).len(), 8);
}
#[test]
fn attach_reports_any_other_failure_at_once() {
let dir = tempfile::tempdir().unwrap();
let (tools, log) = fake_busybox(
dir.path(),
"\"losetup -f\") echo /dev/loop9999 ;;\n\
*) echo \"losetup: /tmp/cow.img: No such file or directory\" >&2; exit 1 ;;",
);
let err = tools
.attach_loop(Path::new("/tmp/cow.img"), false)
.unwrap_err();
assert!(
err.to_string().contains("No such file or directory"),
"{err}"
);
assert_eq!(attach_calls(&log).len(), 1);
}
}