Skip to main content

mount_fstab/
options.rs

1//! Mount options — fstab(5) field 4 (`fs_mntops`).
2//!
3//! Provides parsing, serialization, classification (VFS/filesystem/userspace),
4//! and querying of comma-separated mount options with support for quoted
5//! values containing commas.
6
7use crate::error::{OptItemError, OptionsError};
8use crate::escape::decode_escapes;
9use std::fmt;
10use std::str::FromStr;
11
12/// Classification of a mount option.
13///
14/// Determines whether an option belongs to the VFS layer, a specific
15/// filesystem driver, or userspace.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[non_exhaustive]
19pub enum OptionClass {
20    /// VFS (Virtual File System) option — applies at the kernel VFS layer.
21    /// Examples: `ro`, `noatime`, `bind`, `suid`.
22    Vfs,
23    /// Filesystem-specific option — handled by the individual filesystem driver.
24    /// Examples: `sync`, `async`.
25    Filesystem,
26    /// Userspace option — consumed by userspace mount helpers.
27    /// Examples: `defaults`, `user`, `_netdev`, `nofail`.
28    Userspace,
29}
30
31/// A single mount option item, consisting of a name and an optional value.
32///
33/// # Examples
34///
35/// ```
36/// # use mount_fstab::options::OptItem;
37/// let item = OptItem::flag("ro").unwrap();
38/// assert_eq!(item.name(), "ro");
39/// assert!(item.value().is_none());
40///
41/// let item = OptItem::new("size", Some("10G".into())).unwrap();
42/// assert_eq!(item.value(), Some("10G"));
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct OptItem {
47    name: String,
48    value: Option<String>,
49}
50
51impl OptItem {
52    /// Create a new option item with an optional value.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`OptItemError::EmptyName`] if the name is empty.
57    pub fn new(name: impl Into<String>, value: Option<String>) -> Result<Self, OptItemError> {
58        let name = name.into();
59        if name.is_empty() {
60            return Err(OptItemError::EmptyName);
61        }
62        Ok(OptItem { name, value })
63    }
64
65    /// Create a flag option (no value).
66    ///
67    /// # Errors
68    ///
69    /// Returns [`OptItemError::EmptyName`] if the name is empty.
70    pub fn flag(name: impl Into<String>) -> Result<Self, OptItemError> {
71        Self::new(name, None)
72    }
73
74    /// The option name.
75    #[must_use]
76    pub fn name(&self) -> &str {
77        &self.name
78    }
79
80    /// The option value, if present.
81    #[must_use]
82    pub fn value(&self) -> Option<&str> {
83        self.value.as_deref()
84    }
85
86    /// The classification of this option (VFS, filesystem, or userspace).
87    ///
88    /// Returns `None` for unknown options.
89    #[must_use]
90    pub fn class(&self) -> Option<OptionClass> {
91        classify_option(&self.name)
92    }
93
94    /// Whether this option is a known option (has a classification).
95    #[must_use]
96    pub fn is_known(&self) -> bool {
97        self.class().is_some()
98    }
99}
100
101impl fmt::Display for OptItem {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match &self.value {
104            Some(v) if v.contains(',') => write!(f, "{}=\"{}\"", self.name, v),
105            Some(v) => write!(f, "{}={}", self.name, v),
106            None => write!(f, "{}", self.name),
107        }
108    }
109}
110
111/// Ordered collection of mount options — fstab(5) field 4.
112///
113/// Options are stored in order and parsed from a comma-separated string
114/// with support for quoted values containing commas.
115///
116/// # Examples
117///
118/// ```
119/// # use mount_fstab::options::Options;
120/// let opts = Options::parse("rw,noatime,size=10G").unwrap();
121/// assert!(opts.has("rw"));
122/// assert_eq!(opts.get("size"), Some("10G"));
123/// assert_eq!(opts.len(), 3);
124/// ```
125#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127pub struct Options {
128    items: Vec<OptItem>,
129}
130
131impl Options {
132    /// Create an empty options collection.
133    #[must_use]
134    pub fn new() -> Self {
135        Options { items: Vec::new() }
136    }
137
138    /// Create options with a single `defaults` flag.
139    ///
140    /// This matches the convention that "defaults" is the standard
141    /// placeholder for default mount options in `/etc/fstab`.
142    #[must_use]
143    pub fn defaults() -> Self {
144        Options {
145            items: vec![OptItem {
146                name: "defaults".to_owned(),
147                value: None,
148            }],
149        }
150    }
151
152    /// Parse a comma-separated options string.
153    ///
154    /// Supports quoted values (both single and double quotes) to preserve
155    /// commas within values (e.g., `context="unconfined_u:object_r:user_tmp_t:s0"`).
156    ///
157    /// # Errors
158    ///
159    /// Returns [`OptionsError::EmptyOptionName`] if an option has an empty name.
160    pub fn parse(raw: &str) -> Result<Self, OptionsError> {
161        if raw.is_empty() {
162            return Ok(Options::new());
163        }
164        let tokens = split_options(raw);
165        let mut items = Vec::with_capacity(tokens.len());
166        for token in tokens {
167            let decoded = decode_escapes(token);
168            let item = if let Some(eq) = decoded.find('=') {
169                let name = decoded[..eq].to_owned();
170                let raw_value = &decoded[eq + 1..];
171                let value = strip_quotes(raw_value);
172                OptItem::new(name, Some(value.to_owned()))
173                    .map_err(|_| OptionsError::EmptyOptionName)?
174            } else {
175                OptItem::flag(decoded).map_err(|_| OptionsError::EmptyOptionName)?
176            };
177            items.push(item);
178        }
179        Ok(Options { items })
180    }
181
182    /// Returns `true` if the options collection is empty.
183    #[must_use]
184    pub fn is_empty(&self) -> bool {
185        self.items.is_empty()
186    }
187
188    /// Number of options in this collection.
189    #[must_use]
190    pub fn len(&self) -> usize {
191        self.items.len()
192    }
193
194    /// Get the value of the last occurrence of an option by name.
195    ///
196    /// Returns `None` if the option is not found or is a flag (no value).
197    /// Returns `Some("")` if the option was specified with an empty value
198    /// (e.g., `key=`).
199    ///
200    /// # Last-option-wins semantics
201    ///
202    /// Per mount(8), if an option appears multiple times, the last occurrence
203    /// wins. This method scans from the end of the list.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// # use mount_fstab::Options;
209    /// let opts = Options::parse("size=10G,ro").unwrap();
210    /// assert_eq!(opts.get("size"), Some("10G"));
211    /// assert_eq!(opts.get("ro"), None); // flag option has no value
212    /// assert_eq!(opts.get("nonexistent"), None);
213    /// ```
214    #[must_use]
215    pub fn get(&self, name: &str) -> Option<&str> {
216        self.items
217            .iter()
218            .rev()
219            .find(|item| item.name == name)
220            .and_then(|item| item.value.as_deref())
221    }
222
223    /// Check whether an option by name is present (as a flag or key=value).
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// # use mount_fstab::Options;
229    /// let opts = Options::parse("rw,noatime,size=10G").unwrap();
230    /// assert!(opts.has("rw"));
231    /// assert!(opts.has("noatime"));
232    /// assert!(opts.has("size"));
233    /// assert!(!opts.has("ro"));
234    /// ```
235    #[must_use]
236    pub fn has(&self, name: &str) -> bool {
237        self.items.iter().any(|item| item.name == name)
238    }
239
240    /// Alias for [`has`](Self::has), for consistency with `std::collections::HashSet`.
241    #[must_use]
242    pub fn contains(&self, name: &str) -> bool {
243        self.has(name)
244    }
245
246    /// Iterate over all option items in order.
247    pub fn iter(&self) -> impl Iterator<Item = &OptItem> {
248        self.items.iter()
249    }
250
251    /// Whether the options imply a read-only mount.
252    ///
253    /// Uses last-option-wins semantics: `ro` returns `true`,
254    /// `rw` or `defaults` returns `false`.
255    #[must_use]
256    pub fn is_readonly(&self) -> bool {
257        for item in self.items.iter().rev() {
258            match item.name.as_str() {
259                "ro" => return true,
260                "rw" => return false,
261                "defaults" => return false,
262                _ => {}
263            }
264        }
265        false
266    }
267
268    /// Whether the options imply `noauto` (do not mount automatically at boot).
269    ///
270    /// Uses last-option-wins semantics.
271    #[must_use]
272    pub fn is_noauto(&self) -> bool {
273        for item in self.items.iter().rev() {
274            match item.name.as_str() {
275                "noauto" => return true,
276                "auto" => return false,
277                "defaults" => return false,
278                _ => {}
279            }
280        }
281        false
282    }
283
284    /// Whether the `nofail` option is present (do not halt boot on mount failure).
285    #[must_use]
286    pub fn has_nofail(&self) -> bool {
287        self.has("nofail")
288    }
289
290    /// Whether the `_netdev` option is present (network-backed device).
291    #[must_use]
292    pub fn is_netdev(&self) -> bool {
293        self.has("_netdev")
294    }
295
296    /// Determine the mount permission model based on options.
297    ///
298    /// Uses last-option-wins semantics.
299    #[must_use]
300    pub fn mount_permission(&self) -> MountPermission {
301        for item in self.items.iter().rev() {
302            match item.name.as_str() {
303                "user" => return MountPermission::User,
304                "users" => return MountPermission::Users,
305                "owner" => return MountPermission::Owner,
306                "group" => return MountPermission::Group,
307                "nouser" => return MountPermission::None,
308                "defaults" => return MountPermission::None,
309                _ => {}
310            }
311        }
312        MountPermission::None
313    }
314
315    /// Set a mount option, replacing any existing occurrence with the same name.
316    ///
317    /// The option is appended to the end (last-option-wins position).
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// # use mount_fstab::Options;
323    /// let mut opts = Options::parse("rw,noatime").unwrap();
324    /// opts.set("ro", None).set("size", Some("10G"));
325    /// assert!(opts.has("ro"));
326    /// assert_eq!(opts.get("size"), Some("10G"));
327    /// // Original "rw" replaced by "ro" (last-option-wins)
328    /// assert!(opts.is_readonly());
329    /// ```
330    ///
331    /// Returns `&mut Self` for chaining.
332    pub fn set(&mut self, name: &str, value: Option<&str>) -> &mut Self {
333        self.items.retain(|item| item.name != name);
334        self.items.push(OptItem {
335            name: name.to_owned(),
336            value: value.map(|v| v.to_owned()),
337        });
338        self
339    }
340
341    /// Remove all occurrences of a mount option by name.
342    ///
343    /// Returns `&mut Self` for chaining.
344    pub fn remove(&mut self, name: &str) -> &mut Self {
345        self.items.retain(|item| item.name != name);
346        self
347    }
348
349    /// Append an option item to the end of the list.
350    ///
351    /// Returns `&mut Self` for chaining.
352    pub fn append(&mut self, item: OptItem) -> &mut Self {
353        self.items.push(item);
354        self
355    }
356
357    /// Prepend an option item at the beginning of the list.
358    ///
359    /// Returns `&mut Self` for chaining.
360    pub fn prepend(&mut self, item: OptItem) -> &mut Self {
361        self.items.insert(0, item);
362        self
363    }
364
365    /// Iterate over VFS-classified options only.
366    pub fn vfs_options(&self) -> impl Iterator<Item = &OptItem> {
367        self.items
368            .iter()
369            .filter(|item| item.class() == Some(OptionClass::Vfs))
370    }
371
372    /// Iterate over filesystem-specific options only.
373    pub fn fs_options(&self) -> impl Iterator<Item = &OptItem> {
374        self.items
375            .iter()
376            .filter(|item| item.class() == Some(OptionClass::Filesystem))
377    }
378
379    /// Iterate over userspace options only.
380    pub fn user_options(&self) -> impl Iterator<Item = &OptItem> {
381        self.items
382            .iter()
383            .filter(|item| item.class() == Some(OptionClass::Userspace))
384    }
385}
386
387impl FromStr for Options {
388    type Err = OptionsError;
389
390    /// Parse a comma-separated options string into an `Options` collection.
391    ///
392    /// Equivalent to [`Options::parse`].
393    fn from_str(s: &str) -> Result<Self, Self::Err> {
394        Options::parse(s)
395    }
396}
397
398/// Try to convert a string into `Options`.
399///
400/// Equivalent to [`Options::parse`].
401impl TryFrom<&str> for Options {
402    type Error = OptionsError;
403
404    fn try_from(s: &str) -> Result<Self, Self::Error> {
405        Options::parse(s)
406    }
407}
408
409/// Mount permission level for non-root users.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
412#[non_exhaustive]
413pub enum MountPermission {
414    /// No special permission granted.
415    None,
416    /// Any user can mount (`user`).
417    User,
418    /// Any user can mount or unmount (`users`).
419    Users,
420    /// Only the device owner can mount (`owner`).
421    Owner,
422    /// Only group members can mount (`group`).
423    Group,
424}
425
426impl fmt::Display for Options {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        for (i, item) in self.items.iter().enumerate() {
429            if i > 0 {
430                f.write_str(",")?;
431            }
432            write!(f, "{item}")?;
433        }
434        Ok(())
435    }
436}
437
438// ── Internal helpers ──
439
440/// Split a comma-separated options string, respecting quoted values.
441fn split_options(raw: &str) -> Vec<&str> {
442    let mut items = Vec::new();
443    let mut start = 0;
444    let mut quote: Option<char> = None;
445    for (i, ch) in raw.char_indices() {
446        match (quote, ch) {
447            (None, '"') => quote = Some('"'),
448            (None, '\'') => quote = Some('\''),
449            (Some(q), c) if c == q => quote = None,
450            (None, ',') => {
451                items.push(&raw[start..i]);
452                start = i + 1;
453            }
454            _ => {}
455        }
456    }
457    items.push(&raw[start..]);
458    items
459}
460
461/// Strip matching surrounding quotes from a value string.
462fn strip_quotes(s: &str) -> &str {
463    let s = s.trim();
464    if s.len() >= 2 {
465        let bytes = s.as_bytes();
466        if (bytes[0] == b'"' && bytes[s.len() - 1] == b'"')
467            || (bytes[0] == b'\'' && bytes[s.len() - 1] == b'\'')
468        {
469            return &s[1..s.len() - 1];
470        }
471    }
472    s
473}
474
475/// Known VFS mount option names.
476const VFS_OPTIONS: &[&str] = &[
477    "ro",
478    "rw",
479    "exec",
480    "noexec",
481    "suid",
482    "nosuid",
483    "dev",
484    "nodev",
485    "remount",
486    "bind",
487    "rbind",
488    "atime",
489    "noatime",
490    "diratime",
491    "nodiratime",
492    "relatime",
493    "norelatime",
494    "strictatime",
495    "nostrictatime",
496    "symfollow",
497    "nosymfollow",
498    "silent",
499    "loud",
500    "iversion",
501    "noiversion",
502    "shared",
503    "rshared",
504    "slave",
505    "rslave",
506    "private",
507    "rprivate",
508    "unbindable",
509    "runbindable",
510];
511
512/// Known filesystem-specific mount option names.
513const FS_OPTIONS: &[&str] = &["sync", "async", "dirsync"];
514
515/// Known userspace mount option names.
516const USER_OPTIONS: &[&str] = &[
517    "defaults",
518    "auto",
519    "noauto",
520    "user",
521    "nouser",
522    "users",
523    "owner",
524    "group",
525    "_netdev",
526    "nofail",
527    "loop",
528    "offset",
529    "sizelimit",
530    "encryption",
531    "uhelper",
532    "helper",
533];
534
535/// Classify a mount option by name.
536fn classify_option(name: &str) -> Option<OptionClass> {
537    if name.starts_with("X-") || name.starts_with("x-") || name == "comment" {
538        return Some(OptionClass::Userspace);
539    }
540    if name.starts_with("verity.") {
541        return Some(OptionClass::Userspace);
542    }
543    if VFS_OPTIONS.contains(&name) {
544        return Some(OptionClass::Vfs);
545    }
546    if FS_OPTIONS.contains(&name) {
547        return Some(OptionClass::Filesystem);
548    }
549    if USER_OPTIONS.contains(&name) {
550        return Some(OptionClass::Userspace);
551    }
552    None
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn split_options_with_quotes() {
561        let result = split_options(r#"context="a,b",noatime"#);
562        assert_eq!(result, vec![r#"context="a,b""#, "noatime"]);
563    }
564
565    #[test]
566    fn split_simple() {
567        assert_eq!(split_options("a,b,c"), vec!["a", "b", "c"]);
568    }
569
570    #[test]
571    fn strip_double_quotes() {
572        assert_eq!(strip_quotes("\"hello\""), "hello");
573    }
574
575    #[test]
576    fn strip_single_quotes() {
577        assert_eq!(strip_quotes("'hello'"), "hello");
578    }
579
580    #[test]
581    fn parse_simple_flag() {
582        let opts = Options::parse("defaults").unwrap();
583        assert!(opts.has("defaults"));
584    }
585
586    #[test]
587    fn parse_multiple_flags() {
588        let opts = Options::parse("rw,noatime,nofail").unwrap();
589        assert!(opts.has("rw"));
590        assert!(opts.has("noatime"));
591        assert!(opts.has("nofail"));
592    }
593
594    #[test]
595    fn parse_key_value() {
596        let opts = Options::parse("size=10G,mode=755").unwrap();
597        assert_eq!(opts.get("size"), Some("10G"));
598        assert_eq!(opts.get("mode"), Some("755"));
599    }
600
601    #[test]
602    fn last_option_wins() {
603        let opts = Options::parse("ro,rw").unwrap();
604        assert!(!opts.is_readonly());
605        let opts2 = Options::parse("rw,ro").unwrap();
606        assert!(opts2.is_readonly());
607    }
608
609    #[test]
610    fn quoted_value_preserves_comma() {
611        let opts =
612            Options::parse(r#"context="system_u:object_r:tmp_t:s0:c127,c456",noatime"#).unwrap();
613        assert_eq!(
614            opts.get("context"),
615            Some("system_u:object_r:tmp_t:s0:c127,c456")
616        );
617        assert!(opts.has("noatime"));
618    }
619
620    #[test]
621    fn single_quoted_value() {
622        let opts = Options::parse("key='value,with,commas'").unwrap();
623        assert_eq!(opts.get("key"), Some("value,with,commas"));
624    }
625
626    #[test]
627    fn parse_empty_options() {
628        let opts = Options::parse("").unwrap();
629        assert!(opts.is_empty());
630    }
631
632    #[test]
633    fn serialize_simple() {
634        let opts = Options::parse("rw,noatime").unwrap();
635        assert_eq!(opts.to_string(), "rw,noatime");
636    }
637
638    #[test]
639    fn serialize_with_value() {
640        let opts = Options::parse("size=10G,mode=755").unwrap();
641        assert_eq!(opts.to_string(), "size=10G,mode=755");
642    }
643
644    #[test]
645    fn serialize_roundtrip() {
646        let inputs = [
647            "defaults",
648            "rw,noatime,nofail",
649            "size=10G,mode=755",
650            "ro,nosuid,nodev",
651        ];
652        for input in inputs {
653            let opts = Options::parse(input).unwrap();
654            assert_eq!(opts.to_string(), input, "roundtrip failed for: {input}");
655        }
656    }
657
658    #[test]
659    fn len_and_is_empty() {
660        let opts = Options::new();
661        assert!(opts.is_empty());
662        assert_eq!(opts.len(), 0);
663
664        let opts = Options::parse("a,b").unwrap();
665        assert_eq!(opts.len(), 2);
666        assert!(!opts.is_empty());
667    }
668
669    #[test]
670    fn contains_works() {
671        let opts = Options::parse("rw,noatime").unwrap();
672        assert!(opts.contains("rw"));
673        assert!(!opts.contains("foobar"));
674    }
675
676    #[test]
677    fn set_adds_option() {
678        let mut opts = Options::parse("rw").unwrap();
679        opts.set("noatime", None);
680        assert!(opts.has("noatime"));
681    }
682
683    #[test]
684    fn remove_option() {
685        let mut opts = Options::parse("rw,noatime,nofail").unwrap();
686        opts.remove("noatime");
687        assert!(!opts.has("noatime"));
688        assert!(opts.has("rw"));
689        assert!(opts.has("nofail"));
690    }
691
692    #[test]
693    fn append_option() {
694        let mut opts = Options::parse("rw").unwrap();
695        opts.append(OptItem::flag("noatime").unwrap());
696        assert_eq!(opts.to_string(), "rw,noatime");
697    }
698
699    #[test]
700    fn is_readonly() {
701        assert!(Options::parse("ro").unwrap().is_readonly());
702        assert!(!Options::parse("rw").unwrap().is_readonly());
703    }
704
705    #[test]
706    fn has_nofail() {
707        assert!(Options::parse("nofail").unwrap().has_nofail());
708        assert!(!Options::parse("defaults").unwrap().has_nofail());
709    }
710
711    #[test]
712    fn is_netdev() {
713        assert!(Options::parse("_netdev").unwrap().is_netdev());
714        assert!(!Options::parse("defaults").unwrap().is_netdev());
715    }
716
717    #[test]
718    fn option_classification() {
719        let opts = Options::parse("ro,noexec").unwrap();
720        for item in opts.vfs_options() {
721            assert_eq!(item.class(), Some(OptionClass::Vfs));
722        }
723        let opts = Options::parse("sync").unwrap();
724        for item in opts.fs_options() {
725            assert_eq!(item.class(), Some(OptionClass::Filesystem));
726        }
727        let opts = Options::parse("nofail,_netdev").unwrap();
728        for item in opts.user_options() {
729            assert_eq!(item.class(), Some(OptionClass::Userspace));
730        }
731    }
732
733    #[test]
734    fn unknown_option_is_none_class() {
735        let item = OptItem::flag("mycustomopt").unwrap();
736        assert_eq!(item.class(), None);
737    }
738
739    #[test]
740    fn optitem_empty_name_is_error() {
741        assert!(OptItem::flag("").is_err());
742    }
743
744    #[test]
745    fn iter_preserves_order() {
746        let opts = Options::parse("a,b,c,d").unwrap();
747        let names: Vec<&str> = opts.iter().map(|i| i.name()).collect();
748        assert_eq!(names, vec!["a", "b", "c", "d"]);
749    }
750
751    #[test]
752    fn defaults_constructor() {
753        let opts = Options::defaults();
754        assert!(opts.has("defaults"));
755    }
756
757    #[test]
758    fn from_str_works() {
759        let opts: Options = "rw,noatime".parse().unwrap();
760        assert!(opts.has("rw"));
761        assert!(opts.has("noatime"));
762    }
763
764    #[test]
765    fn set_returns_self_for_chaining() {
766        let mut opts = Options::parse("rw").unwrap();
767        opts.set("noatime", None).set("nofail", None);
768        assert!(opts.has("rw"));
769        assert!(opts.has("noatime"));
770        assert!(opts.has("nofail"));
771    }
772
773    #[test]
774    fn remove_returns_self_for_chaining() {
775        let mut opts = Options::parse("a,b,c").unwrap();
776        opts.remove("a").remove("b");
777        let names: Vec<&str> = opts.iter().map(|o| o.name()).collect();
778        assert_eq!(names, vec!["c"]);
779    }
780}