Skip to main content

graphforge_filesystem/
inode_generation.rs

1//! The generation number of an inode.
2//!
3//! A filesystem reuses the inode number of a deleted file, and its creation time
4//! comes from a coarse clock that repeats for a whole timer tick, so neither
5//! tells a file from a deleted file whose number it inherited. The generation
6//! number does: ext4, XFS and btrfs assign a new one every time an inode is
7//! reused. It is read with `FS_IOC_GETVERSION`, from the admitted filesystem
8//! classes only.
9
10use std::fs::File;
11use std::io;
12
13/// The generation number of the inode behind `file`.
14///
15/// Together with the volume and inode number it names one file for its whole
16/// life. A filesystem that cannot report one, and a platform that cannot ask,
17/// is an error: nothing here falls back to a weaker name.
18///
19/// # Errors
20/// `Unsupported` where the filesystem or platform has no generation number,
21/// otherwise the error of the call.
22pub fn inode_generation(file: &File) -> io::Result<u32> {
23    platform(file)
24}
25
26/// The class of a Linux local filesystem whose durability protocol is admitted
27/// (the `f_type` of `statfs`): ext2/3/4, XFS and btrfs. Their inode generation
28/// numbers are the ones this crate relies on.
29#[must_use]
30pub fn admitted_filesystem_class(f_type: u64) -> Option<&'static str> {
31    match f_type {
32        0xEF53 => Some("ext"),
33        0x5846_5342 => Some("xfs"),
34        0x9123_683E => Some("btrfs"),
35        _ => None,
36    }
37}
38
39#[cfg(target_os = "linux")]
40fn platform(file: &File) -> io::Result<u32> {
41    let unsupported = |reason: String| {
42        io::Error::new(
43            io::ErrorKind::Unsupported,
44            format!("no inode generation for this file: {reason}"),
45        )
46    };
47    // Only an admitted class is asked. Another filesystem (a FUSE mount, say)
48    // may answer the request in ways this does not model.
49    let stat = rustix::fs::fstatfs(file)
50        .map_err(|error| unsupported(format!("the filesystem cannot be classified: {error}")))?;
51    let class = u64::try_from(stat.f_type).unwrap_or_default();
52    if admitted_filesystem_class(class).is_none() {
53        return Err(unsupported(format!(
54            "filesystem type {class:#x} is not an admitted class (ext, xfs, btrfs)"
55        )));
56    }
57    get_version(file).map_err(|error| unsupported(format!("FS_IOC_GETVERSION is refused: {error}")))
58}
59
60#[cfg(target_os = "linux")]
61#[allow(unsafe_code)]
62fn get_version(file: &File) -> rustix::io::Result<u32> {
63    use rustix::ioctl::{Ioctl, IoctlOutput, Opcode, ioctl, opcode};
64
65    /// `FS_IOC_GETVERSION`, `_IOR('v', 1, long)`.
66    ///
67    /// The request number says the kernel may write a `long`, and a filesystem
68    /// that sizes its write from the request (FUSE does) writes that many bytes,
69    /// so the buffer is a whole zeroed `long`. ext4, XFS and btrfs `put_user` one
70    /// `int`, the generation, at the start of it; that is the `u32` read back,
71    /// in the machine's byte order, from the first four bytes.
72    struct GetVersion {
73        buffer: core::ffi::c_long,
74    }
75
76    // SAFETY: `opcode` is `FS_IOC_GETVERSION`; `as_ptr` addresses `buffer`, an
77    // initialized, aligned `long` that is the size the request names, so the
78    // kernel writes within it whatever the filesystem; `output_from_ptr` reads
79    // four initialized bytes from the start of it.
80    unsafe impl Ioctl for GetVersion {
81        type Output = u32;
82
83        const IS_MUTATING: bool = true;
84
85        fn opcode(&self) -> Opcode {
86            opcode::read::<core::ffi::c_long>(b'v', 1)
87        }
88
89        fn as_ptr(&mut self) -> *mut core::ffi::c_void {
90            std::ptr::addr_of_mut!(self.buffer).cast()
91        }
92
93        unsafe fn output_from_ptr(
94            _: IoctlOutput,
95            pointer: *mut core::ffi::c_void,
96        ) -> rustix::io::Result<u32> {
97            // SAFETY: the caller passes the pointer `as_ptr` returned, to the
98            // buffer above, which is at least four bytes and aligned for `u32`.
99            Ok(unsafe { pointer.cast::<u32>().read() })
100        }
101    }
102
103    // SAFETY: see the implementation above; the descriptor is open while
104    // `file` is borrowed.
105    unsafe { ioctl(file, GetVersion { buffer: 0 }) }
106}
107
108#[cfg(not(target_os = "linux"))]
109fn platform(_file: &File) -> io::Result<u32> {
110    Err(io::Error::new(
111        io::ErrorKind::Unsupported,
112        "inode generation numbers are only read on Linux",
113    ))
114}
115
116#[cfg(all(test, target_os = "linux"))]
117mod tests {
118    use super::*;
119
120    /// The property that makes the number a name: across many create and delete
121    /// cycles, no (inode number, generation) pair is shared by two distinct file
122    /// lifetimes. That holds whether or not the filesystem reuses inode numbers
123    /// (btrfs never does within a mount). Where it did reuse one (ext4 does,
124    /// at once), the generation must also have changed; the run says whether the
125    /// discriminating case happened.
126    #[test]
127    fn no_inode_number_and_generation_name_two_file_lifetimes() {
128        const CYCLES: usize = 20_000;
129        const ENOUGH_REUSE: usize = 100;
130        let directory = tempfile::tempdir().unwrap();
131        let mut generations: std::collections::HashMap<(u64, [u8; 16]), Vec<u32>> =
132            std::collections::HashMap::new();
133        let mut reused = 0_usize;
134        for cycle in 0..CYCLES {
135            let path = directory.path().join(format!("file-{cycle}"));
136            let file = File::create(&path).unwrap();
137            let identity = crate::file_identity(&file).unwrap();
138            let generation = inode_generation(&file).unwrap();
139            // Stable for the lifetime.
140            assert_eq!(generation, inode_generation(&file).unwrap());
141            std::fs::remove_file(&path).unwrap();
142            drop(file);
143            let earlier = generations
144                .entry((identity.volume_serial, identity.file_id))
145                .or_default();
146            if !earlier.is_empty() {
147                reused += 1;
148            }
149            assert!(
150                !earlier.contains(&generation),
151                "inode {:?} was named by generation {generation} in two file lifetimes",
152                identity.file_id
153            );
154            earlier.push(generation);
155            if reused >= ENOUGH_REUSE {
156                break;
157            }
158        }
159        println!("inode numbers reused {reused} times; every reuse had a new generation");
160    }
161
162    /// A filesystem that is not an admitted class (procfs here) is refused
163    /// before the request is made: an error, never a default.
164    #[test]
165    fn a_filesystem_that_is_not_admitted_is_an_error() {
166        let file = File::open("/proc/self/status").unwrap();
167        let error = inode_generation(&file).unwrap_err();
168        assert_eq!(error.kind(), io::ErrorKind::Unsupported, "{error}");
169        assert!(
170            error.to_string().contains("not an admitted class"),
171            "{error}"
172        );
173    }
174
175    /// The directory a test runs in is an admitted class, so its inodes have
176    /// generations.
177    #[test]
178    fn an_admitted_filesystem_reports_a_generation() {
179        let directory = tempfile::tempdir().unwrap();
180        let file = File::create(directory.path().join("file")).unwrap();
181        inode_generation(&file).unwrap();
182        let directory = File::open(directory.path()).unwrap();
183        inode_generation(&directory).unwrap();
184    }
185}