arcbox_snapshot/snapshot_cow/block_tools/
util_linux.rs1use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use crate::error::{Result, SnapshotError};
7
8use super::{BlockTools, parse_loop_device, verify_attached};
9
10const LOSETUP_CANDIDATES: &[&str] = &["/usr/sbin/losetup", "/sbin/losetup"];
14
15const BLOCKDEV_CANDIDATES: &[&str] = &["/usr/sbin/blockdev", "/sbin/blockdev"];
17
18#[derive(Debug, Clone)]
33pub struct UtilLinuxBlockTools {
34 losetup: PathBuf,
35 blockdev: PathBuf,
36}
37
38impl UtilLinuxBlockTools {
39 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 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 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
129fn 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
150fn 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 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 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}