use crate::error::{OptItemError, OptionsError};
use crate::escape::decode_escapes;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum OptionClass {
Vfs,
Filesystem,
Userspace,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OptItem {
name: String,
value: Option<String>,
}
impl OptItem {
pub fn new(name: impl Into<String>, value: Option<String>) -> Result<Self, OptItemError> {
let name = name.into();
if name.is_empty() {
return Err(OptItemError::EmptyName);
}
Ok(OptItem { name, value })
}
pub fn flag(name: impl Into<String>) -> Result<Self, OptItemError> {
Self::new(name, None)
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn value(&self) -> Option<&str> {
self.value.as_deref()
}
#[must_use]
pub fn class(&self) -> Option<OptionClass> {
classify_option(&self.name)
}
#[must_use]
pub fn is_known(&self) -> bool {
self.class().is_some()
}
}
impl fmt::Display for OptItem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.value {
Some(v) if v.contains(',') => write!(f, "{}=\"{}\"", self.name, v),
Some(v) => write!(f, "{}={}", self.name, v),
None => write!(f, "{}", self.name),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Options {
items: Vec<OptItem>,
}
impl Options {
#[must_use]
pub fn new() -> Self {
Options { items: Vec::new() }
}
#[must_use]
pub fn defaults() -> Self {
Options {
items: vec![OptItem {
name: "defaults".to_owned(),
value: None,
}],
}
}
pub fn parse(raw: &str) -> Result<Self, OptionsError> {
if raw.is_empty() {
return Ok(Options::new());
}
let tokens = split_options(raw);
let mut items = Vec::with_capacity(tokens.len());
for token in tokens {
let decoded = decode_escapes(token);
let item = if let Some(eq) = decoded.find('=') {
let name = decoded[..eq].to_owned();
let raw_value = &decoded[eq + 1..];
let value = strip_quotes(raw_value);
OptItem::new(name, Some(value.to_owned()))
.map_err(|_| OptionsError::EmptyOptionName)?
} else {
OptItem::flag(decoded).map_err(|_| OptionsError::EmptyOptionName)?
};
items.push(item);
}
Ok(Options { items })
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&str> {
self.items
.iter()
.rev()
.find(|item| item.name == name)
.and_then(|item| item.value.as_deref())
}
#[must_use]
pub fn has(&self, name: &str) -> bool {
self.items.iter().any(|item| item.name == name)
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.has(name)
}
pub fn iter(&self) -> impl Iterator<Item = &OptItem> {
self.items.iter()
}
#[must_use]
pub fn is_readonly(&self) -> bool {
for item in self.items.iter().rev() {
match item.name.as_str() {
"ro" => return true,
"rw" => return false,
"defaults" => return false,
_ => {}
}
}
false
}
#[must_use]
pub fn is_noauto(&self) -> bool {
for item in self.items.iter().rev() {
match item.name.as_str() {
"noauto" => return true,
"auto" => return false,
"defaults" => return false,
_ => {}
}
}
false
}
#[must_use]
pub fn has_nofail(&self) -> bool {
self.has("nofail")
}
#[must_use]
pub fn is_netdev(&self) -> bool {
self.has("_netdev")
}
#[must_use]
pub fn mount_permission(&self) -> MountPermission {
for item in self.items.iter().rev() {
match item.name.as_str() {
"user" => return MountPermission::User,
"users" => return MountPermission::Users,
"owner" => return MountPermission::Owner,
"group" => return MountPermission::Group,
"nouser" => return MountPermission::None,
"defaults" => return MountPermission::None,
_ => {}
}
}
MountPermission::None
}
pub fn set(&mut self, name: &str, value: Option<&str>) -> &mut Self {
self.items.retain(|item| item.name != name);
self.items.push(OptItem {
name: name.to_owned(),
value: value.map(|v| v.to_owned()),
});
self
}
pub fn remove(&mut self, name: &str) -> &mut Self {
self.items.retain(|item| item.name != name);
self
}
pub fn append(&mut self, item: OptItem) -> &mut Self {
self.items.push(item);
self
}
pub fn prepend(&mut self, item: OptItem) -> &mut Self {
self.items.insert(0, item);
self
}
pub fn vfs_options(&self) -> impl Iterator<Item = &OptItem> {
self.items
.iter()
.filter(|item| item.class() == Some(OptionClass::Vfs))
}
pub fn fs_options(&self) -> impl Iterator<Item = &OptItem> {
self.items
.iter()
.filter(|item| item.class() == Some(OptionClass::Filesystem))
}
pub fn user_options(&self) -> impl Iterator<Item = &OptItem> {
self.items
.iter()
.filter(|item| item.class() == Some(OptionClass::Userspace))
}
}
impl FromStr for Options {
type Err = OptionsError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Options::parse(s)
}
}
impl TryFrom<&str> for Options {
type Error = OptionsError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Options::parse(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum MountPermission {
None,
User,
Users,
Owner,
Group,
}
impl fmt::Display for Options {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, item) in self.items.iter().enumerate() {
if i > 0 {
f.write_str(",")?;
}
write!(f, "{item}")?;
}
Ok(())
}
}
fn split_options(raw: &str) -> Vec<&str> {
let mut items = Vec::new();
let mut start = 0;
let mut quote: Option<char> = None;
for (i, ch) in raw.char_indices() {
match (quote, ch) {
(None, '"') => quote = Some('"'),
(None, '\'') => quote = Some('\''),
(Some(q), c) if c == q => quote = None,
(None, ',') => {
items.push(&raw[start..i]);
start = i + 1;
}
_ => {}
}
}
items.push(&raw[start..]);
items
}
fn strip_quotes(s: &str) -> &str {
let s = s.trim();
if s.len() >= 2 {
let bytes = s.as_bytes();
if (bytes[0] == b'"' && bytes[s.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[s.len() - 1] == b'\'')
{
return &s[1..s.len() - 1];
}
}
s
}
const VFS_OPTIONS: &[&str] = &[
"ro",
"rw",
"exec",
"noexec",
"suid",
"nosuid",
"dev",
"nodev",
"remount",
"bind",
"rbind",
"atime",
"noatime",
"diratime",
"nodiratime",
"relatime",
"norelatime",
"strictatime",
"nostrictatime",
"symfollow",
"nosymfollow",
"silent",
"loud",
"iversion",
"noiversion",
"shared",
"rshared",
"slave",
"rslave",
"private",
"rprivate",
"unbindable",
"runbindable",
];
const FS_OPTIONS: &[&str] = &["sync", "async", "dirsync"];
const USER_OPTIONS: &[&str] = &[
"defaults",
"auto",
"noauto",
"user",
"nouser",
"users",
"owner",
"group",
"_netdev",
"nofail",
"loop",
"offset",
"sizelimit",
"encryption",
"uhelper",
"helper",
];
fn classify_option(name: &str) -> Option<OptionClass> {
if name.starts_with("X-") || name.starts_with("x-") || name == "comment" {
return Some(OptionClass::Userspace);
}
if name.starts_with("verity.") {
return Some(OptionClass::Userspace);
}
if VFS_OPTIONS.contains(&name) {
return Some(OptionClass::Vfs);
}
if FS_OPTIONS.contains(&name) {
return Some(OptionClass::Filesystem);
}
if USER_OPTIONS.contains(&name) {
return Some(OptionClass::Userspace);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_options_with_quotes() {
let result = split_options(r#"context="a,b",noatime"#);
assert_eq!(result, vec![r#"context="a,b""#, "noatime"]);
}
#[test]
fn split_simple() {
assert_eq!(split_options("a,b,c"), vec!["a", "b", "c"]);
}
#[test]
fn strip_double_quotes() {
assert_eq!(strip_quotes("\"hello\""), "hello");
}
#[test]
fn strip_single_quotes() {
assert_eq!(strip_quotes("'hello'"), "hello");
}
#[test]
fn parse_simple_flag() {
let opts = Options::parse("defaults").unwrap();
assert!(opts.has("defaults"));
}
#[test]
fn parse_multiple_flags() {
let opts = Options::parse("rw,noatime,nofail").unwrap();
assert!(opts.has("rw"));
assert!(opts.has("noatime"));
assert!(opts.has("nofail"));
}
#[test]
fn parse_key_value() {
let opts = Options::parse("size=10G,mode=755").unwrap();
assert_eq!(opts.get("size"), Some("10G"));
assert_eq!(opts.get("mode"), Some("755"));
}
#[test]
fn last_option_wins() {
let opts = Options::parse("ro,rw").unwrap();
assert!(!opts.is_readonly());
let opts2 = Options::parse("rw,ro").unwrap();
assert!(opts2.is_readonly());
}
#[test]
fn quoted_value_preserves_comma() {
let opts =
Options::parse(r#"context="system_u:object_r:tmp_t:s0:c127,c456",noatime"#).unwrap();
assert_eq!(
opts.get("context"),
Some("system_u:object_r:tmp_t:s0:c127,c456")
);
assert!(opts.has("noatime"));
}
#[test]
fn single_quoted_value() {
let opts = Options::parse("key='value,with,commas'").unwrap();
assert_eq!(opts.get("key"), Some("value,with,commas"));
}
#[test]
fn parse_empty_options() {
let opts = Options::parse("").unwrap();
assert!(opts.is_empty());
}
#[test]
fn serialize_simple() {
let opts = Options::parse("rw,noatime").unwrap();
assert_eq!(opts.to_string(), "rw,noatime");
}
#[test]
fn serialize_with_value() {
let opts = Options::parse("size=10G,mode=755").unwrap();
assert_eq!(opts.to_string(), "size=10G,mode=755");
}
#[test]
fn serialize_roundtrip() {
let inputs = [
"defaults",
"rw,noatime,nofail",
"size=10G,mode=755",
"ro,nosuid,nodev",
];
for input in inputs {
let opts = Options::parse(input).unwrap();
assert_eq!(opts.to_string(), input, "roundtrip failed for: {input}");
}
}
#[test]
fn len_and_is_empty() {
let opts = Options::new();
assert!(opts.is_empty());
assert_eq!(opts.len(), 0);
let opts = Options::parse("a,b").unwrap();
assert_eq!(opts.len(), 2);
assert!(!opts.is_empty());
}
#[test]
fn contains_works() {
let opts = Options::parse("rw,noatime").unwrap();
assert!(opts.contains("rw"));
assert!(!opts.contains("foobar"));
}
#[test]
fn set_adds_option() {
let mut opts = Options::parse("rw").unwrap();
opts.set("noatime", None);
assert!(opts.has("noatime"));
}
#[test]
fn remove_option() {
let mut opts = Options::parse("rw,noatime,nofail").unwrap();
opts.remove("noatime");
assert!(!opts.has("noatime"));
assert!(opts.has("rw"));
assert!(opts.has("nofail"));
}
#[test]
fn append_option() {
let mut opts = Options::parse("rw").unwrap();
opts.append(OptItem::flag("noatime").unwrap());
assert_eq!(opts.to_string(), "rw,noatime");
}
#[test]
fn is_readonly() {
assert!(Options::parse("ro").unwrap().is_readonly());
assert!(!Options::parse("rw").unwrap().is_readonly());
}
#[test]
fn has_nofail() {
assert!(Options::parse("nofail").unwrap().has_nofail());
assert!(!Options::parse("defaults").unwrap().has_nofail());
}
#[test]
fn is_netdev() {
assert!(Options::parse("_netdev").unwrap().is_netdev());
assert!(!Options::parse("defaults").unwrap().is_netdev());
}
#[test]
fn option_classification() {
let opts = Options::parse("ro,noexec").unwrap();
for item in opts.vfs_options() {
assert_eq!(item.class(), Some(OptionClass::Vfs));
}
let opts = Options::parse("sync").unwrap();
for item in opts.fs_options() {
assert_eq!(item.class(), Some(OptionClass::Filesystem));
}
let opts = Options::parse("nofail,_netdev").unwrap();
for item in opts.user_options() {
assert_eq!(item.class(), Some(OptionClass::Userspace));
}
}
#[test]
fn unknown_option_is_none_class() {
let item = OptItem::flag("mycustomopt").unwrap();
assert_eq!(item.class(), None);
}
#[test]
fn optitem_empty_name_is_error() {
assert!(OptItem::flag("").is_err());
}
#[test]
fn iter_preserves_order() {
let opts = Options::parse("a,b,c,d").unwrap();
let names: Vec<&str> = opts.iter().map(|i| i.name()).collect();
assert_eq!(names, vec!["a", "b", "c", "d"]);
}
#[test]
fn defaults_constructor() {
let opts = Options::defaults();
assert!(opts.has("defaults"));
}
#[test]
fn from_str_works() {
let opts: Options = "rw,noatime".parse().unwrap();
assert!(opts.has("rw"));
assert!(opts.has("noatime"));
}
#[test]
fn set_returns_self_for_chaining() {
let mut opts = Options::parse("rw").unwrap();
opts.set("noatime", None).set("nofail", None);
assert!(opts.has("rw"));
assert!(opts.has("noatime"));
assert!(opts.has("nofail"));
}
#[test]
fn remove_returns_self_for_chaining() {
let mut opts = Options::parse("a,b,c").unwrap();
opts.remove("a").remove("b");
let names: Vec<&str> = opts.iter().map(|o| o.name()).collect();
assert_eq!(names, vec!["c"]);
}
}