blkpath/
lib.rs

1//! # blkpath
2//!
3//! A Rust crate for resolving the underlying block device path from a file path or file descriptor.
4//!
5//! ## Overview
6//!
7//! This crate provides a reliable way to determine which block device underlies a given file or
8//! directory. It uses a multi-step resolution strategy:
9//!
10//! 1. First, it uses the `stat` system call to get the device ID (major:minor numbers)
11//! 2. Then, it looks up the device path via `/sys/dev/block/{major}:{minor}`
12//! 3. If that fails, it falls back to parsing `/proc/self/mountinfo`
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use blkpath::ResolveDevice;
18//! use std::path::Path;
19//!
20//! let path = Path::new("/home");
21//! match path.resolve_device() {
22//!     Ok(device_path) => println!("Device: {}", device_path.display()),
23//!     Err(e) => eprintln!("Error: {}", e),
24//! }
25//! ```
26//!
27//! You can also use it with file descriptors:
28//!
29//! ```rust,no_run
30//! use blkpath::ResolveDevice;
31//! use std::fs::File;
32//!
33//! let file = File::open("/home").unwrap();
34//! match file.resolve_device() {
35//!     Ok(device_path) => println!("Device: {}", device_path.display()),
36//!     Err(e) => eprintln!("Error: {}", e),
37//! }
38//! ```
39
40use std::fs::{self, File};
41use std::io::{self, BufRead, BufReader};
42use std::os::unix::fs::MetadataExt;
43use std::os::unix::io::AsRawFd;
44use std::path::{Path, PathBuf};
45
46/// A trait for resolving the underlying block device of a file or path.
47///
48/// This trait is implemented for `Path` and `File`, allowing you to resolve
49/// the block device using a consistent interface.
50pub trait ResolveDevice {
51    /// Resolves the underlying block device path.
52    ///
53    /// Returns the path to the block device (e.g., `/dev/sda1`, `/dev/nvme0n1p1`)
54    /// that contains the file or directory.
55    ///
56    /// # Errors
57    ///
58    /// Returns an `io::Error` if:
59    /// - The file/path cannot be accessed
60    /// - The device information cannot be retrieved
61    /// - The device cannot be mapped to a block device path
62    ///
63    /// # Example
64    ///
65    /// ```rust,no_run
66    /// use blkpath::ResolveDevice;
67    /// use std::path::Path;
68    ///
69    /// let path = Path::new("/home");
70    /// let device = path.resolve_device()?;
71    /// println!("Device: {}", device.display());
72    /// # Ok::<(), std::io::Error>(())
73    /// ```
74    fn resolve_device(&self) -> io::Result<PathBuf>;
75}
76
77impl ResolveDevice for Path {
78    fn resolve_device(&self) -> io::Result<PathBuf> {
79        let metadata = fs::metadata(self)?;
80
81        let dev = metadata.dev();
82        let major = major(dev);
83        let minor = minor(dev);
84
85        resolve_device_from_dev(major, minor)
86    }
87}
88
89impl ResolveDevice for PathBuf {
90    fn resolve_device(&self) -> io::Result<PathBuf> {
91        self.as_path().resolve_device()
92    }
93}
94
95impl ResolveDevice for File {
96    fn resolve_device(&self) -> io::Result<PathBuf> {
97        let fd = self.as_raw_fd();
98        let (major, minor) = get_dev_from_fd(fd)?;
99        resolve_device_from_dev(major, minor)
100    }
101}
102
103impl ResolveDevice for &File {
104    fn resolve_device(&self) -> io::Result<PathBuf> {
105        (*self).resolve_device()
106    }
107}
108
109/// Extracts the major device number from a device ID.
110#[inline]
111fn major(dev: u64) -> u32 {
112    ((dev >> 8) & 0xfff) as u32 | (((dev >> 32) & !0xfff) as u32)
113}
114
115/// Extracts the minor device number from a device ID.
116#[inline]
117fn minor(dev: u64) -> u32 {
118    (dev & 0xff) as u32 | (((dev >> 12) & !0xff) as u32)
119}
120
121/// Gets the device major:minor from a file descriptor using fstat.
122fn get_dev_from_fd(fd: i32) -> io::Result<(u32, u32)> {
123    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
124    let result = unsafe { libc::fstat(fd, &mut stat_buf) };
125
126    if result != 0 {
127        return Err(io::Error::last_os_error());
128    }
129
130    let dev = stat_buf.st_dev;
131    Ok((major(dev), minor(dev)))
132}
133
134/// Resolves a device path from major:minor numbers.
135///
136/// This function tries multiple resolution strategies:
137/// 1. First, try to resolve via `/sys/dev/block/{major}:{minor}`
138/// 2. If that fails, fall back to parsing `/proc/self/mountinfo`
139fn resolve_device_from_dev(major: u32, minor: u32) -> io::Result<PathBuf> {
140    // Try sysfs first
141    if let Some(path) = resolve_via_sysfs(major, minor) {
142        return Ok(path);
143    }
144
145    // Fall back to mountinfo
146    if let Some(path) = resolve_via_mountinfo(major, minor)? {
147        return Ok(path);
148    }
149
150    Err(io::Error::new(
151        io::ErrorKind::NotFound,
152        format!("Could not resolve device for dev {}:{}", major, minor),
153    ))
154}
155
156/// Resolves a device path via the sysfs interface.
157///
158/// Looks up `/sys/dev/block/{major}:{minor}` and follows the symlink to find
159/// the actual device name.
160fn resolve_via_sysfs(major: u32, minor: u32) -> Option<PathBuf> {
161    let sysfs_path = format!("/sys/dev/block/{}:{}", major, minor);
162    let sysfs_path = Path::new(&sysfs_path);
163
164    if !sysfs_path.exists() {
165        return None;
166    }
167
168    // Read the symlink target to get the device name
169    let target = fs::read_link(sysfs_path).ok()?;
170
171    // Extract device name from path like "../../block/sda/sda1"
172    let device_name = target.file_name()?.to_str()?;
173
174    let dev_path = PathBuf::from(format!("/dev/{}", device_name));
175    dev_path.exists().then_some(dev_path)
176}
177
178/// Resolves a device path by parsing /proc/self/mountinfo.
179///
180/// The mountinfo file format is documented in proc(5).
181/// Each line contains fields separated by spaces:
182/// - mount ID
183/// - parent ID
184/// - major:minor
185/// - root
186/// - mount point
187/// - mount options
188/// - optional fields (terminated by " - ")
189/// - filesystem type
190/// - mount source
191/// - super options
192fn resolve_via_mountinfo(major: u32, minor: u32) -> io::Result<Option<PathBuf>> {
193    let mountinfo_path = Path::new("/proc/self/mountinfo");
194    if !mountinfo_path.exists() {
195        return Ok(None);
196    }
197
198    let file = File::open(mountinfo_path)?;
199    let reader = BufReader::new(file);
200
201    for line in reader.lines() {
202        let line = line?;
203        if let Some(device) = parse_mountinfo_line(&line, major, minor) {
204            return Ok(Some(device));
205        }
206    }
207
208    Ok(None)
209}
210
211/// Parses a single line from mountinfo and returns the device path if it matches.
212fn parse_mountinfo_line(line: &str, target_major: u32, target_minor: u32) -> Option<PathBuf> {
213    let fields: Vec<&str> = line.split_whitespace().collect();
214    if fields.len() < 10 {
215        return None;
216    }
217
218    // Field 3 is major:minor
219    let dev_field = fields.get(2)?;
220    let (major, minor) = parse_dev_field(dev_field)?;
221
222    if major != target_major || minor != target_minor {
223        return None;
224    }
225
226    // Find the separator " - " to get the mount source
227    let separator_idx = fields.iter().position(|&f| f == "-")?;
228
229    // Mount source is 2 fields after the separator
230    let mount_source = fields.get(separator_idx + 2)?;
231
232    if mount_source.starts_with('/') {
233        return Some(PathBuf::from(mount_source));
234    }
235
236    // For non-path sources (like "tmpfs", "proc", etc.), try /dev
237    let dev_path = PathBuf::from(format!("/dev/{}", mount_source));
238    if dev_path.exists() {
239        return Some(dev_path);
240    }
241
242    None
243}
244
245/// Parses a "major:minor" string into (u32, u32).
246fn parse_dev_field(field: &str) -> Option<(u32, u32)> {
247    let mut parts = field.split(':');
248    let major: u32 = parts.next()?.parse().ok()?;
249    let minor: u32 = parts.next()?.parse().ok()?;
250    Some((major, minor))
251}
252
253/// Convenience function to resolve the device for a path.
254///
255/// This is a free function that provides the same functionality as the
256/// `ResolveDevice` trait implementation for `Path`.
257///
258/// # Example
259///
260/// ```rust,no_run
261/// use blkpath::resolve_device;
262/// use std::path::Path;
263///
264/// let device = resolve_device(Path::new("/home"))?;
265/// println!("Device: {}", device.display());
266/// # Ok::<(), std::io::Error>(())
267/// ```
268pub fn resolve_device<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
269    path.as_ref().resolve_device()
270}
271
272/// Convenience function to resolve the device from a file descriptor.
273///
274/// # Example
275///
276/// ```rust,no_run
277/// use blkpath::resolve_device_from_file;
278/// use std::fs::File;
279///
280/// let file = File::open("/home")?;
281/// let device = resolve_device_from_file(&file)?;
282/// println!("Device: {}", device.display());
283/// # Ok::<(), std::io::Error>(())
284/// ```
285pub fn resolve_device_from_file(file: &File) -> io::Result<PathBuf> {
286    file.resolve_device()
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::fs::File;
293    use tempfile::TempDir;
294
295    #[test]
296    fn test_major_minor_extraction() {
297        // Test with a known simple/legacy device number encoding:
298        // for major=8, minor=1 the legacy dev_t value is 0x0801.
299        let dev = 0x0801_u64; // legacy/simple dev_t for major=8, minor=1 (e.g. sda1)
300        assert_eq!(major(dev), 8);
301        assert_eq!(minor(dev), 1);
302    }
303
304    #[test]
305    fn test_parse_dev_field() {
306        assert_eq!(parse_dev_field("8:1"), Some((8, 1)));
307        assert_eq!(parse_dev_field("254:0"), Some((254, 0)));
308        assert_eq!(parse_dev_field("invalid"), None);
309        assert_eq!(parse_dev_field("8:"), None);
310        assert_eq!(parse_dev_field(":1"), None);
311    }
312
313    #[test]
314    fn test_resolve_device_for_root() {
315        // Root filesystem should always be resolvable
316        let path = Path::new("/");
317        let result = path.resolve_device();
318        // This might fail in some CI environments without proper /sys
319        if result.is_ok() {
320            let device = result.unwrap();
321            assert!(device.to_string_lossy().starts_with("/dev"));
322        }
323    }
324
325    #[test]
326    fn test_resolve_device_for_temp_file() {
327        let temp_dir = TempDir::new().unwrap();
328        let temp_path = temp_dir.path();
329
330        let result = temp_path.resolve_device();
331        // This might fail in some CI environments
332        if result.is_ok() {
333            let device = result.unwrap();
334            assert!(device.to_string_lossy().starts_with("/dev"));
335        }
336    }
337
338    #[test]
339    fn test_resolve_device_from_file() {
340        let file = File::open("/").unwrap();
341        let result = file.resolve_device();
342        // This might fail in some CI environments without proper /sys
343        if result.is_ok() {
344            let device = result.unwrap();
345            assert!(device.to_string_lossy().starts_with("/dev"));
346        }
347    }
348
349    #[test]
350    fn test_resolve_device_nonexistent() {
351        let path = Path::new("/nonexistent/path/that/does/not/exist");
352        let result = path.resolve_device();
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_parse_mountinfo_line() {
358        // Example mountinfo line
359        let line = "29 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw";
360        let result = parse_mountinfo_line(line, 8, 1);
361        assert_eq!(result, Some(PathBuf::from("/dev/sda1")));
362
363        // Non-matching line
364        let result = parse_mountinfo_line(line, 9, 2);
365        assert!(result.is_none());
366    }
367
368    #[test]
369    fn test_parse_mountinfo_line_with_special_fs() {
370        // tmpfs doesn't have a real device
371        let line = "22 20 0:21 / /dev/shm rw,nosuid,nodev shared:3 - tmpfs tmpfs rw";
372        let result = parse_mountinfo_line(line, 0, 21);
373        // tmpfs doesn't start with /, so it returns None or tries /dev/tmpfs
374        // This should return None since /dev/tmpfs doesn't exist
375        assert!(result.is_none() || result == Some(PathBuf::from("/dev/tmpfs")));
376    }
377
378    #[test]
379    fn test_pathbuf_resolve_device() {
380        let pathbuf = PathBuf::from("/");
381        let result = pathbuf.resolve_device();
382        // This might fail in some CI environments without proper /sys
383        if result.is_ok() {
384            let device = result.unwrap();
385            assert!(device.to_string_lossy().starts_with("/dev"));
386        }
387    }
388}