arcbox_snapshot/snapshot_cow/block_tools/
mod.rs1mod busybox;
20mod util_linux;
21
22pub use busybox::BusyboxBlockTools;
23pub use util_linux::UtilLinuxBlockTools;
24
25use std::path::Path;
26
27use crate::error::{Result, SnapshotError};
28
29pub trait BlockTools: Send + Sync {
36 fn attach_loop(&self, backing: &Path, read_only: bool) -> Result<String>;
43
44 fn detach_loop(&self, device: &str) -> Result<()>;
46
47 fn device_sectors(&self, device: &str) -> Result<u64>;
49}
50
51fn parse_loop_device(what: &str, stdout: &[u8]) -> Result<String> {
57 let text = String::from_utf8_lossy(stdout);
58 let device = text.trim();
59 match device.strip_prefix("/dev/loop") {
60 Some(index) if !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()) => {
61 Ok(device.to_owned())
62 }
63 _ => Err(SnapshotError::DeviceMapper(format!(
64 "{what} printed {device:?}, not a /dev/loopN device"
65 ))),
66 }
67}
68
69fn verify_attached(tools: &dyn BlockTools, device: &str, backing: &Path) -> Result<()> {
78 match loop_backing_file(device)? {
79 Some(actual) => verify_reported_backing(tools, device, backing, &actual),
80 None => Ok(()),
81 }
82}
83
84fn verify_reported_backing(
88 tools: &dyn BlockTools,
89 device: &str,
90 backing: &Path,
91 actual: &str,
92) -> Result<()> {
93 let matches = actual == backing.to_string_lossy()
94 || std::fs::canonicalize(backing).is_ok_and(|path| path.to_string_lossy() == actual);
95 if matches {
96 return Ok(());
97 }
98 let detach_note = match tools.detach_loop(device) {
99 Ok(()) => String::new(),
100 Err(detach) => format!("; and detaching it again failed: {detach}"),
101 };
102 Err(SnapshotError::DeviceMapper(format!(
103 "{device} backs {actual}, not {}{detach_note}",
104 backing.display()
105 )))
106}
107
108pub(super) fn loop_backing_file(device: &str) -> Result<Option<String>> {
115 let Some(name) = Path::new(device).file_name().and_then(|name| name.to_str()) else {
116 return Ok(None);
117 };
118 match std::fs::read_to_string(format!("/sys/block/{name}/loop/backing_file")) {
119 Ok(path) => Ok(Some(path.trim().to_owned())),
120 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
121 Err(error) => Err(error.into()),
122 }
123}
124
125#[cfg(target_os = "linux")]
131pub fn device_major_minor(path: &str) -> Result<(u32, u32)> {
132 use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};
133
134 let metadata = std::fs::metadata(path)
135 .map_err(|e| SnapshotError::DeviceMapper(format!("stat {path}: {e}")))?;
136 if !metadata.file_type().is_block_device() {
137 return Err(SnapshotError::DeviceMapper(format!(
138 "{path} is not a block device"
139 )));
140 }
141 let dev = metadata.rdev();
142 let narrow = |what: &str, value: u64| {
146 u32::try_from(value).map_err(|_| {
147 SnapshotError::DeviceMapper(format!("{path}: {what} {value} out of range"))
148 })
149 };
150 Ok((
151 narrow("major", nix::sys::stat::major(dev))?,
152 narrow("minor", nix::sys::stat::minor(dev))?,
153 ))
154}
155
156#[cfg(target_os = "linux")]
161pub fn mknod_blkdev(node_path: &Path, major: u32, minor: u32) -> Result<()> {
162 use nix::sys::stat::{Mode, SFlag, makedev, mknod};
163
164 mknod(
165 node_path,
166 SFlag::S_IFBLK,
167 Mode::S_IRUSR | Mode::S_IWUSR,
168 makedev(u64::from(major), u64::from(minor)),
169 )
170 .map_err(|e| SnapshotError::DeviceMapper(format!("mknod {}: {e}", node_path.display())))
171}
172
173#[cfg(not(target_os = "linux"))]
176pub fn device_major_minor(path: &str) -> Result<(u32, u32)> {
177 Err(SnapshotError::Unavailable(format!(
178 "block device numbers for {path}: Linux-only"
179 )))
180}
181
182#[cfg(not(target_os = "linux"))]
185pub fn mknod_blkdev(node_path: &Path, _major: u32, _minor: u32) -> Result<()> {
186 Err(SnapshotError::Unavailable(format!(
187 "mknod {}: Linux-only",
188 node_path.display()
189 )))
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[cfg(target_os = "linux")]
197 #[test]
198 fn device_major_minor_rejects_regular_files() {
199 let dir = tempfile::tempdir().unwrap();
200 let file = dir.path().join("plain");
201 std::fs::write(&file, b"x").unwrap();
202 let err = device_major_minor(file.to_str().unwrap()).unwrap_err();
203 assert!(err.to_string().contains("not a block device"), "{err}");
204 }
205
206 #[cfg(target_os = "linux")]
207 #[test]
208 fn device_major_minor_reports_missing_paths() {
209 let err = device_major_minor("/nonexistent/arcbox-blkdev").unwrap_err();
210 assert!(err.to_string().contains("stat"), "{err}");
211 }
212
213 struct DetachSpy {
216 detached: std::sync::Mutex<Vec<String>>,
217 fails: bool,
218 }
219
220 impl DetachSpy {
221 fn new(fails: bool) -> Self {
222 Self {
223 detached: std::sync::Mutex::new(Vec::new()),
224 fails,
225 }
226 }
227
228 fn detached(&self) -> Vec<String> {
229 self.detached.lock().unwrap().clone()
230 }
231 }
232
233 impl BlockTools for DetachSpy {
234 fn attach_loop(&self, _backing: &Path, _read_only: bool) -> Result<String> {
235 unreachable!("verification never attaches")
236 }
237
238 fn detach_loop(&self, device: &str) -> Result<()> {
239 self.detached.lock().unwrap().push(device.to_owned());
240 if self.fails {
241 return Err(SnapshotError::DeviceMapper("device busy".into()));
242 }
243 Ok(())
244 }
245
246 fn device_sectors(&self, _device: &str) -> Result<u64> {
247 unreachable!("verification never sizes")
248 }
249 }
250
251 #[test]
252 fn a_device_backing_the_expected_file_verifies_without_a_detach() {
253 let spy = DetachSpy::new(false);
254
255 verify_reported_backing(
256 &spy,
257 "/dev/loop0",
258 Path::new("/tmp/cow.img"),
259 "/tmp/cow.img",
260 )
261 .unwrap();
262
263 assert_eq!(spy.detached(), Vec::<String>::new());
264 }
265
266 #[test]
267 fn a_device_backing_something_else_is_given_back_and_reported() {
268 let spy = DetachSpy::new(false);
269
270 let err = verify_reported_backing(
271 &spy,
272 "/dev/loop0",
273 Path::new("/tmp/cow.img"),
274 "/tmp/other.img",
275 )
276 .unwrap_err();
277
278 assert!(
279 err.to_string().contains("/dev/loop0 backs /tmp/other.img"),
280 "{err}"
281 );
282 assert_eq!(spy.detached(), ["/dev/loop0"]);
283 }
284
285 #[test]
286 fn a_mismatch_whose_detach_also_fails_reports_both() {
287 let spy = DetachSpy::new(true);
288
289 let err = verify_reported_backing(
290 &spy,
291 "/dev/loop0",
292 Path::new("/tmp/cow.img"),
293 "/tmp/other.img",
294 )
295 .unwrap_err();
296
297 assert!(err.to_string().contains("not /tmp/cow.img"), "{err}");
298 assert!(
299 err.to_string().contains("detaching it again failed"),
300 "{err}"
301 );
302 }
303
304 #[test]
305 fn a_printed_device_is_a_loop_device_with_the_newline_stripped() {
306 assert_eq!(
307 parse_loop_device("losetup -f", b"/dev/loop3\n").unwrap(),
308 "/dev/loop3"
309 );
310 assert_eq!(
311 parse_loop_device("losetup -f", b"/dev/loop12").unwrap(),
312 "/dev/loop12"
313 );
314 for garbage in [
315 &b""[..],
316 b"\n",
317 b"/dev/loop",
318 b"/dev/loopX",
319 b"loop3\n",
320 b"/dev/sda1\n",
321 b"/dev/loop3 (lost)\n",
326 ] {
327 let err = parse_loop_device("losetup -f", garbage).unwrap_err();
328 assert!(
329 err.to_string().contains("not a /dev/loopN"),
330 "{garbage:?}: {err}"
331 );
332 }
333 }
334}