filesystem_table/
lib.rs

1use std::{path::PathBuf, str::FromStr};
2
3use thiserror::Error;
4
5#[derive(Error, Debug)]
6pub enum FsTableError {
7    #[error("Invalid fstab entry: {0}")]
8    InvalidEntry(String),
9
10    #[error("Invalid number conversion: {0}")]
11    InvalidNumberConversion(String),
12
13    #[error("Invalid fsck order: {0}")]
14    InvalidFsckOrder(u8),
15
16    #[error("IO error: {0}")]
17    IoError(#[from] std::io::Error),
18
19    #[error("lsblk error: {0}")]
20    LsblkError(#[from] lsblk::LsblkError),
21}
22
23type Result<T> = std::result::Result<T, FsTableError>;
24
25/// The order in which the filesystems should be checked.
26#[derive(Debug, Clone, Default)]
27#[repr(u8)]
28pub enum FsckOrder {
29    /// Never check the filesystem automatically.
30    #[default]
31    NoCheck = 0,
32    /// Check the filesystem while booting.
33    Boot = 1,
34    /// Check the filesystem after the boot process has finished.
35    PostBoot = 2,
36}
37
38impl TryFrom<&u8> for FsckOrder {
39    type Error = FsTableError;
40
41    fn try_from(value: &u8) -> Result<Self> {
42        match value {
43            0 => Ok(Self::NoCheck),
44            1 => Ok(Self::Boot),
45            2 => Ok(Self::PostBoot),
46            _ => Err(FsTableError::InvalidFsckOrder(*value)),
47        }
48    }
49}
50
51impl TryFrom<u8> for FsckOrder {
52    type Error = FsTableError;
53
54    fn try_from(value: u8) -> Result<Self> {
55        // use the TryFrom<&u8> implementation
56        Self::try_from(&value)
57    }
58}
59
60impl TryFrom<&str> for FsckOrder {
61    type Error = FsTableError;
62
63    fn try_from(value: &str) -> Result<Self> {
64        let n = value
65            .parse::<u8>()
66            .map_err(|e| FsTableError::InvalidNumberConversion(e.to_string()))?;
67        Self::try_from(n)
68    }
69}
70
71#[derive(Debug, Clone, Default)]
72pub struct FsEntry {
73    /// The device spec for mounting the filesystem.
74    ///
75    /// Can be a device path, or some kind of filter to get the
76    /// device, i.e `LABEL=ROOT` or `UUID=1234-5678`
77    ///
78    /// Examples:
79    ///
80    /// - `/dev/sda1`
81    /// - `LABEL=ROOT`
82    /// - `UUID=1234-5678`
83    /// - `PARTUUID=1234-5678`
84    /// - `PARTLABEL=ROOT`
85    /// - `PARTUUID=1234-5678`
86    /// - `PARTLABEL=ROOT`
87    pub device_spec: String,
88    /// The mountpoint for the filesystem.
89    /// Specifies where the filesystem should be mounted.
90    ///
91    /// Doesn't actually need to be a real mountpoint, but
92    /// most of the time it will be.
93    ///
94    /// Is an optional field, a [`None`] value will serialize into `none`.
95    ///
96    /// Examples:
97    ///
98    /// - `/`
99    /// - `/boot`
100    /// - `none` (for no mountpoint, used for swap or similar filesystems)
101    /// - `/home`
102    pub mountpoint: Option<String>,
103
104    /// The filesystem type for the filesystem.
105    ///
106    /// Examples:
107    ///
108    /// - `ext4`
109    /// - `btrfs`
110    /// - `vfat`
111    /// - ...
112    pub fs_type: String,
113
114    /// Mount options for the filesystem. Is a comma-separated list of options.
115    ///
116    /// This type returns a vector of strings, as there can be multiple options.
117    /// They will be serialized into a comma-separated list.
118    pub options: Vec<String>,
119
120    /// The dump frequency for the filesystem.
121    ///
122    /// This is a number that specifies how often the filesystem should be backed up.
123    ///
124    pub dump_freq: u8,
125
126    /// The pass number for the filesystem.
127    ///
128    /// Determines when the filesystem health should be checked using `fsck`.
129    pub pass: FsckOrder,
130}
131
132impl FsEntry {
133    /// Parse a FsEntry from a line in the fstab file.
134    pub fn from_line_str(line: &str) -> std::result::Result<Self, FsTableError> {
135        // split by whitespace
136        let parts: Vec<&str> = line.split_whitespace().collect();
137
138        if parts.len() < 6 {
139            return Err(FsTableError::InvalidEntry(line.to_string()));
140        }
141
142        let device_spec = parts[0].to_string();
143
144        let mountpoint = if parts[1] == "none" {
145            None
146        } else {
147            Some(parts[1].to_string())
148        };
149
150        let fs_type = parts[2].to_string();
151
152        let options = parts[3].split(',').map(|s| s.to_string()).collect();
153
154        let dump_freq = parts[4]
155            .parse::<u8>()
156            .map_err(|_| FsTableError::InvalidEntry(line.to_string()))?;
157        let pass = FsckOrder::try_from(parts[5])?;
158
159        Ok(Self {
160            device_spec,
161            mountpoint,
162            fs_type,
163            options,
164            dump_freq,
165            pass,
166        })
167    }
168
169    /// Serialize the FsEntry into a string that can be written to the fstab file.
170    pub fn to_line_str(&self) -> String {
171        let mountpoint = self.mountpoint.as_deref().unwrap_or("none");
172        let options = if self.options.is_empty() {
173            "defaults".to_string()
174        } else {
175            self.options.join(",")
176        };
177        let pass = self.pass.clone() as u8;
178
179        format!(
180            "{device_spec}\t{mountpoint}\t{fs_type}\t{options}\t{dump_freq}\t{pass}",
181            device_spec = self.device_spec,
182            mountpoint = mountpoint,
183            fs_type = self.fs_type,
184            options = options,
185            pass = pass,
186            dump_freq = self.dump_freq,
187        )
188    }
189}
190
191impl TryFrom<&str> for FsEntry {
192    type Error = FsTableError;
193
194    fn try_from(value: &str) -> Result<Self> {
195        Self::from_line_str(value)
196    }
197}
198
199impl std::fmt::Display for FsEntry {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.write_str(&self.to_line_str())
202    }
203}
204
205#[derive(Debug)]
206pub struct FsTable {
207    pub entries: Vec<FsEntry>,
208}
209
210impl FromStr for FsTable {
211    type Err = FsTableError;
212
213    fn from_str(table: &str) -> Result<Self> {
214        let entries = table
215            .lines()
216            .map(FsEntry::from_line_str)
217            .collect::<Result<Vec<FsEntry>>>()?;
218
219        Ok(Self { entries })
220    }
221}
222
223impl std::fmt::Display for FsTable {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        self.entries
226            .iter()
227            .map(|entry| entry.to_line_str())
228            .collect::<Vec<_>>()
229            .join("\n")
230            .as_str()
231            .fmt(f)
232    }
233}
234
235// impl ToString for FsTable {
236//     fn to_string(&self) -> String {
237//         self.to_string()
238//     }
239// }
240
241impl TryFrom<&str> for FsTable {
242    type Error = FsTableError;
243
244    fn try_from(value: &str) -> Result<Self> {
245        Self::from_str(value)
246    }
247}
248
249pub fn read_mtab() -> Result<FsTable> {
250    let mtab = std::fs::read_to_string("/etc/mtab")
251        .map_err(|e| FsTableError::InvalidEntry(e.to_string()))?;
252    FsTable::from_str(&mtab)
253}
254
255pub fn read_fstab() -> Result<FsTable> {
256    let fstab = std::fs::read_to_string("/etc/fstab")
257        .map_err(|e| FsTableError::InvalidEntry(e.to_string()))?;
258    FsTable::from_str(&fstab)
259}
260
261/// Generate a new fstab from mtab, using a chroot prefix to generate the new fstab.
262/// 
263/// This is useful when you want to generate a new fstab for a chroot environment.
264/// 
265/// 
266/// # Example
267/// 
268/// ```rust
269/// let fstab = generate_fstab("/mnt/custom").unwrap();
270/// 
271/// println!("{}", fstab.to_string());
272/// ```
273/// 
274/// This will generate a new fstab for the `/mnt/custom` chroot.
275pub fn generate_fstab(prefix: &str) -> Result<FsTable> {
276    let mtab = read_mtab()?;
277    
278    // if prefix ends with /, strip it
279    // 
280    // This solves some common cases where the prefix contains a trailing slash,
281    // causing only the subdirectories to be matched.
282    let prefix = prefix.trim_end_matches('/');
283
284    let block_list = lsblk::BlockDevice::list()?;
285
286    // filter by prefix 
287    let entries = (mtab.entries.into_iter())
288        .filter(|entry| (entry.mountpoint.as_ref()).is_some_and(|mp| mp.starts_with(prefix)))
289        .map(|mut entry| -> Result<FsEntry> {
290            entry.mountpoint = Some(
291                match entry.mountpoint.unwrap().strip_prefix(prefix).unwrap() {
292                    "" => "/",
293                    path => path,
294                }
295                .to_string(),
296            );
297
298            let device_spec_og = entry.device_spec.clone();
299
300            let uuid = block_list
301                .iter()
302                .find(|dev| dev.fullname == PathBuf::from(&device_spec_og))
303                .and_then(|dev| dev.uuid.as_ref())
304                .ok_or_else(|| {
305                    FsTableError::InvalidEntry(format!(
306                        "Could not find UUID for device: {}",
307                        device_spec_og
308                    ))
309                })?;
310            entry.device_spec = format!("UUID={uuid}");
311            Ok(entry)
312        })
313        .collect::<Result<Vec<_>>>()?;
314
315    Ok(FsTable { entries })
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_fstab_parse() {
324        let line = "/dev/sda1\t/\text4\trw,relatime\t0\t1";
325        let entry = FsEntry::from_line_str(line).unwrap();
326
327        assert_eq!(entry.device_spec, "/dev/sda1");
328        assert_eq!(entry.mountpoint, Some("/".to_string()));
329        assert_eq!(entry.fs_type, "ext4");
330        assert_eq!(entry.options, vec!["rw", "relatime"]);
331        assert_eq!(entry.dump_freq, 0);
332        assert_eq!(entry.pass as u8, 1);
333    }
334
335    #[test]
336    fn test_fstab_serialize() {
337        let entry = FsEntry {
338            device_spec: "/dev/sda1".to_string(),
339            mountpoint: Some("/".to_string()),
340            fs_type: "ext4".to_string(),
341            options: vec!["rw".to_string(), "relatime".to_string()],
342            dump_freq: 0,
343            pass: FsckOrder::Boot,
344        };
345
346        assert_eq!(entry.to_line_str(), "/dev/sda1\t/\text4\trw,relatime\t0\t1");
347    }
348
349    #[test]
350    fn test_fsck_order() {
351        assert_eq!(FsckOrder::try_from(&0u8).unwrap() as u8, 0);
352        assert_eq!(FsckOrder::try_from(&1u8).unwrap() as u8, 1);
353        assert_eq!(FsckOrder::try_from(&2u8).unwrap() as u8, 2);
354        assert!(FsckOrder::try_from(&3u8).is_err());
355    }
356
357    #[test]
358    fn test_fstab_table() {
359        let table = "/dev/sda1\t/\text4\trw,relatime\t0\t1\n/dev/sda2\tnone\tswap\tsw\t0\t0";
360        let fstab = FsTable::from_str(table).unwrap();
361
362        assert_eq!(fstab.entries.len(), 2);
363        assert_eq!(fstab.entries[0].device_spec, "/dev/sda1");
364        assert_eq!(fstab.entries[1].device_spec, "/dev/sda2");
365
366        let serialized = fstab.to_string();
367        assert_eq!(serialized, table);
368    }
369
370    #[test]
371    fn test_mtab_parse() {
372        let mtab = std::fs::read_to_string("/etc/mtab").unwrap();
373
374        let table = FsTable::from_str(&mtab).unwrap();
375
376        println!("{:#?}", table.to_string());
377    }
378    
379    #[test]
380    fn test_generate_fstab() {
381        let fstab = generate_fstab("/mnt/custom").unwrap();
382
383        println!("{}", fstab.to_string());
384        
385        // check if theres newlines
386        assert!(fstab.to_string().contains('\n'));
387    }
388}